Skip to content

Commit c69e2e5

Browse files
authored
fix: prevent IAsyncInitializer from running during test discovery when using InstanceMethodDataSource (#4002)
* fix: prevent IAsyncInitializer from running during test discovery when using InstanceMethodDataSource * fix: prevent IAsyncInitializer from running during test discovery by using predefined test case identifiers
1 parent fdbf6a1 commit c69e2e5

2 files changed

Lines changed: 161 additions & 8 deletions

File tree

TUnit.Core/Helpers/DataSourceHelpers.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Concurrent;
33
using System.Diagnostics.CodeAnalysis;
44
using System.Runtime.CompilerServices;
5+
using TUnit.Core.Interfaces;
56

67
namespace TUnit.Core.Helpers;
78

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

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

183188
return actualData;
184189
}
@@ -197,7 +202,11 @@ public static T InvokeIfFunc<T>(object? value)
197202
if (enumerator.MoveNext())
198203
{
199204
var value = enumerator.Current;
200-
await ObjectInitializer.InitializeAsync(value);
205+
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
206+
if (value is IAsyncDiscoveryInitializer)
207+
{
208+
await ObjectInitializer.InitializeAsync(value);
209+
}
201210
return value;
202211
}
203212

@@ -224,14 +233,22 @@ public static T InvokeIfFunc<T>(object? value)
224233
if (enumerator.MoveNext())
225234
{
226235
var value = enumerator.Current;
227-
await ObjectInitializer.InitializeAsync(value);
236+
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
237+
if (value is IAsyncDiscoveryInitializer)
238+
{
239+
await ObjectInitializer.InitializeAsync(value);
240+
}
228241
return value;
229242
}
230243
return null;
231244
}
232245

233-
// For non-enumerable types, just initialize and return
234-
await ObjectInitializer.InitializeAsync(actualData);
246+
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
247+
// Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
248+
if (actualData is IAsyncDiscoveryInitializer)
249+
{
250+
await ObjectInitializer.InitializeAsync(actualData);
251+
}
235252
return actualData;
236253
}
237254

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

582-
// Initialize the value if it implements IAsyncInitializer
583-
await ObjectInitializer.InitializeAsync(value);
599+
// Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
600+
// Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
601+
if (value is IAsyncDiscoveryInitializer)
602+
{
603+
await ObjectInitializer.InitializeAsync(value);
604+
}
584605

