Skip to content

Commit fdbf6a1

Browse files
authored
+semver:minor - feat: add IAsyncDiscoveryInitializer for asynchronous initialization during test discovery (#4000)
* feat: add IAsyncDiscoveryInitializer for asynchronous initialization during test discovery * feat: initialize IAsyncDiscoveryInitializer instances during test execution
1 parent eeea476 commit fdbf6a1

7 files changed

Lines changed: 140 additions & 0 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace TUnit.Core.Interfaces;
2+
3+
/// <summary>
4+
/// Defines a contract for types that require asynchronous initialization during test discovery.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// Unlike <see cref="IAsyncInitializer"/> which runs during test execution,
9+
/// implementations of this interface are initialized during the test discovery phase.
10+
/// This enables data sources (such as <c>InstanceMethodDataSource</c>) to access
11+
/// fully-initialized objects when generating test cases.
12+
/// </para>
13+
/// <para>
14+
/// Common use cases include:
15+
/// <list type="bullet">
16+
/// <item><description>Starting Docker containers before test case enumeration</description></item>
17+
/// <item><description>Connecting to databases to discover parameterized test data</description></item>
18+
/// <item><description>Initializing fixtures that provide data for test case generation</description></item>
19+
/// </list>
20+
/// </para>
21+
/// <para>
22+
/// This interface extends <see cref="IAsyncInitializer"/>, meaning the same
23+
/// <see cref="IAsyncInitializer.InitializeAsync"/> method is used. The framework
24+
/// guarantees exactly-once initialization semantics - objects will not be
25+
/// re-initialized during test execution.
26+
/// </para>
27+
/// </remarks>
28+
public interface IAsyncDiscoveryInitializer : IAsyncInitializer;

TUnit.Engine/Building/TestBuilder.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ private async Task InitializeDeferredClassDataAsync(object?[] classData)
6666
}
6767
}
6868

69+
/// <summary>
70+
/// Initializes any IAsyncDiscoveryInitializer objects in class data during test discovery.
71+
/// This is called BEFORE method data sources are evaluated, enabling data sources
72+
/// to access initialized shared objects (like Docker containers).
73+
/// </summary>
74+
private static async Task InitializeDiscoveryObjectsAsync(object?[] classData)
75+
{
76+
if (classData == null || classData.Length == 0)
77+
{
78+
return;
79+
}
80+
81+
foreach (var data in classData)
82+
{
83+
if (data is IAsyncDiscoveryInitializer)
84+
{
85+
// Uses ObjectInitializer which handles deduplication.
86+
// This also prevents double-init during execution since ObjectInitializer
87+
// tracks initialized objects.
88+
await ObjectInitializer.InitializeAsync(data);
89+
}
90+
}
91+
}
92+
6993
private async Task<object> CreateInstance(TestMetadata metadata, Type[] resolvedClassGenericArgs, object?[] classData, TestBuilderContext builderContext)
7094
{
7195
// Initialize any deferred IAsyncInitializer objects in class data
@@ -206,6 +230,10 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
206230
var classDataResult = await classDataFactory() ?? [];
207231
var classData = DataUnwrapper.Unwrap(classDataResult);
208232

233+
// Initialize IAsyncDiscoveryInitializer objects before method data sources are evaluated.
234+
// This enables InstanceMethodDataSource to access initialized shared objects.
235+
await InitializeDiscoveryObjectsAsync(classData);
236+
209237
var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData);
210238

211239
object? instanceForMethodDataSources = null;
@@ -265,6 +293,12 @@ await _objectLifecycleService.RegisterObjectAsync(
265293
tempObjectBag,
266294
metadata.MethodMetadata,
267295
tempEvents);
296+
297+
// Initialize the test class instance if it implements IAsyncDiscoveryInitializer
298+
if (instanceForMethodDataSources is IAsyncDiscoveryInitializer)
299+
{
300+
await ObjectInitializer.InitializeAsync(instanceForMethodDataSources);
301+
}
268302
}
269303
}
270304
catch (Exception ex)
@@ -313,6 +347,9 @@ await _objectLifecycleService.RegisterObjectAsync(
313347
classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []);
314348
var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters);
315349

350+
// Initialize any IAsyncDiscoveryInitializer objects in method data
351+
await InitializeDiscoveryObjectsAsync(methodData);
352+
316353
// For concrete generic instantiations, check if the data is compatible with the expected types
317354
if (metadata.GenericMethodTypeArguments is { Length: > 0 })
318355
{
@@ -1386,6 +1423,10 @@ public async IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
13861423

13871424
var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []);
13881425

