diff --git a/TUnit.Core/Interfaces/IAsyncDiscoveryInitializer.cs b/TUnit.Core/Interfaces/IAsyncDiscoveryInitializer.cs new file mode 100644 index 0000000000..8c9f4f1dfb --- /dev/null +++ b/TUnit.Core/Interfaces/IAsyncDiscoveryInitializer.cs @@ -0,0 +1,28 @@ +namespace TUnit.Core.Interfaces; + +/// +/// Defines a contract for types that require asynchronous initialization during test discovery. +/// +/// +/// +/// Unlike which runs during test execution, +/// implementations of this interface are initialized during the test discovery phase. +/// This enables data sources (such as InstanceMethodDataSource) to access +/// fully-initialized objects when generating test cases. +/// +/// +/// Common use cases include: +/// +/// Starting Docker containers before test case enumeration +/// Connecting to databases to discover parameterized test data +/// Initializing fixtures that provide data for test case generation +/// +/// +/// +/// This interface extends , meaning the same +/// method is used. The framework +/// guarantees exactly-once initialization semantics - objects will not be +/// re-initialized during test execution. +/// +/// +public interface IAsyncDiscoveryInitializer : IAsyncInitializer; diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index b3ef936b88..30dc1bcb7e 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -66,6 +66,30 @@ private async Task InitializeDeferredClassDataAsync(object?[] classData) } } + /// + /// Initializes any IAsyncDiscoveryInitializer objects in class data during test discovery. + /// This is called BEFORE method data sources are evaluated, enabling data sources + /// to access initialized shared objects (like Docker containers). + /// + private static async Task InitializeDiscoveryObjectsAsync(object?[] classData) + { + if (classData == null || classData.Length == 0) + { + return; + } + + foreach (var data in classData) + { + if (data is IAsyncDiscoveryInitializer) + { + // Uses ObjectInitializer which handles deduplication. + // This also prevents double-init during execution since ObjectInitializer + // tracks initialized objects. + await ObjectInitializer.InitializeAsync(data); + } + } + } + private async Task CreateInstance(TestMetadata metadata, Type[] resolvedClassGenericArgs, object?[] classData, TestBuilderContext builderContext) { // Initialize any deferred IAsyncInitializer objects in class data @@ -206,6 +230,10 @@ public async Task> BuildTestsFromMetadataAsy var classDataResult = await classDataFactory() ?? []; var classData = DataUnwrapper.Unwrap(classDataResult); + // Initialize IAsyncDiscoveryInitializer objects before method data sources are evaluated. + // This enables InstanceMethodDataSource to access initialized shared objects. + await InitializeDiscoveryObjectsAsync(classData); + var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData); object? instanceForMethodDataSources = null; @@ -265,6 +293,12 @@ await _objectLifecycleService.RegisterObjectAsync( tempObjectBag, metadata.MethodMetadata, tempEvents); + + // Initialize the test class instance if it implements IAsyncDiscoveryInitializer + if (instanceForMethodDataSources is IAsyncDiscoveryInitializer) + { + await ObjectInitializer.InitializeAsync(instanceForMethodDataSources); + } } } catch (Exception ex) @@ -313,6 +347,9 @@ await _objectLifecycleService.RegisterObjectAsync( classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); + // Initialize any IAsyncDiscoveryInitializer objects in method data + await InitializeDiscoveryObjectsAsync(methodData); + // For concrete generic instantiations, check if the data is compatible with the expected types if (metadata.GenericMethodTypeArguments is { Length: > 0 }) { @@ -1386,6 +1423,10 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); + // Initialize IAsyncDiscoveryInitializer objects before method data sources are evaluated. + // This enables InstanceMethodDataSource to access initialized shared objects. + await InitializeDiscoveryObjectsAsync(classData); + // Handle instance creation for method data sources var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData); object? instanceForMethodDataSources = null; @@ -1410,6 +1451,12 @@ await _objectLifecycleService.RegisterObjectAsync( tempObjectBag, metadata.MethodMetadata, tempEvents); + + // Initialize the test class instance if it implements IAsyncDiscoveryInitializer + if (instanceForMethodDataSources is IAsyncDiscoveryInitializer) + { + await ObjectInitializer.InitializeAsync(instanceForMethodDataSources); + } } // Stream through method data sources @@ -1520,6 +1567,9 @@ await _objectLifecycleService.RegisterObjectAsync( var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); + // Initialize any IAsyncDiscoveryInitializer objects in method data + await InitializeDiscoveryObjectsAsync(methodData); + // Check data compatibility for generic methods if (metadata.GenericMethodTypeArguments is { Length: > 0 }) { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index a445e9e1f2..33684f6146 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2199,6 +2199,7 @@ namespace .Interfaces { .<> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default); } + public interface IAsyncDiscoveryInitializer : . { } public interface IAsyncInitializer { . InitializeAsync(); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index a1f6b35877..d97c4f38b5 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2199,6 +2199,7 @@ namespace .Interfaces { .<> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default); } + public interface IAsyncDiscoveryInitializer : . { } public interface IAsyncInitializer { . InitializeAsync(); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index e295333e1a..5280c9595c 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2199,6 +2199,7 @@ namespace .Interfaces { .<> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default); } + public interface IAsyncDiscoveryInitializer : . { } public interface IAsyncInitializer { . InitializeAsync(); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index ee302f89a4..538bd3ca00 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2131,6 +2131,7 @@ namespace .Interfaces { .<> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default); } + public interface IAsyncDiscoveryInitializer : . { } public interface IAsyncInitializer { . InitializeAsync(); diff --git a/TUnit.TestProject/Bugs/3997/Tests.cs b/TUnit.TestProject/Bugs/3997/Tests.cs new file mode 100644 index 0000000000..9fe62ef189 --- /dev/null +++ b/TUnit.TestProject/Bugs/3997/Tests.cs @@ -0,0 +1,58 @@ +using TUnit.Core; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._3997; + +/// +/// Simulates a data source (like a Docker container) that needs initialization during test discovery +/// so that InstanceMethodDataSource can access its data when generating test cases. +/// +public class SimulatedContainer : IAsyncDiscoveryInitializer +{ + private readonly List _data = []; + public bool IsInitialized { get; private set; } + + public IReadOnlyList Data => _data; + + public Task InitializeAsync() + { + if (IsInitialized) + { + throw new InvalidOperationException("Container already initialized! InitializeAsync should only be called once."); + } + + // Simulate container startup and data population + _data.AddRange(["TestCase1", "TestCase2", "TestCase3"]); + IsInitialized = true; + return Task.CompletedTask; + } +} + +/// +/// Tests that IAsyncDiscoveryInitializer is called during test discovery, +/// allowing InstanceMethodDataSource to access initialized data. +/// +[EngineTest(ExpectedResult.Pass)] +public class DiscoveryInitializerTests +{ + [ClassDataSource(Shared = SharedType.PerClass)] + public required SimulatedContainer Container { get; init; } + + /// + /// This property provides test data from the initialized container. + /// The container MUST be initialized during discovery before this is evaluated. + /// + public IEnumerable TestCases => Container.Data; + + [Test] + [InstanceMethodDataSource(nameof(TestCases))] + public async Task TestWithContainerData(string testCase) + { + // Container should be initialized + await Assert.That(Container.IsInitialized).IsTrue(); + + // testCase should be one of the container's data items + await Assert.That(Container.Data).Contains(testCase); + } +}