Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions TUnit.Core/Contexts/TestRegisteredContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public void SetTestExecutor(ITestExecutor executor)
DiscoveredTest.TestExecutor = executor;
}

/// <summary>
/// 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).
/// </summary>
public void SetHookExecutor(IHookExecutor executor)
{
TestContext.CustomHookExecutor = executor;
}

/// <summary>
/// Sets the parallel limiter for the test
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions TUnit.Core/Hooks/AfterTestHookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ public record AfterTestHookMethod : StaticHookMethod<TestContext>
{
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)
);
Expand Down
10 changes: 10 additions & 0 deletions TUnit.Core/Hooks/BeforeTestHookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ public record BeforeTestHookMethod : StaticHookMethod<TestContext>
{
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)
);
Expand Down
6 changes: 6 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ public static string WorkingDirectory

public Type? DisplayNameFormatter { get; set; }

/// <summary>
/// Custom hook executor that overrides the default hook executor for all test-level hooks.
/// Set via TestRegisteredContext.SetHookExecutor() during test registration.
/// </summary>
public IHookExecutor? CustomHookExecutor { get; set; }

public Func<TestContext, Exception, int, Task<bool>>? RetryFunc { get; set; }

// New: Support multiple parallel constraints
Expand Down
65 changes: 59 additions & 6 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,21 +21,24 @@ 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,
EventReceiverOrchestrator eventReceiverOrchestrator,
IContextProvider contextProvider,
PropertyInjectionService propertyInjectionService,
DataSourceInitializer dataSourceInitializer,
Discovery.IHookDiscoveryService hookDiscoveryService)
Discovery.IHookDiscoveryService hookDiscoveryService,
TestArgumentRegistrationService testArgumentRegistrationService)
{
_sessionId = sessionId;
_hookDiscoveryService = hookDiscoveryService;
_eventReceiverOrchestrator = eventReceiverOrchestrator;
_contextProvider = contextProvider;
_propertyInjectionService = propertyInjectionService;
_dataSourceInitializer = dataSourceInitializer;
_testArgumentRegistrationService = testArgumentRegistrationService;
}

/// <summary>
Expand Down Expand Up @@ -764,8 +768,8 @@ public async Task<AbstractExecutableTest> 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,
Expand All @@ -778,7 +782,27 @@ public async Task<AbstractExecutableTest> 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;
}

/// <summary>
Expand Down Expand Up @@ -854,6 +878,37 @@ private async ValueTask<TestContext> 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<object>
{
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<ITestRegisteredEventReceiver>())
{
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(
Expand All @@ -877,8 +932,6 @@ private async Task<AbstractExecutableTest> CreateFailedTestForDataGenerationErro
var testDetails = await CreateFailedTestDetails(metadata, testId);
var context = CreateFailedTestContext(metadata, testDetails);

await InvokeDiscoveryEventReceiversAsync(context);

return new FailedExecutableTest(exception)
{
TestId = testId,
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public TUnitServiceProvider(IExtension extension,
}

var testBuilder = Register<ITestBuilder>(
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService));
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService));

TestBuilderPipeline = Register(
new TestBuilderPipeline(
Expand Down
47 changes: 44 additions & 3 deletions TUnit.Engine/Helpers/HookTimeoutHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using TUnit.Core;
using TUnit.Core.Hooks;
using TUnit.Core.Interfaces;

namespace TUnit.Engine.Helpers;

Expand All @@ -15,11 +17,14 @@ public static Func<Task> CreateTimeoutHookAction<T>(
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 () =>
Expand All @@ -30,7 +35,7 @@ public static Func<Task> CreateTimeoutHookAction<T>(

try
{
await hook.ExecuteAsync(context, cts.Token);
await ExecuteHookWithPotentialCustomExecutor(hook, context, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
Expand All @@ -39,6 +44,40 @@ public static Func<Task> CreateTimeoutHookAction<T>(
};
}

/// <summary>
/// Executes a hook, using a custom executor if one is set on the TestContext
/// </summary>
private static ValueTask ExecuteHookWithPotentialCustomExecutor<T>(StaticHookMethod<T> 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);
}

/// <summary>
/// Creates a timeout-aware action wrapper for a hook delegate
/// </summary>
Expand Down Expand Up @@ -74,6 +113,8 @@ public static Func<Task> CreateTimeoutHookAction<T>(

/// <summary>
/// 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
/// </summary>
public static Func<Task> CreateTimeoutHookAction<T>(
Func<T, CancellationToken, ValueTask> hookDelegate,
Expand Down
41 changes: 34 additions & 7 deletions TUnit.Engine/Services/HookCollectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -621,14 +621,41 @@ private async Task<Func<TestContext, CancellationToken, 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();
}
};
}

Expand Down
15 changes: 6 additions & 9 deletions TUnit.Engine/Services/TestArgumentRegistrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,24 +139,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);
return; // Stop processing further properties for this test
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);
throw registrationException;
}
}
}
11 changes: 10 additions & 1 deletion TUnit.Engine/Services/TestFilterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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) { }
}
Expand Down
Loading
Loading