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 + { + TestContext = context + }; + + var registeredContext = new TestRegisteredContext(context) + { + DiscoveredTest = discoveredTest + }; + + context.InternalDiscoveredTest = discoveredTest; + + // First, invoke the global test argument registration service to register shared instances + await _testArgumentRegistrationService.OnTestRegistered(registeredContext); + + var eventObjects = context.GetEligibleEventObjects(); + + foreach (var receiver in eventObjects.OfType()) + { + await receiver.OnTestRegistered(registeredContext); + } + } + +#if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Scoped attribute filtering uses Type.GetInterfaces and reflection")] +#endif private async Task InvokeDiscoveryEventReceiversAsync(TestContext context) { var discoveredContext = new DiscoveredTestContext( diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index a266526c39..673e73c91a 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -166,7 +166,7 @@ public TUnitServiceProvider(IExtension extension, } var testBuilder = Register( - new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService)); + new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService)); TestBuilderPipeline = Register( new TestBuilderPipeline( diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 7205caaf05..155b6c34a1 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -1,4 +1,6 @@ +using TUnit.Core; using TUnit.Core.Hooks; +using TUnit.Core.Interfaces; namespace TUnit.Engine.Helpers; @@ -15,11 +17,14 @@ public static Func CreateTimeoutHookAction( T context, CancellationToken cancellationToken) { + // CENTRAL POINT: At execution time, check if we should use a custom hook executor + // This happens AFTER OnTestRegistered, so CustomHookExecutor will be set if the user called SetHookExecutor var timeout = hook.Timeout; + if (timeout == null) { - // No timeout specified, execute normally - return async () => await hook.ExecuteAsync(context, cancellationToken); + // No timeout specified, execute with potential custom executor + return async () => await ExecuteHookWithPotentialCustomExecutor(hook, context, cancellationToken); } return async () => @@ -30,7 +35,7 @@ public static Func CreateTimeoutHookAction( try { - await hook.ExecuteAsync(context, cts.Token); + await ExecuteHookWithPotentialCustomExecutor(hook, context, cts.Token); } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -39,6 +44,40 @@ public static Func CreateTimeoutHookAction( }; } + /// + /// Executes a hook, using a custom executor if one is set on the TestContext + /// + private static ValueTask ExecuteHookWithPotentialCustomExecutor(StaticHookMethod hook, T context, CancellationToken cancellationToken) + { + // Check if this is a TestContext with a custom hook executor + if (context is TestContext testContext && testContext.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly with the hook's body + var customExecutor = testContext.CustomHookExecutor; + + // Determine which executor method to call based on hook type + if (hook is BeforeTestHookMethod || hook is InstanceHookMethod) + { + return customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + else if (hook is AfterTestHookMethod) + { + return customExecutor.ExecuteAfterTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + } + + // No custom executor, use the hook's default executor + return hook.ExecuteAsync(context, cancellationToken); + } + /// /// Creates a timeout-aware action wrapper for a hook delegate /// @@ -74,6 +113,8 @@ public static Func CreateTimeoutHookAction( /// /// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask + /// This overload is used for instance hooks (InstanceHookMethod) + /// Custom executor handling for instance hooks is done in HookCollectionService.CreateInstanceHookDelegateAsync /// public static Func CreateTimeoutHookAction( Func hookDelegate, diff --git a/TUnit.Engine/Services/HookCollectionService.cs b/TUnit.Engine/Services/HookCollectionService.cs index 902f7ed057..ab31b36e89 100644 --- a/TUnit.Engine/Services/HookCollectionService.cs +++ b/TUnit.Engine/Services/HookCollectionService.cs @@ -621,14 +621,41 @@ private async Task> CreateInstanceHoo return async (context, cancellationToken) => { - var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( - (ctx, ct) => hook.ExecuteAsync(ctx, ct), - context, - hook.Timeout, - hook.Name, - cancellationToken); + // Check at EXECUTION time if a custom executor should be used + if (context.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly + var customExecutor = context.CustomHookExecutor; - await timeoutAction(); + // Skip skipped test instances + if (context.TestDetails.ClassInstance is SkippedTestInstance) + { + return; + } + + if (context.TestDetails.ClassInstance is PlaceholderInstance) + { + throw new InvalidOperationException($"Cannot execute instance hook {hook.Name} because the test instance has not been created yet. This is likely a framework bug."); + } + + await customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + context, + () => hook.Body!.Invoke(context.TestDetails.ClassInstance, context, cancellationToken) + ); + } + else + { + // No custom executor, use normal execution path + var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( + (ctx, ct) => hook.ExecuteAsync(ctx, ct), + context, + hook.Timeout, + hook.Name, + cancellationToken); + + await timeoutAction(); + } }; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index de865ee675..098be8d6d7 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index f7b3d69196..cec9588a0a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index c7e96f00ab..475f2d4304 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 2f9c6ad709..cfa7c50d42 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1222,6 +1222,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1423,6 +1424,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.TestProject/SetHookExecutorTests.cs b/TUnit.TestProject/SetHookExecutorTests.cs new file mode 100644 index 0000000000..df35ef859f --- /dev/null +++ b/TUnit.TestProject/SetHookExecutorTests.cs @@ -0,0 +1,109 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; +using TUnit.TestProject.TestExecutors; + +namespace TUnit.TestProject; + +/// +/// Attribute that sets both test and hook executors - mimics the user's [Dispatch] attribute from issue #2666 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class SetBothExecutorsAttribute : Attribute, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + // Set both test and hook executors to use the same custom executor + // This is the key feature - users can now wrap all methods (test + hooks) in their custom dispatcher + var customExecutor = new CrossPlatformTestExecutor(); + context.SetTestExecutor(customExecutor); + context.SetHookExecutor(customExecutor); + + return default; + } +} + +/// +/// Tests demonstrating SetHookExecutor functionality for issue #2666 +/// Users can use an attribute that calls context.SetHookExecutor() to wrap both tests and their hooks in the same executor +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorTests +{ + private static bool _beforeTestHookExecutedWithCustomExecutor; + private static bool _afterTestHookExecutedWithCustomExecutor; + + [Before(Test)] + public async Task BeforeTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _beforeTestHookExecutedWithCustomExecutor = true; + } + + [After(Test)] + public async Task AfterTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _afterTestHookExecutedWithCustomExecutor = true; + } + + [Test] + public async Task Test_ExecutesInCustomExecutor() + { + // Test should execute in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + + [Test] + public async Task Test_HooksAlsoExecuteInCustomExecutor() + { + // Verify that the hooks executed in the custom executor + await Assert.That(_beforeTestHookExecutedWithCustomExecutor).IsTrue(); + + // After hook will be verified by its own assertions + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +} + +/// +/// Tests demonstrating SetHookExecutor with static hooks +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorWithStaticHooksTests +{ + [BeforeEvery(Test)] + public static async Task BeforeEveryTest(TestContext context) + { + // This static hook should also execute with the custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + context.ObjectBag["BeforeEveryExecuted"] = true; + } + + [AfterEvery(Test)] + public static async Task AfterEveryTest(TestContext context) + { + // This static hook should also execute with the custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + + [Test] + public async Task Test_StaticHooksExecuteInCustomExecutor() + { + // Verify the BeforeEvery hook ran + await Assert.That(TestContext.Current?.ObjectBag["BeforeEveryExecuted"]).IsEquatableOrEqualTo(true); + + // Test itself runs in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +} From e2d93951ec938c58afc0ace9a72be35ce5994eeb Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:36:08 +0100 Subject: [PATCH 02/13] refactor: improve test registration flow and error handling in TestBuilder and TestArgumentRegistrationService --- TUnit.Engine/Building/TestBuilder.cs | 21 ++++++++++------ .../TestArgumentRegistrationService.cs | 24 +++++++++++++++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 71f0308214..43a063e7c7 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -768,12 +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 - // 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); - + // 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, @@ -786,7 +782,18 @@ 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 + await InvokeTestRegisteredEventReceiversAsync(context); + + await InvokeDiscoveryEventReceiversAsync(context); + + return test; } /// diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 5698233ef3..17e9910b9f 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -5,10 +5,12 @@ using TUnit.Core; using TUnit.Core.Data; using TUnit.Core.Enums; +using TUnit.Core.Helpers; using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; +using TUnit.Engine.Helpers; namespace TUnit.Engine.Services; @@ -89,6 +91,19 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) // Create the data source for this property var dataSource = metadata.CreateDataSource(); + // Create PropertyMetadata for MembersToGenerate + var containingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(metadata.ContainingType); + var propMetadata = new PropertyMetadata + { + IsStatic = false, + Name = metadata.PropertyName, + ClassMetadata = containingTypeMetadata, + Type = metadata.PropertyType, + ReflectionInfo = PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName), + Getter = parent => PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName).GetValue(parent!)!, + ContainingTypeMetadata = containingTypeMetadata + }; + // Create minimal DataGeneratorMetadata for property resolution during registration var testBuilderContext = new TestBuilderContext { @@ -101,7 +116,7 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) var dataGenMetadata = new DataGeneratorMetadata { TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), - MembersToGenerate = [], // Properties don't use member generation + MembersToGenerate = [propMetadata], // Pass the property metadata TestInformation = testContext.TestDetails.MethodMetadata, Type = DataGeneratorType.Property, TestSessionId = TestSessionContext.Current?.Id ?? "registration", @@ -145,7 +160,9 @@ await _objectRegistrationService.RegisterObjectAsync( // Mark the test as failed immediately during registration testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); - return; // Stop processing further properties for this test + + // Re-throw so the error is captured during test building + throw; } } } @@ -157,6 +174,9 @@ await _objectRegistrationService.RegisterObjectAsync( // Mark the test as failed immediately during registration testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); + + // Re-throw so the error is captured during test building + throw; } } } From 588fd760f53edf7b4211f292ba02c24bd2318bb8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:16:21 +0100 Subject: [PATCH 03/13] refactor: improve error handling in RegisterPropertiesAsync to re-throw exceptions for test building --- .../TestArgumentRegistrationService.cs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 17e9910b9f..2a2fa623a5 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -154,29 +154,21 @@ await _objectRegistrationService.RegisterObjectAsync( } catch (Exception ex) { - // Capture the exception for this property - mark the test as failed + // Capture the exception for this property and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; var propertyException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); - - // Re-throw so the error is captured during test building - throw; + throw propertyException; } } } catch (Exception ex) { - // Capture any top-level exceptions (e.g., getting property source) + // Capture any top-level exceptions (e.g., getting property source) and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to register properties for test '{testContext.TestDetails.TestName}': {ex.Message}"; var registrationException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); - - // Re-throw so the error is captured during test building - throw; + throw registrationException; } } } From 9db4aa9f6e8d57850b533abd290a406866909470 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:59:18 +0100 Subject: [PATCH 04/13] feat: add support for custom hook executors in BeforeTestHookMethod and AfterTestHookMethod --- TUnit.Core/Hooks/AfterTestHookMethod.cs | 10 ++++++++++ TUnit.Core/Hooks/BeforeTestHookMethod.cs | 10 ++++++++++ 2 files changed, 20 insertions(+) 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) ); From a8457b6ea48307724acf783bf413bef7df357178 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:38:34 +0100 Subject: [PATCH 05/13] fix: Make BeforeEvery/AfterEvery hooks check test class before asserting custom executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BeforeEvery(Test) and AfterEvery(Test) hooks are GLOBAL - they run for ALL tests in the assembly. When SetHookExecutorWithStaticHooksTests was added, these global hooks would run for every test, including tests like AfterTestAttributeTests that don't have CustomHookExecutor set, causing assertions to fail. The fix: Check context.TestDetails.ClassType before running assertions in the global hooks, so they only assert custom executor behavior for tests in SetHookExecutorWithStaticHooksTests. This demonstrates that static hooks DO respect CustomHookExecutor when it's set on the TestContext. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- TUnit.TestProject/SetHookExecutorTests.cs | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/TUnit.TestProject/SetHookExecutorTests.cs b/TUnit.TestProject/SetHookExecutorTests.cs index df35ef859f..420fbe9ae5 100644 --- a/TUnit.TestProject/SetHookExecutorTests.cs +++ b/TUnit.TestProject/SetHookExecutorTests.cs @@ -83,18 +83,28 @@ public class SetHookExecutorWithStaticHooksTests [BeforeEvery(Test)] public static async Task BeforeEveryTest(TestContext context) { - // This static hook should also execute with the custom executor - await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); - await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); - context.ObjectBag["BeforeEveryExecuted"] = true; + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + context.ObjectBag["BeforeEveryExecuted"] = true; + } } [AfterEvery(Test)] public static async Task AfterEveryTest(TestContext context) { - // This static hook should also execute with the custom executor - await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); - await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } } [Test] From 4ed0c60012f179cedaf92289c9f80ae1a7a1032e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:18:34 +0100 Subject: [PATCH 06/13] fix: improve error handling in test registration by catching exceptions during event receiver invocation --- TUnit.Engine/Building/TestBuilder.cs | 11 ++++++++++- .../Services/TestArgumentRegistrationService.cs | 17 +---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 43a063e7c7..d8edd8b204 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -789,7 +789,16 @@ public async Task BuildTestAsync(TestMetadata metadata, // Invoke test registered event receivers BEFORE discovery event receivers // This is critical for allowing attributes to set custom hook executors - await InvokeTestRegisteredEventReceiversAsync(context); + 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); diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 2a2fa623a5..d92139a881 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -5,12 +5,10 @@ using TUnit.Core; using TUnit.Core.Data; using TUnit.Core.Enums; -using TUnit.Core.Helpers; using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; -using TUnit.Engine.Helpers; namespace TUnit.Engine.Services; @@ -91,19 +89,6 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) // Create the data source for this property var dataSource = metadata.CreateDataSource(); - // Create PropertyMetadata for MembersToGenerate - var containingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(metadata.ContainingType); - var propMetadata = new PropertyMetadata - { - IsStatic = false, - Name = metadata.PropertyName, - ClassMetadata = containingTypeMetadata, - Type = metadata.PropertyType, - ReflectionInfo = PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName), - Getter = parent => PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName).GetValue(parent!)!, - ContainingTypeMetadata = containingTypeMetadata - }; - // Create minimal DataGeneratorMetadata for property resolution during registration var testBuilderContext = new TestBuilderContext { @@ -116,7 +101,7 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) var dataGenMetadata = new DataGeneratorMetadata { TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), - MembersToGenerate = [propMetadata], // Pass the property metadata + MembersToGenerate = [], // Properties don't use member generation TestInformation = testContext.TestDetails.MethodMetadata, Type = DataGeneratorType.Property, TestSessionId = TestSessionContext.Current?.Id ?? "registration", From 814c67d0582410c793866931a6f9e246cb4f1017 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:36:08 +0100 Subject: [PATCH 07/13] refactor: improve test registration flow and error handling in TestBuilder and TestArgumentRegistrationService --- TUnit.Engine/Building/TestBuilder.cs | 11 +------ .../TestArgumentRegistrationService.cs | 31 +++++++++++++++++-- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index d8edd8b204..43a063e7c7 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -789,16 +789,7 @@ public async Task BuildTestAsync(TestMetadata metadata, // 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 InvokeTestRegisteredEventReceiversAsync(context); await InvokeDiscoveryEventReceiversAsync(context); diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index d92139a881..675221af19 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -5,10 +5,12 @@ using TUnit.Core; using TUnit.Core.Data; using TUnit.Core.Enums; +using TUnit.Core.Helpers; using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; +using TUnit.Engine.Helpers; namespace TUnit.Engine.Services; @@ -89,6 +91,19 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) // Create the data source for this property var dataSource = metadata.CreateDataSource(); + // Create PropertyMetadata for MembersToGenerate + var containingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(metadata.ContainingType); + var propMetadata = new PropertyMetadata + { + IsStatic = false, + Name = metadata.PropertyName, + ClassMetadata = containingTypeMetadata, + Type = metadata.PropertyType, + ReflectionInfo = PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName), + Getter = parent => PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName).GetValue(parent!)!, + ContainingTypeMetadata = containingTypeMetadata + }; + // Create minimal DataGeneratorMetadata for property resolution during registration var testBuilderContext = new TestBuilderContext { @@ -101,7 +116,7 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) var dataGenMetadata = new DataGeneratorMetadata { TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), - MembersToGenerate = [], // Properties don't use member generation + MembersToGenerate = [propMetadata], // Pass the property metadata TestInformation = testContext.TestDetails.MethodMetadata, Type = DataGeneratorType.Property, TestSessionId = TestSessionContext.Current?.Id ?? "registration", @@ -143,7 +158,12 @@ await _objectRegistrationService.RegisterObjectAsync( // The test building process will handle marking it as failed var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; var propertyException = new InvalidOperationException(exceptionMessage, ex); - throw propertyException; + + // Mark the test as failed immediately during registration + testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); + + // Re-throw so the error is captured during test building + throw; } } } @@ -153,7 +173,12 @@ await _objectRegistrationService.RegisterObjectAsync( // The test building process will handle marking it as failed var exceptionMessage = $"Failed to register properties for test '{testContext.TestDetails.TestName}': {ex.Message}"; var registrationException = new InvalidOperationException(exceptionMessage, ex); - throw registrationException; + + // Mark the test as failed immediately during registration + testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); + + // Re-throw so the error is captured during test building + throw; } } } From 1054e867b9a8003c8a77907e3db39b829d18b4f8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:16:21 +0100 Subject: [PATCH 08/13] refactor: improve error handling in RegisterPropertiesAsync to re-throw exceptions for test building --- .../Services/TestArgumentRegistrationService.cs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 675221af19..2a2fa623a5 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -158,12 +158,7 @@ await _objectRegistrationService.RegisterObjectAsync( // The test building process will handle marking it as failed var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; var propertyException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); - - // Re-throw so the error is captured during test building - throw; + throw propertyException; } } } @@ -173,12 +168,7 @@ await _objectRegistrationService.RegisterObjectAsync( // The test building process will handle marking it as failed var exceptionMessage = $"Failed to register properties for test '{testContext.TestDetails.TestName}': {ex.Message}"; var registrationException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); - - // Re-throw so the error is captured during test building - throw; + throw registrationException; } } } From cd88616e3c009282bfeb2c3c55b54931028a9725 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:18:34 +0100 Subject: [PATCH 09/13] fix: improve error handling in test registration by catching exceptions during event receiver invocation --- TUnit.Engine/Building/TestBuilder.cs | 11 ++++++++++- .../Services/TestArgumentRegistrationService.cs | 17 +---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 43a063e7c7..d8edd8b204 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -789,7 +789,16 @@ public async Task BuildTestAsync(TestMetadata metadata, // Invoke test registered event receivers BEFORE discovery event receivers // This is critical for allowing attributes to set custom hook executors - await InvokeTestRegisteredEventReceiversAsync(context); + 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); diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 2a2fa623a5..d92139a881 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -5,12 +5,10 @@ using TUnit.Core; using TUnit.Core.Data; using TUnit.Core.Enums; -using TUnit.Core.Helpers; using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; -using TUnit.Engine.Helpers; namespace TUnit.Engine.Services; @@ -91,19 +89,6 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) // Create the data source for this property var dataSource = metadata.CreateDataSource(); - // Create PropertyMetadata for MembersToGenerate - var containingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(metadata.ContainingType); - var propMetadata = new PropertyMetadata - { - IsStatic = false, - Name = metadata.PropertyName, - ClassMetadata = containingTypeMetadata, - Type = metadata.PropertyType, - ReflectionInfo = PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName), - Getter = parent => PropertyHelper.GetPropertyInfo(metadata.ContainingType, metadata.PropertyName).GetValue(parent!)!, - ContainingTypeMetadata = containingTypeMetadata - }; - // Create minimal DataGeneratorMetadata for property resolution during registration var testBuilderContext = new TestBuilderContext { @@ -116,7 +101,7 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) var dataGenMetadata = new DataGeneratorMetadata { TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), - MembersToGenerate = [propMetadata], // Pass the property metadata + MembersToGenerate = [], // Properties don't use member generation TestInformation = testContext.TestDetails.MethodMetadata, Type = DataGeneratorType.Property, TestSessionId = TestSessionContext.Current?.Id ?? "registration", From e729abe446be189d5ce513ebc9000784359cb27a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:39:48 +0000 Subject: [PATCH 10/13] refactor: remove redundant invocation of discovery event receivers in test failure handling --- TUnit.Engine/Building/TestBuilder.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index d8edd8b204..020cb6a06d 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -932,8 +932,6 @@ private async Task CreateFailedTestForDataGenerationErro var testDetails = await CreateFailedTestDetails(metadata, testId); var context = CreateFailedTestContext(metadata, testDetails); - await InvokeDiscoveryEventReceiversAsync(context); - return new FailedExecutableTest(exception) { TestId = testId, From 9aef7cbe714b798126b190bc0f2747dacade86ae Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:23:25 +0000 Subject: [PATCH 11/13] chore: update public API snapshots for custom hook executor support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the .NET 9.0 PublicAPI verified snapshots to reflect the new API surface changes introduced by the custom hook executor feature: - SetHookExecutor() method on TestRegisteredContext - CustomHookExecutor property on TestContext - Hook executor support in both source-generated and reflection modes These changes enable users to set custom executors for hooks via attributes or TestContext, allowing tests and their hooks to run in the same execution context (e.g., UI thread dispatchers). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...ions_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 8 ++++++++ ...Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 941d60bc07..9adf2916fc 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -5,6 +5,14 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : class { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : struct { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : class { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 475f2d4304..c7e96f00ab 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1268,7 +1268,6 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } - public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1472,7 +1471,6 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } - public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } From d972602576acbe1a2eeafe5323b8e7f6a82c1908 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:27:45 +0000 Subject: [PATCH 12/13] fix: correct public API snapshots with CustomHookExecutor changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit had outdated snapshot files. This commit includes the correct API surface with: - CustomHookExecutor property on TestContext - SetHookExecutor() method on TestRegisteredContext 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...ions_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 8 -------- ...Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 2 ++ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 9adf2916fc..941d60bc07 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -5,14 +5,6 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } - public static void NotNull([.] T? value, [.("value")] string? expression = null) - where T : class { } - public static void NotNull([.] T? value, [.("value")] string? expression = null) - where T : struct { } - public static void Null(T? value, [.("value")] string? expression = null) - where T : class { } - public static void Null(T? value, [.("value")] string? expression = null) - where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index c7e96f00ab..475f2d4304 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } From bb19de6e81aacb4a6c930b37e8d9fa2e5ad65f47 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:19:23 +0000 Subject: [PATCH 13/13] fix: add exception handling for property registration in TestFilterService Static property injection was causing unhandled exceptions because TestFilterService.RegisterTest() was calling testArgumentRegistrationService.OnTestRegistered() without a try-catch. When property registration failed, the exception bubbled up uncaught. Now we catch exceptions from OnTestRegistered, mark the test as failed, and continue processing other tests instead of crashing the entire test run. --- TUnit.Engine/Services/TestFilterService.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 7854b9f9c1..ddc8998467 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -70,7 +70,16 @@ private async Task RegisterTest(AbstractExecutableTest test) test.Context.InternalDiscoveredTest = discoveredTest; - await testArgumentRegistrationService.OnTestRegistered(registeredContext); + try + { + await testArgumentRegistrationService.OnTestRegistered(registeredContext); + } + catch (Exception ex) + { + // Mark the test as failed and skip further event receiver processing + test.SetResult(TestState.Failed, ex); + return; + } var eventObjects = test.Context.GetEligibleEventObjects();