diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs
index 49fbbbdc24..7b3786ff9d 100644
--- a/TUnit.Core/Contexts/TestRegisteredContext.cs
+++ b/TUnit.Core/Contexts/TestRegisteredContext.cs
@@ -34,6 +34,15 @@ public void SetTestExecutor(ITestExecutor executor)
DiscoveredTest.TestExecutor = executor;
}
+ ///
+ /// Sets a custom hook executor that will be used for all test-level hooks (Before/After Test).
+ /// This allows you to wrap hook execution in custom logic (e.g., running on a specific thread).
+ ///
+ public void SetHookExecutor(IHookExecutor executor)
+ {
+ TestContext.CustomHookExecutor = executor;
+ }
+
///
/// Sets the parallel limiter for the test
///
diff --git a/TUnit.Core/Hooks/AfterTestHookMethod.cs b/TUnit.Core/Hooks/AfterTestHookMethod.cs
index 510ffa81bf..90ac145667 100644
--- a/TUnit.Core/Hooks/AfterTestHookMethod.cs
+++ b/TUnit.Core/Hooks/AfterTestHookMethod.cs
@@ -4,6 +4,16 @@ public record AfterTestHookMethod : StaticHookMethod
{
public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken)
{
+ // Check if a custom hook executor has been set (e.g., via SetHookExecutor())
+ // This ensures static hooks respect the custom executor even in AOT/trimmed builds
+ if (context.CustomHookExecutor != null)
+ {
+ return context.CustomHookExecutor.ExecuteAfterTestHook(MethodInfo, context,
+ () => Body!.Invoke(context, cancellationToken)
+ );
+ }
+
+ // Use the default executor specified at hook registration time
return HookExecutor.ExecuteAfterTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
diff --git a/TUnit.Core/Hooks/BeforeTestHookMethod.cs b/TUnit.Core/Hooks/BeforeTestHookMethod.cs
index eea40c6cc1..867af57b7c 100644
--- a/TUnit.Core/Hooks/BeforeTestHookMethod.cs
+++ b/TUnit.Core/Hooks/BeforeTestHookMethod.cs
@@ -4,6 +4,16 @@ public record BeforeTestHookMethod : StaticHookMethod
{
public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken)
{
+ // Check if a custom hook executor has been set (e.g., via SetHookExecutor())
+ // This ensures static hooks respect the custom executor even in AOT/trimmed builds
+ if (context.CustomHookExecutor != null)
+ {
+ return context.CustomHookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
+ () => Body!.Invoke(context, cancellationToken)
+ );
+ }
+
+ // Use the default executor specified at hook registration time
return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs
index e585327cb0..125a72f85d 100644
--- a/TUnit.Core/TestContext.cs
+++ b/TUnit.Core/TestContext.cs
@@ -103,6 +103,12 @@ public static string WorkingDirectory
public Type? DisplayNameFormatter { get; set; }
+ ///
+ /// Custom hook executor that overrides the default hook executor for all test-level hooks.
+ /// Set via TestRegisteredContext.SetHookExecutor() during test registration.
+ ///
+ public IHookExecutor? CustomHookExecutor { get; set; }
+
public Func>? RetryFunc { get; set; }
// New: Support multiple parallel constraints
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 133dff646a..020cb6a06d 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -6,6 +6,7 @@
using TUnit.Core.Interfaces;
using TUnit.Core.Services;
using TUnit.Engine.Building.Interfaces;
+using TUnit.Engine.Extensions;
using TUnit.Engine.Helpers;
using TUnit.Engine.Services;
using TUnit.Engine.Utilities;
@@ -20,6 +21,7 @@ internal sealed class TestBuilder : ITestBuilder
private readonly PropertyInjectionService _propertyInjectionService;
private readonly DataSourceInitializer _dataSourceInitializer;
private readonly Discovery.IHookDiscoveryService _hookDiscoveryService;
+ private readonly TestArgumentRegistrationService _testArgumentRegistrationService;
public TestBuilder(
string sessionId,
@@ -27,7 +29,8 @@ public TestBuilder(
IContextProvider contextProvider,
PropertyInjectionService propertyInjectionService,
DataSourceInitializer dataSourceInitializer,
- Discovery.IHookDiscoveryService hookDiscoveryService)
+ Discovery.IHookDiscoveryService hookDiscoveryService,
+ TestArgumentRegistrationService testArgumentRegistrationService)
{
_sessionId = sessionId;
_hookDiscoveryService = hookDiscoveryService;
@@ -35,6 +38,7 @@ public TestBuilder(
_contextProvider = contextProvider;
_propertyInjectionService = propertyInjectionService;
_dataSourceInitializer = dataSourceInitializer;
+ _testArgumentRegistrationService = testArgumentRegistrationService;
}
///
@@ -764,8 +768,8 @@ public async Task BuildTestAsync(TestMetadata metadata,
// Arguments will be tracked by TestArgumentTrackingService during TestRegistered event
// This ensures proper reference counting for shared instances
- await InvokeDiscoveryEventReceiversAsync(context);
-
+ // Create the test object BEFORE invoking event receivers
+ // This ensures context.InternalExecutableTest is set for error handling in registration
var creationContext = new ExecutableTestCreationContext
{
TestId = testId,
@@ -778,7 +782,27 @@ public async Task BuildTestAsync(TestMetadata metadata,
ResolvedClassGenericArguments = testData.ResolvedClassGenericArguments
};
- return metadata.CreateExecutableTestFactory(creationContext, metadata);
+ var test = metadata.CreateExecutableTestFactory(creationContext, metadata);
+
+ // Set InternalExecutableTest so it's available during registration for error handling
+ context.InternalExecutableTest = test;
+
+ // Invoke test registered event receivers BEFORE discovery event receivers
+ // This is critical for allowing attributes to set custom hook executors
+ try
+ {
+ await InvokeTestRegisteredEventReceiversAsync(context);
+ }
+ catch (Exception ex)
+ {
+ // Property registration or other registration logic failed
+ // Mark the test as failed immediately, as the old code did
+ test.SetResult(TestState.Failed, ex);
+ }
+
+ await InvokeDiscoveryEventReceiversAsync(context);
+
+ return test;
}
///
@@ -854,6 +878,37 @@ private async ValueTask CreateTestContextAsync(string testId, TestM
return context;
}
+#if NET6_0_OR_GREATER
+ [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Type comes from runtime objects that cannot be annotated")]
+#endif
+ private async Task InvokeTestRegisteredEventReceiversAsync(TestContext context)
+ {
+ var discoveredTest = new DiscoveredTest