diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 61ce4b2571..af1b1710d9 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -138,6 +138,12 @@ internal override void SetAsyncLocalContext() internal bool RunOnTestDiscovery { get; set; } + /// + /// Indicates whether this test is reusing the discovery-time instance instead of creating a new instance. + /// When true, property resolution and initialization should be skipped since the instance is already prepared. + /// + internal bool IsDiscoveryInstanceReused { get; set; } + public object Lock { get; } = new(); diff --git a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs index c50bda428e..8f75be2a0e 100644 --- a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs +++ b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs @@ -14,8 +14,9 @@ internal interface ITestBuilder /// The test metadata /// The test data /// + /// Whether this test is reusing the discovery instance /// An executable test ready for execution - Task BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext); + Task BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false); /// /// Builds all executable tests from a single TestMetadata using its DataCombinationGenerator delegate. diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 628c8e6449..b3ef936b88 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -209,6 +209,7 @@ public async Task> BuildTestsFromMetadataAsy var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData); object? instanceForMethodDataSources = null; + var discoveryInstanceUsed = false; if (needsInstanceForMethodDataSources) { @@ -382,10 +383,21 @@ await _objectLifecycleService.RegisterObjectAsync( var basicSkipReason = GetBasicSkipReason(metadata, attributes); Func> instanceFactory; + bool isReusingDiscoveryInstance = false; + if (basicSkipReason is { Length: > 0 }) { instanceFactory = () => Task.FromResult(SkippedTestInstance.Instance); } + else if (methodDataLoopIndex == 1 && i == 0 && instanceForMethodDataSources != null && !discoveryInstanceUsed) + { + // Reuse the discovery instance for the first test to avoid duplicate initialization + var capturedInstance = instanceForMethodDataSources; + discoveryInstanceUsed = true; + isReusingDiscoveryInstance = true; + + instanceFactory = () => Task.FromResult(capturedInstance); + } else { var capturedMetadata = metadata; @@ -420,7 +432,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - var test = await BuildTestAsync(metadata, testData, testSpecificContext); + var test = await BuildTestAsync(metadata, testData, testSpecificContext, isReusingDiscoveryInstance); // If we have a basic skip reason, set it immediately if (!string.IsNullOrEmpty(basicSkipReason)) @@ -799,7 +811,7 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hook discovery service handles mode-specific logic; reflection calls suppressed in AOT mode")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Hook discovery service handles mode-specific logic; dynamic code suppressed in AOT mode")] - public async Task BuildTestAsync(TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext) + public async Task BuildTestAsync(TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false) { // Discover instance hooks for closed generic types (no-op in source gen mode) if (metadata.TestClassType is { IsGenericType: true, IsGenericTypeDefinition: false }) @@ -811,6 +823,9 @@ public async Task BuildTestAsync(TestMetadata metadata, var context = await CreateTestContextAsync(testId, metadata, testData, testBuilderContext); + // Mark if this test is reusing the discovery instance (already initialized) + context.IsDiscoveryInstanceReused = isReusingDiscoveryInstance; + context.Metadata.TestDetails.ClassInstance = PlaceholderInstance.Instance; // Arguments will be tracked by TestArgumentTrackingService during TestRegistered event diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index 55b6ff5b0c..249f19207b 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -39,6 +39,12 @@ public async Task ResolveAndCachePropertiesAsync( TestContextEvents events, TestContext testContext) { + // Skip property resolution if this test is reusing the discovery instance (already initialized) + if (testContext.IsDiscoveryInstanceReused) + { + return; + } + var plan = PropertyInjectionCache.GetOrCreatePlan(testClassType); if (!plan.HasProperties) diff --git a/TUnit.TestProject/Bugs/3993/IAsyncInitializerTests.cs b/TUnit.TestProject/Bugs/3993/IAsyncInitializerTests.cs new file mode 100644 index 0000000000..58b07b362a --- /dev/null +++ b/TUnit.TestProject/Bugs/3993/IAsyncInitializerTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._3993; + +/// +/// Regression test for issue #3993: IAsyncInitializer called 4 times instead of 3 +/// when using ClassDataSource with MethodDataSource that accesses instance properties. +/// +[EngineTest(ExpectedResult.Pass)] +public class IAsyncInitializerTests +{ + [ClassDataSource(Shared = SharedType.None)] + public required PerTestCaseSource PerTestCaseSource { get; init; } + + public IEnumerable PerTestCaseStrings() => PerTestCaseSource.MyString; + + [Test] + [MethodDataSource(nameof(PerTestCaseStrings))] + public async Task ForNTestCases_ShouldInitNTimes(string s) + { + await Assert.That(s).IsNotNull(); + + // Each of the 3 test cases should have its own isolated PerTestCaseSource instance + // Since Shared = SharedType.None, each test gets a new instance + // Therefore, InitializeAsync should be called exactly 3 times (once per test) + // NOT 4 times (which would include the discovery instance) + await Assert + .That(PerTestCaseSource.SetUps) + .IsEqualTo(3) + .Because("each of the 3 test cases should be wrapped in its own isolated test class, " + + "causing a call to async init, but no more than those three calls should be needed. " + + "The discovery-time instance should NOT be initialized."); + } + + [After(Class)] + public static void ResetCounter() + { + // Reset for next run + PerTestCaseSource.Reset(); + } +} + +public class PerTestCaseSource : IAsyncInitializer +{ + public string[] MyString = []; + private static int _setUps = 0; + public static int SetUps => _setUps; + + public Task InitializeAsync() + { + Interlocked.Increment(ref _setUps); + MyString = ["123", "321", "9210"]; + return Task.CompletedTask; + } + + public static void Reset() + { + _setUps = 0; + } +}