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
28 changes: 28 additions & 0 deletions TUnit.Core/Interfaces/IAsyncDiscoveryInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace TUnit.Core.Interfaces;

/// <summary>
/// Defines a contract for types that require asynchronous initialization during test discovery.
/// </summary>
/// <remarks>
/// <para>
/// Unlike <see cref="IAsyncInitializer"/> which runs during test execution,
/// implementations of this interface are initialized during the test discovery phase.
/// This enables data sources (such as <c>InstanceMethodDataSource</c>) to access
/// fully-initialized objects when generating test cases.
/// </para>
/// <para>
/// Common use cases include:
/// <list type="bullet">
/// <item><description>Starting Docker containers before test case enumeration</description></item>
/// <item><description>Connecting to databases to discover parameterized test data</description></item>
/// <item><description>Initializing fixtures that provide data for test case generation</description></item>
/// </list>
/// </para>
/// <para>
/// This interface extends <see cref="IAsyncInitializer"/>, meaning the same
/// <see cref="IAsyncInitializer.InitializeAsync"/> method is used. The framework
/// guarantees exactly-once initialization semantics - objects will not be
/// re-initialized during test execution.
/// </para>
/// </remarks>
public interface IAsyncDiscoveryInitializer : IAsyncInitializer;
32 changes: 32 additions & 0 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ private async Task InitializeDeferredClassDataAsync(object?[] classData)
}
}

/// <summary>
/// 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).
/// </summary>
private static async Task InitializeDiscoveryObjectsAsync(object?[] classData)
{
if (classData == null || classData.Length == 0)
{
return;
}

foreach (var data in classData)
{
if (data is IAsyncDiscoveryInitializer && data is not IDataSourceAttribute)
{
// Uses ObjectInitializer which handles deduplication.
// This also prevents double-init during execution since ObjectInitializer
// tracks initialized objects.
await ObjectInitializer.InitializeAsync(data);
}
}
Comment on lines 81 to 90
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.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
}

private async Task<object> CreateInstance(TestMetadata metadata, Type[] resolvedClassGenericArgs, object?[] classData, TestBuilderContext builderContext)
{
// Initialize any deferred IAsyncInitializer objects in class data
Expand Down Expand Up @@ -206,6 +230,10 @@ public async Task<IEnumerable<AbstractExecutableTest>> 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;
Expand Down Expand Up @@ -1386,6 +1414,10 @@ public async IAsyncEnumerable<AbstractExecutableTest> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,7 @@ namespace .Interfaces
{
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
}
public interface IAsyncDiscoveryInitializer : . { }
public interface IAsyncInitializer
{
. InitializeAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,7 @@ namespace .Interfaces
{
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
}
public interface IAsyncDiscoveryInitializer : . { }
public interface IAsyncInitializer
{
. InitializeAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,7 @@ namespace .Interfaces
{
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
}
public interface IAsyncDiscoveryInitializer : . { }
public interface IAsyncInitializer
{
. InitializeAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2131,6 +2131,7 @@ namespace .Interfaces
{
.<<object?[]>> GenerateDataFactoriesAsync(.DataSourceContext context, .CancellationToken cancellationToken = default);
}
public interface IAsyncDiscoveryInitializer : . { }
public interface IAsyncInitializer
{
. InitializeAsync();
Expand Down
58 changes: 58 additions & 0 deletions TUnit.TestProject/Bugs/3997/Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using TUnit.Core;
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._3997;

/// <summary>
/// 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.
/// </summary>
public class SimulatedContainer : IAsyncDiscoveryInitializer
{
private readonly List<string> _data = [];
public bool IsInitialized { get; private set; }

public IReadOnlyList<string> 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;
}
}

/// <summary>
/// Tests that IAsyncDiscoveryInitializer is called during test discovery,
/// allowing InstanceMethodDataSource to access initialized data.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class DiscoveryInitializerTests
{
[ClassDataSource<SimulatedContainer>(Shared = SharedType.PerClass)]
public required SimulatedContainer Container { get; init; }

/// <summary>
/// This property provides test data from the initialized container.
/// The container MUST be initialized during discovery before this is evaluated.
/// </summary>
public IEnumerable<string> 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);
}
}
Loading