From 3f0c68eb1c5f90c6388391b228ff578f003f272f Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 8 Jun 2026 20:06:20 +0100
Subject: [PATCH 1/3] fix(engine): run lifecycle hooks before test class
construction (#6192)
Before(TestSession/Assembly/Class) hooks (and their BeforeEvery
counterparts) were executed inside TestExecutor.ExecuteAsync, which
TestCoordinator only invoked AFTER calling CreateInstanceAsync. As a
result the test class constructor ran before BeforeEveryAssembly /
BeforeEveryClass, violating the documented lifecycle contract that
hooks run before instantiation.
Hoist the cached Before(Session/Assembly/Class) hook tasks ahead of
CreateInstanceAsync via a new TestExecutor.EnsureClassAndAssemblyHooks
ExecutedAsync helper, called on both the no-retry fast path and the
retry path. The hook tasks are cached in BeforeHookTaskCache, so
ExecuteAsync's later awaits are no-ops; the helper replays the
RestoreExecutionContext chain so AsyncLocals still flow between hooks.
Adds a self-validating regression test under Bugs/6192 that asserts the
constructor observes BeforeEvery(Assembly)/BeforeEvery(Class) as already
run. Verified in both source-generated and reflection modes.
---
.../Services/TestExecution/TestCoordinator.cs | 9 +++
TUnit.Engine/TestExecutor.cs | 33 +++++++++++
TUnit.TestProject/Bugs/6192/Bug6192Tests.cs | 59 +++++++++++++++++++
3 files changed, 101 insertions(+)
create mode 100644 TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index 8ed35ae8e1..5d443d5c41 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -128,6 +128,11 @@ public async ValueTask ExecuteTestAsync(AbstractExecutableTest test, Cancellatio
}
else
{
+ // Run Before(TestSession/Assembly/Class) hooks (incl. BeforeEvery) before constructing
+ // the instance so the documented lifecycle contract holds — hooks precede the
+ // constructor (#6192). Hook tasks are cached, so ExecuteAsync's later awaits are no-ops.
+ await _testExecutor.EnsureClassAndAssemblyHooksExecutedAsync(test, cancellationToken).ConfigureAwait(false);
+
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
// Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed.
@@ -350,6 +355,10 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
return;
}
+ // Run Before(TestSession/Assembly/Class) hooks (incl. BeforeEvery) before constructing the
+ // instance so hooks precede the constructor (#6192). Cached, so ExecuteAsync re-awaits are no-ops.
+ await _testExecutor.EnsureClassAndAssemblyHooksExecutedAsync(test, cancellationToken).ConfigureAwait(false);
+
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
// Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed.
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index d8f048f0cf..5f40a84934 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -62,6 +62,39 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
() => _hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None));
}
+ ///
+ /// Ensures Before(TestSession), Before(Assembly) and Before(Class) hooks (including their
+ /// BeforeEvery counterparts) have executed before the test class instance is constructed, so the
+ /// documented lifecycle contract (hooks run before instantiation) holds — see issue #6192.
+ /// The hook tasks are cached in , so the matching awaits later in
+ /// are no-ops. Only the pure cached getters are invoked here; the
+ /// After-hook pair registration stays single-sourced in ExecuteAsync to avoid double-registration.
+ ///
+ public async ValueTask EnsureClassAndAssemblyHooksExecutedAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
+ {
+ var testClass = test.Metadata.TestClassType;
+ var testAssembly = testClass.Assembly;
+
+ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
+ ct => _hookExecutor.ExecuteBeforeTestSessionHooksAsync(ct),
+ cancellationToken).ConfigureAwait(false);
+
+ // Flow AsyncLocals captured by BeforeTestSession into the BeforeAssembly hook, and likewise
+ // BeforeAssembly into BeforeClass. This mirrors the RestoreExecutionContext chain in
+ // ExecuteAsync (the assembly/class hook tasks are cached, so when ExecuteAsync re-awaits them
+ // it re-applies the same captured contexts).
+ test.Context.ClassContext.AssemblyContext.TestSessionContext.RestoreExecutionContext();
+
+ await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
+ testAssembly,
+ (assembly, ct) => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, ct),
+ cancellationToken).ConfigureAwait(false);
+
+ test.Context.ClassContext.AssemblyContext.RestoreExecutionContext();
+
+ await _beforeHookTaskCache.GetOrCreateBeforeClassTask(testClass, _hookExecutor, cancellationToken).ConfigureAwait(false);
+ }
+
///
/// Creates a test executor delegate that wraps the provided executor with hook orchestration.
/// Uses focused services that follow SRP to manage lifecycle and execution.
diff --git a/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs b/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
new file mode 100644
index 0000000000..efd0a424c7
--- /dev/null
+++ b/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
@@ -0,0 +1,59 @@
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject.Bugs._6192;
+
+// Regression test for #6192: lifecycle hooks must run BEFORE the test class is constructed.
+// Previously the constructor ran before BeforeEvery(Assembly)/BeforeEvery(Class), so the
+// assertions in the constructor below would throw.
+
+public class Bug6192HookOrderProbe
+{
+ public static bool BeforeEveryClassRan;
+ public static bool BeforeEveryAssemblyRan;
+
+ [BeforeEvery(Class)]
+ public static void BeforeEveryClass(ClassHookContext context)
+ {
+ if (context.ClassType == typeof(Bug6192Tests))
+ {
+ BeforeEveryClassRan = true;
+ }
+ }
+
+ [BeforeEvery(Assembly)]
+ public static void BeforeEveryAssembly(AssemblyHookContext context)
+ {
+ if (context.Assembly == typeof(Bug6192Tests).Assembly)
+ {
+ BeforeEveryAssemblyRan = true;
+ }
+ }
+}
+
+[EngineTest(ExpectedResult.Pass)]
+public class Bug6192Tests
+{
+ public Bug6192Tests()
+ {
+ // The constructor must run AFTER the lifecycle hooks (issue #6192).
+ if (!Bug6192HookOrderProbe.BeforeEveryAssemblyRan)
+ {
+ throw new InvalidOperationException("Constructor ran before [BeforeEvery(Assembly)] hook");
+ }
+
+ if (!Bug6192HookOrderProbe.BeforeEveryClassRan)
+ {
+ throw new InvalidOperationException("Constructor ran before [BeforeEvery(Class)] hook");
+ }
+ }
+
+ [Test]
+ public void TestA()
+ {
+ }
+
+ [Test]
+ public void TestB()
+ {
+ }
+}
From 952a7c3caaa17ab82d25ba6581da3613601fe12f Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 8 Jun 2026 20:13:45 +0100
Subject: [PATCH 2/3] refactor(engine): cache before-hook factory delegates to
avoid per-test allocations
Hoist the BeforeTestSession / BeforeAssembly hook-factory lambdas into
readonly fields instead of allocating a fresh closure on every call. These
run on the per-test hot path and the factory is only invoked on the first
cache miss, so the per-test allocation was pure waste. Reused across
EnsureTestSessionHooksExecutedAsync, EnsureClassAndAssemblyHooksExecutedAsync
and ExecuteAsync. Also inline the single-use testAssembly local.
---
TUnit.Engine/TestExecutor.cs | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index 5f40a84934..3d815e50c6 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -27,6 +27,11 @@ internal class TestExecutor
private readonly IContextProvider _contextProvider;
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
+ // Cached hook-factory delegates so the per-test before-hook awaits don't allocate a fresh closure
+ // each time (these run on the hot path and the factory is only invoked on the first cache miss).
+ private readonly Func _beforeTestSessionHookFactory;
+ private readonly Func _beforeAssemblyHookFactory;
+
public TestExecutor(
HookExecutor hookExecutor,
TestLifecycleCoordinator lifecycleCoordinator,
@@ -41,6 +46,9 @@ public TestExecutor(
_afterHookPairTracker = afterHookPairTracker;
_contextProvider = contextProvider;
_eventReceiverOrchestrator = eventReceiverOrchestrator;
+
+ _beforeTestSessionHookFactory = ct => _hookExecutor.ExecuteBeforeTestSessionHooksAsync(ct);
+ _beforeAssemblyHookFactory = (assembly, ct) => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, ct);
}
@@ -53,7 +61,7 @@ public async Task EnsureTestSessionHooksExecutedAsync(CancellationToken cancella
{
// Get or create and cache Before hooks - these run only once
await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
- ct => _hookExecutor.ExecuteBeforeTestSessionHooksAsync(ct),
+ _beforeTestSessionHookFactory,
cancellationToken).ConfigureAwait(false);
// Register After Session hook to run on cancellation (guarantees cleanup)
@@ -73,10 +81,9 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
public async ValueTask EnsureClassAndAssemblyHooksExecutedAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
var testClass = test.Metadata.TestClassType;
- var testAssembly = testClass.Assembly;
await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
- ct => _hookExecutor.ExecuteBeforeTestSessionHooksAsync(ct),
+ _beforeTestSessionHookFactory,
cancellationToken).ConfigureAwait(false);
// Flow AsyncLocals captured by BeforeTestSession into the BeforeAssembly hook, and likewise
@@ -86,8 +93,8 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
test.Context.ClassContext.AssemblyContext.TestSessionContext.RestoreExecutionContext();
await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
- testAssembly,
- (assembly, ct) => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, ct),
+ testClass.Assembly,
+ _beforeAssemblyHookFactory,
cancellationToken).ConfigureAwait(false);
test.Context.ClassContext.AssemblyContext.RestoreExecutionContext();
@@ -121,7 +128,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(
await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
testAssembly,
- (assembly, ct) => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, ct),
+ _beforeAssemblyHookFactory,
cancellationToken).ConfigureAwait(false);
// Register After Assembly hook to run on cancellation (guarantees cleanup)
From c12d654ff776e98bf21aa4f23188b6e6893dfcfb Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 8 Jun 2026 20:53:58 +0100
Subject: [PATCH 3/3] fix(engine): restore class ExecutionContext before
constructor so hook AsyncLocals flow (#6192)
The pre-construction hook gate ran Before(Assembly)/Before(Class) hooks before the test class was constructed, but never restored the class ExecutionContext before construction. AsyncLocals captured by those hooks (via AddAsyncLocalValues) therefore reached the test body but not the constructor.
Restoring inside EnsureClassAndAssemblyHooksExecutedAsync does not work: the ambient ExecutionContext is reset when that async method returns to the caller. Restore ClassContext in TestCoordinator immediately before CreateInstanceAsync on both the fast and retry paths instead.
Also expand the regression test to cover Before(Class)/Before(Assembly) (not just BeforeEvery), the retry path, and AsyncLocal flow into the constructor; and make EnsureTestSessionHooksExecutedAsync return ValueTask for consistency.
---
.../Services/TestExecution/TestCoordinator.cs | 10 +++
TUnit.Engine/TestExecutor.cs | 7 +-
TUnit.TestProject/Bugs/6192/Bug6192Tests.cs | 84 +++++++++++++++++--
3 files changed, 95 insertions(+), 6 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index 5d443d5c41..8fbf4359da 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -133,6 +133,11 @@ public async ValueTask ExecuteTestAsync(AbstractExecutableTest test, Cancellatio
// constructor (#6192). Hook tasks are cached, so ExecuteAsync's later awaits are no-ops.
await _testExecutor.EnsureClassAndAssemblyHooksExecutedAsync(test, cancellationToken).ConfigureAwait(false);
+ // Flow AsyncLocals captured by Before(Assembly)/Before(Class) hooks into the
+ // constructor. Must run here (not inside the gate above) so the restored ambient
+ // ExecutionContext is still in effect when CreateInstanceAsync runs the constructor.
+ test.Context.ClassContext.RestoreExecutionContext();
+
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
// Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed.
@@ -359,6 +364,11 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
// instance so hooks precede the constructor (#6192). Cached, so ExecuteAsync re-awaits are no-ops.
await _testExecutor.EnsureClassAndAssemblyHooksExecutedAsync(test, cancellationToken).ConfigureAwait(false);
+ // Flow AsyncLocals captured by Before(Assembly)/Before(Class) hooks into the constructor.
+ // Must run here (not inside the gate above) so the restored ambient ExecutionContext is still
+ // in effect when CreateInstanceAsync runs the constructor.
+ test.Context.ClassContext.RestoreExecutionContext();
+
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
// Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed.
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index 3d815e50c6..6ab03ae5d3 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -57,7 +57,7 @@ public TestExecutor(
/// This is called before creating test instances to ensure resources are available.
/// Registers the corresponding After(TestSession) hook to run on cancellation.
///
- public async Task EnsureTestSessionHooksExecutedAsync(CancellationToken cancellationToken)
+ public async ValueTask EnsureTestSessionHooksExecutedAsync(CancellationToken cancellationToken)
{
// Get or create and cache Before hooks - these run only once
await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
@@ -100,6 +100,11 @@ await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
test.Context.ClassContext.AssemblyContext.RestoreExecutionContext();
await _beforeHookTaskCache.GetOrCreateBeforeClassTask(testClass, _hookExecutor, cancellationToken).ConfigureAwait(false);
+
+ // Note: the caller (TestCoordinator) restores ClassContext.RestoreExecutionContext() right
+ // before constructing the instance so AsyncLocals captured by BeforeAssembly/BeforeClass flow
+ // into the constructor. Restoring here wouldn't persist — the ambient ExecutionContext is
+ // reset when this async method returns to the caller.
}
///
diff --git a/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs b/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
index efd0a424c7..bedfa745b5 100644
--- a/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
+++ b/TUnit.TestProject/Bugs/6192/Bug6192Tests.cs
@@ -1,20 +1,30 @@
+using System.Threading;
using TUnit.TestProject.Attributes;
namespace TUnit.TestProject.Bugs._6192;
// Regression test for #6192: lifecycle hooks must run BEFORE the test class is constructed.
-// Previously the constructor ran before BeforeEvery(Assembly)/BeforeEvery(Class), so the
-// assertions in the constructor below would throw.
+// Previously the constructor ran before Before(Assembly)/Before(Class) (and their BeforeEvery
+// counterparts), so the assertions in the constructors below would throw.
public class Bug6192HookOrderProbe
{
- public static bool BeforeEveryClassRan;
- public static bool BeforeEveryAssemblyRan;
+ // volatile to make the happens-before intent explicit (the framework's task-caching already
+ // provides the edge between hook completion and the constructor that reads these).
+ public static volatile bool BeforeEveryClassRan;
+ public static volatile bool BeforeEveryAssemblyRan;
+ public static volatile bool BeforeClassRan;
+ public static volatile bool BeforeAssemblyRan;
+
+ // AsyncLocal set inside a [Before(Class)] hook (which opts in via context.AddAsyncLocalValues()).
+ // Its value must flow into the constructor, which proves ClassContext.RestoreExecutionContext()
+ // runs before construction in the pre-construction hook gate (#6192 / review feedback).
+ public static readonly AsyncLocal ClassHookAsyncLocal = new();
[BeforeEvery(Class)]
public static void BeforeEveryClass(ClassHookContext context)
{
- if (context.ClassType == typeof(Bug6192Tests))
+ if (context.ClassType == typeof(Bug6192Tests) || context.ClassType == typeof(Bug6192RetryTests))
{
BeforeEveryClassRan = true;
}
@@ -33,6 +43,19 @@ public static void BeforeEveryAssembly(AssemblyHookContext context)
[EngineTest(ExpectedResult.Pass)]
public class Bug6192Tests
{
+ // Non-BeforeEvery hooks go through the same cache path; assert they precede the constructor too.
+ [Before(Assembly)]
+ public static void BeforeAssembly() => Bug6192HookOrderProbe.BeforeAssemblyRan = true;
+
+ [Before(Class)]
+ public static void BeforeClass(ClassHookContext context)
+ {
+ Bug6192HookOrderProbe.BeforeClassRan = true;
+ Bug6192HookOrderProbe.ClassHookAsyncLocal.Value = "set-by-before-class";
+ // Opt in to flowing this AsyncLocal forward (otherwise TUnit doesn't capture it).
+ context.AddAsyncLocalValues();
+ }
+
public Bug6192Tests()
{
// The constructor must run AFTER the lifecycle hooks (issue #6192).
@@ -45,6 +68,23 @@ public Bug6192Tests()
{
throw new InvalidOperationException("Constructor ran before [BeforeEvery(Class)] hook");
}
+
+ if (!Bug6192HookOrderProbe.BeforeAssemblyRan)
+ {
+ throw new InvalidOperationException("Constructor ran before [Before(Assembly)] hook");
+ }
+
+ if (!Bug6192HookOrderProbe.BeforeClassRan)
+ {
+ throw new InvalidOperationException("Constructor ran before [Before(Class)] hook");
+ }
+
+ // AsyncLocals captured by a class-level hook must flow into the constructor, otherwise the
+ // hook ran but ClassContext.RestoreExecutionContext() did not run before construction.
+ if (Bug6192HookOrderProbe.ClassHookAsyncLocal.Value != "set-by-before-class")
+ {
+ throw new InvalidOperationException("Before(Class) AsyncLocal did not flow into the constructor");
+ }
}
[Test]
@@ -57,3 +97,37 @@ public void TestB()
{
}
}
+
+// Same contract must hold on the retry code path (ExecuteTestLifecycleAsync), which has its own
+// pre-construction hook gate separate from the no-retry fast path.
+[EngineTest(ExpectedResult.Pass)]
+public class Bug6192RetryTests
+{
+ public static readonly AsyncLocal ClassHookAsyncLocal = new();
+
+ [Before(Class)]
+ public static void BeforeClass(ClassHookContext context)
+ {
+ ClassHookAsyncLocal.Value = "set-by-before-class-retry";
+ context.AddAsyncLocalValues();
+ }
+
+ public Bug6192RetryTests()
+ {
+ if (!Bug6192HookOrderProbe.BeforeEveryClassRan)
+ {
+ throw new InvalidOperationException("Constructor ran before [BeforeEvery(Class)] hook (retry path)");
+ }
+
+ if (ClassHookAsyncLocal.Value != "set-by-before-class-retry")
+ {
+ throw new InvalidOperationException("Before(Class) AsyncLocal did not flow into the constructor (retry path)");
+ }
+ }
+
+ [Retry(1)]
+ [Test]
+ public void RetriedTest()
+ {
+ }
+}