Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions TUnit.Core/Helpers/DataSourceHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Helpers;

Expand Down Expand Up @@ -177,8 +178,12 @@ public static T InvokeIfFunc<T>(object? value)
// If it's a Func<TResult>, 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;
}
Expand All @@ -197,7 +202,11 @@ public static T InvokeIfFunc<T>(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;
}

Expand All @@ -224,14 +233,22 @@ public static T InvokeIfFunc<T>(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;
}

Expand Down Expand Up @@ -579,8 +596,12 @@ public static void RegisterTypeCreator<T>(Func<MethodMetadata, string, Task<T>>
{
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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Collections.Concurrent;
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._3992;

/// <summary>
/// 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.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class InstanceMethodDataSourceWithAsyncInitializerTests
{
private static int _initializationCount;
private static int _testExecutionCount;
private static readonly ConcurrentBag<Guid> _observedInstanceIds = [];

/// <summary>
/// Simulates a fixture like ClientServiceFixture that starts Docker containers.
/// Implements IAsyncInitializer (NOT IAsyncDiscoveryInitializer) because the user
/// does not want initialization during discovery.
/// </summary>
public class SimulatedContainerFixture : IAsyncInitializer
{
/// <summary>
/// Test case identifiers are PREDEFINED - they don't depend on initialization.
/// This allows discovery to enumerate test cases without initializing the fixture.
/// </summary>
private static readonly string[] PredefinedTestCases = ["TestCase1", "TestCase2", "TestCase3"];

/// <summary>
/// Unique identifier for this instance to verify sharing behavior.
/// </summary>
public Guid InstanceId { get; } = Guid.NewGuid();

public bool IsInitialized { get; private set; }

/// <summary>
/// Returns predefined test case identifiers. These are available during discovery
/// WITHOUT requiring initialization.
/// </summary>
public IEnumerable<string> 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<SimulatedContainerFixture>(Shared = SharedType.PerClass)]
public required SimulatedContainerFixture Fixture { get; init; }

/// <summary>
/// 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.
/// </summary>
public IEnumerable<string> TestExecutions => Fixture.GetTestCases();

[Test]
[InstanceMethodDataSource(nameof(TestExecutions))]
public async Task Test_WithInstanceMethodDataSource_DoesNotInitializeDuringDiscovery(string testCase)
{
Interlocked.Increment(ref _testExecutionCount);
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write to static field from instance method, property, or constructor.

Copilot uses AI. Check for mistakes.

// 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();
}
}
Loading