From 4d3c679866b4dcbb137d492bfe65513d38732b40 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 13 Oct 2025 12:09:28 +0100
Subject: [PATCH 01/13] feat: implement custom hook executor support for test
execution
---
TUnit.Core/Contexts/TestRegisteredContext.cs | 9 ++
TUnit.Core/TestContext.cs | 6 +
TUnit.Engine/Building/TestBuilder.cs | 41 ++++++-
.../Framework/TUnitServiceProvider.cs | 2 +-
TUnit.Engine/Helpers/HookTimeoutHelper.cs | 47 +++++++-
.../Services/HookCollectionService.cs | 41 +++++--
...Has_No_API_Changes.DotNet10_0.verified.txt | 2 +
..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 +
..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 +
...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 +
TUnit.TestProject/SetHookExecutorTests.cs | 109 ++++++++++++++++++
11 files changed, 251 insertions(+), 12 deletions(-)
create mode 100644 TUnit.TestProject/SetHookExecutorTests.cs
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/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..71f0308214 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,6 +768,10 @@ public async Task BuildTestAsync(TestMetadata metadata,
// Arguments will be tracked by TestArgumentTrackingService during TestRegistered event
// This ensures proper reference counting for shared instances
+ // Invoke test registered event receivers BEFORE discovery event receivers
+ // This is critical for allowing attributes to set custom hook executors
+ await InvokeTestRegisteredEventReceiversAsync(context);
+
await InvokeDiscoveryEventReceiversAsync(context);
var creationContext = new ExecutableTestCreationContext
@@ -854,6 +862,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