585606
return value;
586607
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Collections.Concurrent;
2+
using TUnit.Core.Interfaces;
3+
using TUnit.TestProject.Attributes;
4+
5+
namespace TUnit.TestProject.Bugs._3992;
6+
7+
/// <summary>
8+
/// Regression test for issue #3992: IAsyncInitializer should not run during test discovery
9+
/// when using InstanceMethodDataSource with ClassDataSource.
10+
///
11+
/// This test replicates the user's scenario where:
12+
/// 1. A ClassDataSource fixture implements IAsyncInitializer (e.g., starts Docker containers)
13+
/// 2. An InstanceMethodDataSource returns predefined test case identifiers
14+
/// 3. The fixture should NOT be initialized during discovery - only during execution
15+
///
16+
/// The key insight is that test case IDENTIFIERS are known ahead of time (predefined),
17+
/// but the actual fixture initialization (Docker containers, DB connections, etc.)
18+
/// should only happen when tests actually execute.
19+
///
20+
/// The bug caused Docker containers to start during test discovery (e.g., in IDE or --list-tests),
21+
/// which was unexpected and resource-intensive.
22+
/// </summary>
23+
[EngineTest(ExpectedResult.Pass)]
24+
public class InstanceMethodDataSourceWithAsyncInitializerTests
25+
{
26+
private static int _initializationCount;
27+
private static int _testExecutionCount;
28+
private static readonly ConcurrentBag<Guid> _observedInstanceIds = [];
29+
30+
/// <summary>
31+
/// Simulates a fixture like ClientServiceFixture that starts Docker containers.
32+
/// Implements IAsyncInitializer (NOT IAsyncDiscoveryInitializer) because the user
33+
/// does not want initialization during discovery.
34+
/// </summary>
35+
public class SimulatedContainerFixture : IAsyncInitializer
36+
{
37+
/// <summary>
38+
/// Test case identifiers are PREDEFINED - they don't depend on initialization.
39+
/// This allows discovery to enumerate test cases without initializing the fixture.
40+
/// </summary>
41+
private static readonly string[] PredefinedTestCases = ["TestCase1", "TestCase2", "TestCase3"];
42+
43+
/// <summary>
44+
/// Unique identifier for this instance to verify sharing behavior.
45+
/// </summary>
46+
public Guid InstanceId { get; } = Guid.NewGuid();
47+
48+
public bool IsInitialized { get; private set; }
49+
50+
/// <summary>
51+
/// Returns predefined test case identifiers. These are available during discovery
52+
/// WITHOUT requiring initialization.
53+
/// </summary>
54+
public IEnumerable<string> GetTestCases() => PredefinedTestCases;
55+
56+
public Task InitializeAsync()
57+
{
58+
Interlocked.Increment(ref _initializationCount);
59+
Console.WriteLine($"[SimulatedContainerFixture] InitializeAsync called on instance {InstanceId} (count: {_initializationCount})");
60+
61+
// Simulate expensive container startup - this should NOT happen during discovery
62+
IsInitialized = true;
63+
64+
return Task.CompletedTask;
65+
}
66+
}
67+
68+
[ClassDataSource<SimulatedContainerFixture>(Shared = SharedType.PerClass)]
69+
public required SimulatedContainerFixture Fixture { get; init; }
70+
71+
/// <summary>
72+
/// This property is accessed by InstanceMethodDataSource during discovery.
73+
/// It returns predefined test case identifiers that don't require initialization.
74+
/// The bug was that accessing this would trigger InitializeAsync() during discovery.
75+
/// After the fix, InitializeAsync() should only be called during test execution.
76+
/// </summary>
77+
public IEnumerable<string> TestExecutions => Fixture.GetTestCases();
78+
79+
[Test]
80+
[InstanceMethodDataSource(nameof(TestExecutions))]
81+
public async Task Test_WithInstanceMethodDataSource_DoesNotInitializeDuringDiscovery(string testCase)
82+
{
83+
Interlocked.Increment(ref _testExecutionCount);
84+
85+
// Track this instance to verify sharing
86+
_observedInstanceIds.Add(Fixture.InstanceId);
87+
88+
// The fixture should be initialized by the time the test runs
89+
await Assert.That(Fixture.IsInitialized)
90+
.IsTrue()
91+
.Because("the fixture should be initialized before test execution");
92+
93+
await Assert.That(testCase)
94+
.IsNotNullOrEmpty()
95+
.Because("the test case data should be available");
96+
97+
Console.WriteLine($"[Test] Executed with testCase='{testCase}', instanceId={Fixture.InstanceId}, " +
98+
$"initCount={_initializationCount}, execCount={_testExecutionCount}");
99+
}
100+
101+
[After(Class)]
102+
public static async Task VerifyInitializationAndSharing()
103+
{
104+
// With SharedType.PerClass, the fixture should be initialized exactly ONCE
105+
// during test execution, NOT during discovery.
106+
//
107+
// Before the fix: _initializationCount would be 2+ (discovery + execution)
108+
// After the fix: _initializationCount should be exactly 1 (execution only)
109+
110+
Console.WriteLine($"[After(Class)] Final counts - init: {_initializationCount}, exec: {_testExecutionCount}");
111+
Console.WriteLine($"[After(Class)] Unique instance IDs observed: {_observedInstanceIds.Distinct().Count()}");
112+
113+
await Assert.That(_initializationCount)
114+
.IsEqualTo(1)
115+
.Because("IAsyncInitializer should only be called once during execution, not during discovery");
116+
117+
await Assert.That(_testExecutionCount)
118+
.IsEqualTo(3)
119+
.Because("there should be 3 test executions (one per test case)");
120+
121+
// Verify that all tests used the SAME fixture instance (SharedType.PerClass)
122+
var uniqueInstanceIds = _observedInstanceIds.Distinct().ToList();
123+
await Assert.That(uniqueInstanceIds)
124+
.HasCount().EqualTo(1)
125+
.Because("with SharedType.PerClass, all tests should share the same fixture instance");
126+
127+
// Reset for next run
128+
_initializationCount = 0;
129+
_testExecutionCount = 0;
130+
_observedInstanceIds.Clear();
131+
}
132+
}

0 commit comments

Comments
 (0)