Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,17 @@ private static void TraverseInitializerProperties(
foreach (var property in properties)
{
cancellationToken.ThrowIfCancellationRequested();

// Only access properties whose declared type could be IAsyncInitializer.
// This prevents triggering side effects from unrelated property getters
// (e.g., WebApplicationFactory.Server which starts the test host when accessed).
// Properties typed as object/interfaces not extending IAsyncInitializer won't be
// checked - users should properly type their properties or use data source attributes.
if (!typeof(IAsyncInitializer).IsAssignableFrom(property.PropertyType))
{
continue;
}

try
{
var value = property.GetValue(obj);
Expand Down
132 changes: 132 additions & 0 deletions TUnit.TestProject/Bugs/4049/PropertyGetterSideEffectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._4049;

/// <summary>
/// Regression test for issue #4049: Property getters should not be called during discovery
/// for properties that don't return IAsyncInitializer.
///
/// This simulates the WebApplicationFactory.Server scenario where accessing a property
/// causes side effects (like starting a test server) before the object is configured.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class PropertyGetterSideEffectTests
Comment on lines +13 to +14
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test classes use static counters but lack the NotInParallel attribute. If these tests run concurrently, the static fields _sideEffectCount, _parentInitCount, and _childInitCount could experience race conditions, leading to flaky test failures. Add [NotInParallel] attribute to both test classes to ensure sequential execution.

Copilot uses AI. Check for mistakes.
{
private static int _sideEffectCount = 0;

/// <summary>
/// Simulates a class like WebApplicationFactory where accessing certain properties
/// triggers initialization (like building the test host).
/// </summary>
public class FixtureWithSideEffects : IAsyncInitializer
{
private object? _server;

/// <summary>
/// Simulates WebApplicationFactory.Server - accessing this property has side effects.
/// This property type is 'object', NOT IAsyncInitializer, so TUnit should NOT access it.
/// </summary>
public object Server
{
get
{
Interlocked.Increment(ref _sideEffectCount);
Console.WriteLine($"Server getter called! Side effect count: {_sideEffectCount}");
_server ??= new object(); // Side effect: lazy initialization
return _server;
}
}

public bool IsInitialized { get; private set; }

public Task InitializeAsync()
{
IsInitialized = true;
Console.WriteLine("FixtureWithSideEffects.InitializeAsync called");
return Task.CompletedTask;
}
}

[Test]
[ClassDataSource<FixtureWithSideEffects>]
public async Task PropertyGetters_WithSideEffects_ShouldNotBeCalledDuringDiscovery(FixtureWithSideEffects fixture)
{
// Verify the fixture was properly initialized
await Assert.That(fixture.IsInitialized).IsTrue();

// The Server property getter should NOT have been called during discovery.
// It should only be called if the test explicitly accesses it.
// At this point, we haven't accessed Server, so count should be 0.
await Assert.That(_sideEffectCount).IsEqualTo(0)
.Because("TUnit should not access non-IAsyncInitializer property getters during discovery");

// Now let's explicitly access the Server to verify it works
var server = fixture.Server;
await Assert.That(server).IsNotNull();
await Assert.That(_sideEffectCount).IsEqualTo(1);

Console.WriteLine($"Test passed: Server getter was only called when explicitly accessed (count: {_sideEffectCount})");
}
}

/// <summary>
/// Test to verify that nested IAsyncInitializer properties ARE still discovered
/// when they are properly typed.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test class uses static counters but lacks the NotInParallel attribute. If these tests run concurrently with other tests, the static fields _parentInitCount and _childInitCount could experience race conditions, leading to flaky test failures. Add [NotInParallel] attribute to ensure sequential execution.

Suggested change
[EngineTest(ExpectedResult.Pass)]
[EngineTest(ExpectedResult.Pass)]
[NotInParallel]

Copilot uses AI. Check for mistakes.
public class NestedAsyncInitializerDiscoveryTests
{
private static int _parentInitCount = 0;
private static int _childInitCount = 0;

public class ChildInitializer : IAsyncInitializer
{
public bool IsInitialized { get; private set; }

public Task InitializeAsync()
{
Interlocked.Increment(ref _childInitCount);
IsInitialized = true;
Console.WriteLine($"ChildInitializer.InitializeAsync called (count: {_childInitCount})");
return Task.CompletedTask;
}
}

public class ParentWithNestedInitializer : IAsyncInitializer
{
/// <summary>
/// This property IS typed as IAsyncInitializer, so TUnit SHOULD access it
/// and discover the nested initializer for proper lifecycle management.
/// </summary>
public IAsyncInitializer NestedInitializer { get; } = new ChildInitializer();

public bool IsInitialized { get; private set; }

public Task InitializeAsync()
{
Interlocked.Increment(ref _parentInitCount);
IsInitialized = true;
Console.WriteLine($"ParentWithNestedInitializer.InitializeAsync called (count: {_parentInitCount})");
return Task.CompletedTask;
}
}

[Test]
[ClassDataSource<ParentWithNestedInitializer>]
public async Task NestedInitializers_WhenProperlyTyped_ShouldBeDiscoveredAndInitialized(ParentWithNestedInitializer parent)
{
// Verify parent was initialized
await Assert.That(parent.IsInitialized).IsTrue();
await Assert.That(_parentInitCount).IsGreaterThan(0);

// Verify child was discovered and initialized
var child = parent.NestedInitializer as ChildInitializer;
await Assert.That(child).IsNotNull();
await Assert.That(child!.IsInitialized).IsTrue()
.Because("TUnit should discover and initialize properly-typed IAsyncInitializer properties");
await Assert.That(_childInitCount).IsGreaterThan(0);

Console.WriteLine($"Test passed: Both parent ({_parentInitCount}) and child ({_childInitCount}) were initialized");
}
}
Loading