From 024a2ca55e1fdbc669877e6854fcb4002605da80 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 7 Dec 2025 11:49:05 +0000
Subject: [PATCH 01/20] feat: implement IAsyncDiscoveryInitializer and related
classes for improved test discovery handling
---
TUnit.Engine/Building/TestBuilder.cs | 8 +++-
TUnit.Engine/Services/PropertyInjector.cs | 19 ++++++++-
TUnit.TestProject/Bugs/3992/BugRecreation.cs | 41 +++++++++++++++++++
TUnit.TestProject/Bugs/3992/DummyContainer.cs | 22 ++++++++++
4 files changed, 86 insertions(+), 4 deletions(-)
create mode 100644 TUnit.TestProject/Bugs/3992/BugRecreation.cs
create mode 100644 TUnit.TestProject/Bugs/3992/DummyContainer.cs
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 30dc1bcb7e..c94a4b6ded 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -45,7 +45,9 @@ public TestBuilder(
}
///
- /// Initializes any IAsyncInitializer objects in class data that were deferred during registration.
+ /// Initializes any IAsyncDiscoveryInitializer objects in class data during test building.
+ /// Regular IAsyncInitializer objects are NOT initialized here - they are deferred to test execution
+ /// via ObjectLifecycleService to avoid premature initialization during discovery.
///
private async Task InitializeDeferredClassDataAsync(object?[] classData)
{
@@ -56,7 +58,9 @@ private async Task InitializeDeferredClassDataAsync(object?[] classData)
foreach (var data in classData)
{
- if (data is IAsyncInitializer asyncInitializer && data is not IDataSourceAttribute)
+ // Only initialize IAsyncDiscoveryInitializer during discovery/building.
+ // Regular IAsyncInitializer objects are initialized during test execution.
+ if (data is IAsyncDiscoveryInitializer && data is not IDataSourceAttribute)
{
if (!ObjectInitializer.IsInitialized(data))
{
diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs
index 249f19207b..36179c779e 100644
--- a/TUnit.Engine/Services/PropertyInjector.cs
+++ b/TUnit.Engine/Services/PropertyInjector.cs
@@ -525,15 +525,30 @@ private async Task ResolveAndCacheReflectionPropertyAsync(
if (value != null)
{
- // Ensure nested objects are initialized
- if (PropertyInjectionCache.HasInjectableProperties(value.GetType()) || value is IAsyncInitializer)
+ // Handle property injection and initialization appropriately during discovery
+ var hasInjectableProperties = PropertyInjectionCache.HasInjectableProperties(value.GetType());
+ var isDiscoveryInitializer = value is IAsyncDiscoveryInitializer;
+
+ if (isDiscoveryInitializer)
{
+ // Full initialization during discovery (property injection + IAsyncInitializer.InitializeAsync)
+ // for objects that explicitly opt-in via IAsyncDiscoveryInitializer
await _objectLifecycleService.Value.EnsureInitializedAsync(
value,
context.ObjectBag,
context.MethodMetadata,
context.Events);
}
+ else if (hasInjectableProperties)
+ {
+ // Property injection only, IAsyncInitializer.InitializeAsync deferred to execution
+ // Regular IAsyncInitializer objects are initialized during test execution by ObjectLifecycleService
+ await _objectLifecycleService.Value.InjectPropertiesAsync(
+ value,
+ context.ObjectBag,
+ context.MethodMetadata,
+ context.Events);
+ }
return value;
}
diff --git a/TUnit.TestProject/Bugs/3992/BugRecreation.cs b/TUnit.TestProject/Bugs/3992/BugRecreation.cs
new file mode 100644
index 0000000000..fbc042d9fa
--- /dev/null
+++ b/TUnit.TestProject/Bugs/3992/BugRecreation.cs
@@ -0,0 +1,41 @@
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject.Bugs._3992;
+
+///
+/// Once this is discovered during test discovery, containers spin up
+///
+[EngineTest(ExpectedResult.Pass)]
+public sealed class BugRecreation
+{
+ //Docker container
+ [ClassDataSource(Shared = SharedType.PerClass)]
+ public required DummyContainer Container { get; init; }
+
+ public IEnumerable> Executions
+ => Container.Ints.Select(e => new Func(() => e));
+
+ [Before(Class)]
+ public static Task BeforeClass(ClassHookContext context) => NotInitialised(context.Tests);
+
+ [After(TestDiscovery)]
+ public static Task AfterDiscovery(TestDiscoveryContext context) => NotInitialised(context.AllTests);
+
+ public static async Task NotInitialised(IEnumerable tests)
+ {
+ var bugRecreations = tests.Select(x => x.Metadata.TestDetails.ClassInstance).OfType();
+
+ foreach (var bugRecreation in bugRecreations)
+ {
+ await Assert.That(bugRecreation.Container).IsNotNull();
+ await Assert.That(DummyContainer.NumberOfInits).IsEqualTo(0);
+ }
+ }
+
+ [Test, Arguments(1)]
+ public async Task Test(int value, CancellationToken token)
+ {
+ await Assert.That(value).IsNotDefault();
+ await Assert.That(DummyContainer.NumberOfInits).IsEqualTo(1);
+ }
+}
diff --git a/TUnit.TestProject/Bugs/3992/DummyContainer.cs b/TUnit.TestProject/Bugs/3992/DummyContainer.cs
new file mode 100644
index 0000000000..b075f5e9f1
--- /dev/null
+++ b/TUnit.TestProject/Bugs/3992/DummyContainer.cs
@@ -0,0 +1,22 @@
+using TUnit.Core.Interfaces;
+
+namespace TUnit.TestProject.Bugs._3992;
+
+public class DummyContainer : IAsyncInitializer, IAsyncDisposable
+{
+ public Task InitializeAsync()
+ {
+ NumberOfInits++;
+ Ints = [1, 2, 3, 4, 5, 6];
+ return Task.CompletedTask;
+ }
+
+ public int[] Ints { get; private set; } = null!;
+
+ public static int NumberOfInits { get; private set; }
+
+ public ValueTask DisposeAsync()
+ {
+ return default;
+ }
+}
From 7cdc0fc4960e7cb8710e1adc98ce7cd18b7f48c6 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 7 Dec 2025 13:28:49 +0000
Subject: [PATCH 02/20] feat: implement IAsyncDiscoveryInitializer and related
classes for improved test discovery handling
---
TUnit.Core/Helpers/DataSourceHelpers.cs | 40 ++----
TUnit.Core/ObjectInitializer.cs | 61 +++++++--
TUnit.Core/TestBuilderContext.cs | 3 +-
.../Tracking/TrackableObjectGraphProvider.cs | 62 ++++++++-
TUnit.Engine/Building/TestBuilder.cs | 79 +++---------
.../Services/ObjectGraphDiscoveryService.cs | 120 +++++++++++++-----
.../Services/ObjectLifecycleService.cs | 91 ++++++++-----
TUnit.Engine/Services/PropertyInjector.cs | 32 ++---
.../Services/TestExecution/TestCoordinator.cs | 4 +-
TUnit.Engine/TestExecutor.cs | 8 +-
TUnit.Engine/TestInitializer.cs | 13 +-
11 files changed, 319 insertions(+), 194 deletions(-)
diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs
index 6802e5add5..fc7ba158e6 100644
--- a/TUnit.Core/Helpers/DataSourceHelpers.cs
+++ b/TUnit.Core/Helpers/DataSourceHelpers.cs
@@ -178,12 +178,9 @@ public static T InvokeIfFunc(object? value)
// If it's a Func, invoke it first
var actualData = InvokeIfFunc(data);
- // 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);
- }
+ // During discovery, only IAsyncDiscoveryInitializer objects are initialized.
+ // Regular IAsyncInitializer objects are deferred to Execution phase.
+ await ObjectInitializer.InitializeForDiscoveryAsync(actualData);
return actualData;
}
@@ -202,11 +199,8 @@ public static T InvokeIfFunc(object? value)
if (enumerator.MoveNext())
{
var value = enumerator.Current;
- // Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
- if (value is IAsyncDiscoveryInitializer)
- {
- await ObjectInitializer.InitializeAsync(value);
- }
+ // Discovery: only IAsyncDiscoveryInitializer
+ await ObjectInitializer.InitializeForDiscoveryAsync(value);
return value;
}
@@ -233,22 +227,16 @@ public static T InvokeIfFunc(object? value)
if (enumerator.MoveNext())
{
var value = enumerator.Current;
- // Only initialize during discovery if explicitly opted-in via IAsyncDiscoveryInitializer
- if (value is IAsyncDiscoveryInitializer)
- {
- await ObjectInitializer.InitializeAsync(value);
- }
+ // Discovery: only IAsyncDiscoveryInitializer
+ await ObjectInitializer.InitializeForDiscoveryAsync(value);
return value;
}
return null;
}
- // 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);
- }
+ // During discovery, only IAsyncDiscoveryInitializer objects are initialized.
+ // Regular IAsyncInitializer objects are deferred to Execution phase.
+ await ObjectInitializer.InitializeForDiscoveryAsync(actualData);
return actualData;
}
@@ -596,12 +584,8 @@ public static void RegisterTypeCreator(Func>
{
var value = args[0];
- // 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);
- }
+ // Discovery: only IAsyncDiscoveryInitializer
+ await ObjectInitializer.InitializeForDiscoveryAsync(value);
return value;
}
diff --git a/TUnit.Core/ObjectInitializer.cs b/TUnit.Core/ObjectInitializer.cs
index 362445816e..33da8a31d6 100644
--- a/TUnit.Core/ObjectInitializer.cs
+++ b/TUnit.Core/ObjectInitializer.cs
@@ -1,35 +1,76 @@
-using System.Runtime.CompilerServices;
+using System.Runtime.CompilerServices;
using TUnit.Core.Interfaces;
namespace TUnit.Core;
+///
+/// Centralized service for initializing objects that implement IAsyncInitializer.
+/// Provides thread-safe, deduplicated initialization with explicit phase control.
+///
+/// Use InitializeForDiscoveryAsync during test discovery - only IAsyncDiscoveryInitializer objects are initialized.
+/// Use InitializeAsync during test execution - all IAsyncInitializer objects are initialized.
+///
public static class ObjectInitializer
{
private static readonly ConditionalWeakTable