Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,119 @@
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 accesses data from that fixture
/// 3. The fixture should NOT be initialized during discovery - only during execution
///
/// 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
{
private readonly List<string> _testCases = [];

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

public bool IsInitialized { get; private set; }
public IReadOnlyList<string> TestCases => _testCases;

public Task InitializeAsync()
{
Interlocked.Increment(ref _initializationCount);
Console.WriteLine($"[SimulatedContainerFixture] InitializeAsync called on instance {InstanceId} (count: {_initializationCount})");

// Simulate container startup that populates test data
_testCases.AddRange(["TestCase1", "TestCase2", "TestCase3"]);
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.

Critical Test Design Flaw: _testCases starts as an empty list and is only populated during InitializeAsync(). After the fix, InitializeAsync() won't be called during discovery, so Fixture.TestCases will be empty when InstanceMethodDataSource evaluates TestExecutions at line 64. This means zero test cases will be generated, and the test will never execute.

This contradicts the test's purpose - the After(Class) hook at line 88 expects to verify that exactly 3 tests ran and initialization happened once. But with empty test cases, no tests will run, and the verification will never execute.

Solution: Initialize _testCases with default data before InitializeAsync(), so test discovery can proceed:

private readonly List<string> _testCases = ["TestCase1", "TestCase2", "TestCase3"];

public Task InitializeAsync()
{
    Interlocked.Increment(ref _initializationCount);
    // Do something else to prove initialization ran (e.g., set a flag, modify data, etc.)
    IsInitialized = true;
    return Task.CompletedTask;
}

Note: If users genuinely need data populated during discovery (like the original issue #3992), they should use IAsyncDiscoveryInitializer instead of IAsyncInitializer (see Bug 3997 test for correct pattern).

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

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

@copilot reanalyse

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.
/// With the bug, accessing this would trigger InitializeAsync() during discovery.
/// After the fix, InitializeAsync() should only be called during test execution.
/// </summary>
public IEnumerable<string> TestExecutions => Fixture.TestCases;

[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