Skip to content
Closed
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
37 changes: 29 additions & 8 deletions TUnit.Core/Helpers/DataSourceHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Helpers;

Expand Down Expand Up @@ -177,8 +178,12 @@ public static T InvokeIfFunc<T>(object? value)
// If it's a Func<TResult>, invoke it first
var actualData = InvokeIfFunc(data);

// Initialize the object if it implements IAsyncInitializer
await ObjectInitializer.InitializeAsync(actualData);
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
// Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
if (actualData is IAsyncDiscoveryInitializer)
{
await ObjectInitializer.InitializeAsync(actualData);
}

return actualData;
}
Expand All @@ -197,7 +202,11 @@ public static T InvokeIfFunc<T>(object? value)
if (enumerator.MoveNext())
{
var value = enumerator.Current;
await ObjectInitializer.InitializeAsync(value);
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
if (value is IAsyncDiscoveryInitializer)
{
await ObjectInitializer.InitializeAsync(value);
}
return value;
}

Expand All @@ -224,14 +233,22 @@ public static T InvokeIfFunc<T>(object? value)
if (enumerator.MoveNext())
{
var value = enumerator.Current;
await ObjectInitializer.InitializeAsync(value);
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
if (value is IAsyncDiscoveryInitializer)
{
await ObjectInitializer.InitializeAsync(value);
}
return value;
}
return null;
}

// For non-enumerable types, just initialize and return
await ObjectInitializer.InitializeAsync(actualData);
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
// Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
if (actualData is IAsyncDiscoveryInitializer)
{
await ObjectInitializer.InitializeAsync(actualData);
}
return actualData;
}

Expand Down Expand Up @@ -579,8 +596,12 @@ public static void RegisterTypeCreator<T>(Func<MethodMetadata, string, Task<T>>
{
var value = args[0];

// Initialize the value if it implements IAsyncInitializer
await ObjectInitializer.InitializeAsync(value);
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
// Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
if (value is IAsyncDiscoveryInitializer)
{
await ObjectInitializer.InitializeAsync(value);
}

return value;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Collections.Concurrent;
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._3992;

/// <summary>
/// Regression test for issue #3992: IAsyncInitializer should not run during test discovery
/// when using InstanceMethodDataSource with ClassDataSource.
///
/// This test replicates the user's scenario where:
/// 1. A ClassDataSource fixture implements IAsyncInitializer (e.g., starts Docker containers)
/// 2. An InstanceMethodDataSource returns predefined test case identifiers
/// 3. The fixture should NOT be initialized during discovery - only during execution
///
/// The key insight is that test case IDENTIFIERS are known ahead of time (predefined),
/// but the actual fixture initialization (Docker containers, DB connections, etc.)
/// should only happen when tests actually execute.
///
/// The bug caused Docker containers to start during test discovery (e.g., in IDE or --list-tests),
/// which was unexpected and resource-intensive.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class InstanceMethodDataSourceWithAsyncInitializerTests
{
private static int _initializationCount;
private static int _testExecutionCount;
private static readonly ConcurrentBag<Guid> _observedInstanceIds = [];

/// <summary>
/// Simulates a fixture like ClientServiceFixture that starts Docker containers.
/// Implements IAsyncInitializer (NOT IAsyncDiscoveryInitializer) because initialization
/// should only happen during test execution, not during discovery.
///
/// With the engine fix in DataSourceHelpers.cs, fixtures implementing IAsyncInitializer
/// are NOT initialized during discovery - only during test execution.
/// </summary>
public class SimulatedContainerFixture : IAsyncInitializer
{
/// <summary>
/// Unique identifier for this instance to verify sharing behavior.
/// </summary>
public Guid InstanceId { get; } = Guid.NewGuid();

public bool IsInitialized { get; private set; }

public Task InitializeAsync()
{
Interlocked.Increment(ref _initializationCount);
Console.WriteLine($"[SimulatedContainerFixture] InitializeAsync called on instance {InstanceId} (count: {_initializationCount})");

// Simulate expensive container startup - this should NOT happen during discovery
IsInitialized = true;

return Task.CompletedTask;
}
}

[ClassDataSource<SimulatedContainerFixture>(Shared = SharedType.PerClass)]
public required SimulatedContainerFixture Fixture { get; init; }

/// <summary>
/// This property is accessed by InstanceMethodDataSource during discovery (compile-time).
/// It MUST return test case identifiers WITHOUT accessing the fixture, because:
/// 1. Source generation happens at compile-time, not runtime
/// 2. The fixture is created at runtime via ClassDataSource
/// 3. Accessing runtime properties during compilation is impossible
///
/// Test case identifiers are predefined here, and the test verifies that the fixture
/// (which implements IAsyncInitializer) is only initialized during test execution,
/// not during discovery.
/// </summary>
public IEnumerable<string> TestExecutions => ["TestCase1", "TestCase2", "TestCase3"];

[Test]
[InstanceMethodDataSource(nameof(TestExecutions))]
public async Task Test_WithInstanceMethodDataSource_DoesNotInitializeDuringDiscovery(string testCase)
{
Interlocked.Increment(ref _testExecutionCount);

// Track this instance to verify sharing
_observedInstanceIds.Add(Fixture.InstanceId);

// The fixture should be initialized by the time the test runs
await Assert.That(Fixture.IsInitialized)
.IsTrue()
.Because("the fixture should be initialized before test execution");

await Assert.That(testCase)
.IsNotNullOrEmpty()
.Because("the test case data should be available");

Console.WriteLine($"[Test] Executed with testCase='{testCase}', instanceId={Fixture.InstanceId}, " +
$"initCount={_initializationCount}, execCount={_testExecutionCount}");
}

[After(Class)]
public static async Task VerifyInitializationAndSharing()
{
// With SharedType.PerClass, the fixture should be initialized exactly ONCE
// during test execution, NOT during discovery.
//
// Before the fix: _initializationCount would be 2+ (discovery + execution)
// After the fix: _initializationCount should be exactly 1 (execution only)

Console.WriteLine($"[After(Class)] Final counts - init: {_initializationCount}, exec: {_testExecutionCount}");
Console.WriteLine($"[After(Class)] Unique instance IDs observed: {_observedInstanceIds.Distinct().Count()}");

await Assert.That(_initializationCount)
.IsEqualTo(1)
.Because("IAsyncInitializer should only be called once during execution, not during discovery");

await Assert.That(_testExecutionCount)
.IsEqualTo(3)
.Because("there should be 3 test executions (one per test case)");

// Verify that all tests used the SAME fixture instance (SharedType.PerClass)
var uniqueInstanceIds = _observedInstanceIds.Distinct().ToList();
await Assert.That(uniqueInstanceIds)
.HasCount().EqualTo(1)
.Because("with SharedType.PerClass, all tests should share the same fixture instance");

// Reset for next run
_initializationCount = 0;
_testExecutionCount = 0;
_observedInstanceIds.Clear();
}
}