1426+
// Initialize IAsyncDiscoveryInitializer objects before method data sources are evaluated.
1427+
// This enables InstanceMethodDataSource to access initialized shared objects.
1428+
await InitializeDiscoveryObjectsAsync(classData);
1429+
13891430
// Handle instance creation for method data sources
13901431
var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData);
13911432
object? instanceForMethodDataSources = null;
@@ -1410,6 +1451,12 @@ await _objectLifecycleService.RegisterObjectAsync(
14101451
tempObjectBag,
14111452
metadata.MethodMetadata,
14121453
tempEvents);
1454+
1455+
// Initialize the test class instance if it implements IAsyncDiscoveryInitializer
1456+
if (instanceForMethodDataSources is IAsyncDiscoveryInitializer)
1457+
{
1458+
await ObjectInitializer.InitializeAsync(instanceForMethodDataSources);
1459+
}
14131460
}
14141461

14151462
// Stream through method data sources
@@ -1520,6 +1567,9 @@ await _objectLifecycleService.RegisterObjectAsync(
15201567

15211568
var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters);
15221569

1570+
// Initialize any IAsyncDiscoveryInitializer objects in method data
1571+
await InitializeDiscoveryObjectsAsync(methodData);
1572+
15231573
// Check data compatibility for generic methods
15241574
if (metadata.GenericMethodTypeArguments is { Length: > 0 })
15251575
{

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,7 @@ namespace .Interfaces
21992199
{
22002200
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
22012201
}
2202+
public interface IAsyncDiscoveryInitializer : . { }
22022203
public interface IAsyncInitializer
22032204
{
22042205
. InitializeAsync();

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,7 @@ namespace .Interfaces
21992199
{
22002200
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
22012201
}
2202+
public interface IAsyncDiscoveryInitializer : . { }
22022203
public interface IAsyncInitializer
22032204
{
22042205
. InitializeAsync();

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,7 @@ namespace .Interfaces
21992199
{
22002200
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
22012201
}
2202+
public interface IAsyncDiscoveryInitializer : . { }
22022203
public interface IAsyncInitializer
22032204
{
22042205
. InitializeAsync();

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2131,6 +2131,7 @@ namespace .Interfaces
21312131
{
21322132
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
21332133
}
2134+
public interface IAsyncDiscoveryInitializer : . { }
21342135
public interface IAsyncInitializer
21352136
{
21362137
. InitializeAsync();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using TUnit.Core;
2+
using TUnit.Core.Interfaces;
3+
using TUnit.TestProject.Attributes;
4+
5+
namespace TUnit.TestProject.Bugs._3997;
6+
7+
/// <summary>
8+
/// Simulates a data source (like a Docker container) that needs initialization during test discovery
9+
/// so that InstanceMethodDataSource can access its data when generating test cases.
10+
/// </summary>
11+
public class SimulatedContainer : IAsyncDiscoveryInitializer
12+
{
13+
private readonly List<string> _data = [];
14+
public bool IsInitialized { get; private set; }
15+
16+
public IReadOnlyList<string> Data => _data;
17+
18+
public Task InitializeAsync()
19+
{
20+
if (IsInitialized)
21+
{
22+
throw new InvalidOperationException("Container already initialized! InitializeAsync should only be called once.");
23+
}
24+
25+
// Simulate container startup and data population
26+
_data.AddRange(["TestCase1", "TestCase2", "TestCase3"]);
27+
IsInitialized = true;
28+
return Task.CompletedTask;
29+
}
30+
}
31+
32+
/// <summary>
33+
/// Tests that IAsyncDiscoveryInitializer is called during test discovery,
34+
/// allowing InstanceMethodDataSource to access initialized data.
35+
/// </summary>
36+
[EngineTest(ExpectedResult.Pass)]
37+
public class DiscoveryInitializerTests
38+
{
39+
[ClassDataSource<SimulatedContainer>(Shared = SharedType.PerClass)]
40+
public required SimulatedContainer Container { get; init; }
41+
42+
/// <summary>
43+
/// This property provides test data from the initialized container.
44+
/// The container MUST be initialized during discovery before this is evaluated.
45+
/// </summary>
46+
public IEnumerable<string> TestCases => Container.Data;
47+
48+
[Test]
49+
[InstanceMethodDataSource(nameof(TestCases))]
50+
public async Task TestWithContainerData(string testCase)
51+
{
52+
// Container should be initialized
53+
await Assert.That(Container.IsInitialized).IsTrue();
54+
55+
// testCase should be one of the container's data items
56+
await Assert.That(Container.Data).Contains(testCase);
57+
}
58+
}

0 commit comments

Comments
 (0)