diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index 001fb6077b..6802e5add5 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using TUnit.Core.Interfaces; namespace TUnit.Core.Helpers; @@ -177,8 +178,12 @@ public static T InvokeIfFunc(object? value) // If it's a Func, 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; } @@ -197,7 +202,11 @@ public static T InvokeIfFunc(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; } @@ -224,14 +233,22 @@ public static T InvokeIfFunc(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; } @@ -579,8 +596,12 @@ public static void RegisterTypeCreator(Func> { 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; } diff --git a/TUnit.TestProject/Bugs/3992/InstanceMethodDataSourceWithAsyncInitializerTests.cs b/TUnit.TestProject/Bugs/3992/InstanceMethodDataSourceWithAsyncInitializerTests.cs new file mode 100644 index 0000000000..733b3b6b72 --- /dev/null +++ b/TUnit.TestProject/Bugs/3992/InstanceMethodDataSourceWithAsyncInitializerTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Concurrent; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._3992; + +/// +/// 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. +/// +[EngineTest(ExpectedResult.Pass)] +public class InstanceMethodDataSourceWithAsyncInitializerTests +{ + private static int _initializationCount; + private static int _testExecutionCount; + private static readonly ConcurrentBag _observedInstanceIds = []; + + /// + /// Simulates a fixture like ClientServiceFixture that starts Docker containers. + /// Implements IAsyncInitializer (NOT IAsyncDiscoveryInitializer) because the user + /// does not want initialization during discovery. + /// + public class SimulatedContainerFixture : IAsyncInitializer + { + /// + /// Test case identifiers are PREDEFINED - they don't depend on initialization. + /// This allows discovery to enumerate test cases without initializing the fixture. + /// + private static readonly string[] PredefinedTestCases = ["TestCase1", "TestCase2", "TestCase3"]; + + /// + /// Unique identifier for this instance to verify sharing behavior. + /// + public Guid InstanceId { get; } = Guid.NewGuid(); + + public bool IsInitialized { get; private set; } + + /// + /// Returns predefined test case identifiers. These are available during discovery + /// WITHOUT requiring initialization. + /// + public IEnumerable GetTestCases() => PredefinedTestCases; + + 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(Shared = SharedType.PerClass)] + public required SimulatedContainerFixture Fixture { get; init; } + + /// + /// This property is accessed by InstanceMethodDataSource during discovery. + /// It returns predefined test case identifiers that don't require initialization. + /// The bug was that accessing this would trigger InitializeAsync() during discovery. + /// After the fix, InitializeAsync() should only be called during test execution. + /// + public IEnumerable TestExecutions => Fixture.GetTestCases(); + + [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(); + } +}