diff --git a/TUnit.Core/Attributes/Executors/CultureAttribute.cs b/TUnit.Core/Attributes/Executors/CultureAttribute.cs
index a5bf31748b..364b8ffff8 100644
--- a/TUnit.Core/Attributes/Executors/CultureAttribute.cs
+++ b/TUnit.Core/Attributes/Executors/CultureAttribute.cs
@@ -1,11 +1,14 @@
-using System.Globalization;
+using System.Globalization;
using TUnit.Core.Interfaces;
namespace TUnit.Core.Executors;
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
-public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
+public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
+ private CultureExecutor? _executor;
+ private CultureExecutor Executor => _executor ??= new CultureExecutor(cultureInfo);
+
public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cultureName))
{
}
@@ -19,7 +22,16 @@ public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cu
///
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
- context.SetTestExecutor(new CultureExecutor(cultureInfo));
+ var executor = Executor;
+ context.SetTestExecutor(executor);
+ context.SetHookExecutor(executor);
+ return default(ValueTask);
+ }
+
+ ///
+ public ValueTask OnHookRegistered(HookRegisteredContext context)
+ {
+ context.HookExecutor = Executor;
return default(ValueTask);
}
}
diff --git a/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs b/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs
index ba8e48fba1..840285750c 100644
--- a/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs
+++ b/TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs
@@ -1,12 +1,15 @@
-using System.Runtime.Versioning;
+using System.Runtime.Versioning;
using TUnit.Core.Interfaces;
namespace TUnit.Core.Executors;
[SupportedOSPlatform("windows")]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
-public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
+public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
+ private STAThreadExecutor? _executor;
+ private STAThreadExecutor Executor => _executor ??= new STAThreadExecutor();
+
///
public int Order => 0;
@@ -16,8 +19,16 @@ public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventRe
///
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
- var executor = new STAThreadExecutor();
+ var executor = Executor;
context.SetTestExecutor(executor);
+ context.SetHookExecutor(executor);
+ return default(ValueTask);
+ }
+
+ ///
+ public ValueTask OnHookRegistered(HookRegisteredContext context)
+ {
+ context.HookExecutor = Executor;
return default(ValueTask);
}
}
diff --git a/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs b/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs
index f2d7bf9247..7f8057c903 100644
--- a/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs
+++ b/TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs
@@ -1,11 +1,14 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Interfaces;
namespace TUnit.Core.Executors;
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
-public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new()
+public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new()
{
+ private T? _executor;
+ private T Executor => _executor ??= new T();
+
///
public int Order => 0;
@@ -15,14 +18,43 @@ namespace TUnit.Core.Executors;
///
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
- context.SetTestExecutor(new T());
+ var executor = Executor;
+ context.SetTestExecutor(executor);
+ if (executor is IHookExecutor hookExecutor)
+ {
+ context.SetHookExecutor(hookExecutor);
+ }
+ return default(ValueTask);
+ }
+
+ ///
+ public ValueTask OnHookRegistered(HookRegisteredContext context)
+ {
+ if (Executor is IHookExecutor hookExecutor)
+ {
+ context.HookExecutor = hookExecutor;
+ }
return default(ValueTask);
}
}
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
-public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
+public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
+ private readonly Type _type;
+
+ private readonly bool _isHookExecutor;
+ private ITestExecutor? _executor;
+
+ public TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
+ {
+ _type = type;
+ _isHookExecutor = typeof(IHookExecutor).IsAssignableFrom(type);
+ }
+
+ private ITestExecutor Executor => _executor ??= (ITestExecutor) Activator.CreateInstance(_type)!;
+
///
public int Order => 0;
@@ -32,7 +64,22 @@ public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(Dynamicall
///
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
- context.SetTestExecutor((ITestExecutor) Activator.CreateInstance(type)!);
+ var executor = Executor;
+ context.SetTestExecutor(executor);
+ if (_isHookExecutor)
+ {
+ context.SetHookExecutor((IHookExecutor) executor);
+ }
+ return default(ValueTask);
+ }
+
+ ///
+ public ValueTask OnHookRegistered(HookRegisteredContext context)
+ {
+ if (_isHookExecutor)
+ {
+ context.HookExecutor = (IHookExecutor) Executor;
+ }
return default(ValueTask);
}
}
diff --git a/TUnit.Core/Contexts/HookRegisteredContext.cs b/TUnit.Core/Contexts/HookRegisteredContext.cs
index 144aa84c94..72967a7270 100644
--- a/TUnit.Core/Contexts/HookRegisteredContext.cs
+++ b/TUnit.Core/Contexts/HookRegisteredContext.cs
@@ -1,4 +1,5 @@
using TUnit.Core.Hooks;
+using TUnit.Core.Interfaces;
namespace TUnit.Core;
@@ -7,20 +8,22 @@ namespace TUnit.Core;
///
public class HookRegisteredContext
{
- private TimeSpan? _timeout;
-
public HookMethod HookMethod { get; }
public string HookName => HookMethod.Name;
-
+
///
/// Gets or sets the timeout for this hook
///
- public TimeSpan? Timeout
- {
- get => _timeout;
- set => _timeout = value;
- }
-
+ public TimeSpan? Timeout { get; set; }
+
+ ///
+ /// Gets or sets the hook executor that will be used to invoke this hook.
+ /// Set by implementations (e.g. CultureAttribute,
+ /// STAThreadExecutorAttribute) to wrap hook invocation in custom execution logic.
+ /// If left null, the hook's default executor is used.
+ ///
+ public IHookExecutor? HookExecutor { get; set; }
+
public HookRegisteredContext(HookMethod hookMethod)
{
HookMethod = hookMethod;
diff --git a/TUnit.Core/Hooks/AfterTestHookMethod.cs b/TUnit.Core/Hooks/AfterTestHookMethod.cs
index 90ac145667..97a88fdc9d 100644
--- a/TUnit.Core/Hooks/AfterTestHookMethod.cs
+++ b/TUnit.Core/Hooks/AfterTestHookMethod.cs
@@ -1,20 +1,10 @@
-namespace TUnit.Core.Hooks;
+namespace TUnit.Core.Hooks;
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,
+ return ResolveEffectiveExecutor(context).ExecuteAfterTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}
diff --git a/TUnit.Core/Hooks/BeforeTestHookMethod.cs b/TUnit.Core/Hooks/BeforeTestHookMethod.cs
index 867af57b7c..3ce59d3f35 100644
--- a/TUnit.Core/Hooks/BeforeTestHookMethod.cs
+++ b/TUnit.Core/Hooks/BeforeTestHookMethod.cs
@@ -1,20 +1,10 @@
-namespace TUnit.Core.Hooks;
+namespace TUnit.Core.Hooks;
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,
+ return ResolveEffectiveExecutor(context).ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}
diff --git a/TUnit.Core/Hooks/HookMethod.cs b/TUnit.Core/Hooks/HookMethod.cs
index 9de33ba695..045720c3c8 100644
--- a/TUnit.Core/Hooks/HookMethod.cs
+++ b/TUnit.Core/Hooks/HookMethod.cs
@@ -30,7 +30,34 @@ public abstract record HookMethod
///
public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout;
- public required IHookExecutor HookExecutor { get; init; }
+ private IHookExecutor _hookExecutor = DefaultExecutor.Instance;
+ private bool _hookExecutorIsExplicit;
+
+ public required IHookExecutor HookExecutor
+ {
+ get => _hookExecutor;
+ init
+ {
+ _hookExecutor = value;
+ // An init-time value other than DefaultExecutor means an explicit [HookExecutor]
+ // attribute was present at discovery time (source-gen or reflection).
+ _hookExecutorIsExplicit = !ReferenceEquals(value, DefaultExecutor.Instance);
+ }
+ }
+
+ internal void SetHookExecutor(IHookExecutor executor) => _hookExecutor = executor;
+
+ // Explicit [HookExecutor] on the hook method itself always wins.
+ // Otherwise, prefer the per-test CustomHookExecutor (set via OnTestRegistered with
+ // ScopedAttributeFilter, so it reflects the most-specific scoped attribute for this
+ // test — e.g. method-level [Culture] over class-level). Fall back to _hookExecutor
+ // which may have been set via OnHookRegistered from class/assembly-level attributes.
+ internal IHookExecutor ResolveEffectiveExecutor(TestContext? testContext) =>
+ _hookExecutorIsExplicit
+ ? _hookExecutor
+ : testContext?.CustomHookExecutor is { } custom
+ ? custom
+ : _hookExecutor;
public required int Order { get; init; }
diff --git a/TUnit.Core/Hooks/InstanceHookMethod.cs b/TUnit.Core/Hooks/InstanceHookMethod.cs
index 2b2a2776f7..9e55d8d10d 100644
--- a/TUnit.Core/Hooks/InstanceHookMethod.cs
+++ b/TUnit.Core/Hooks/InstanceHookMethod.cs
@@ -35,7 +35,7 @@ public ValueTask ExecuteAsync(TestContext context, CancellationToken cancellatio
throw new InvalidOperationException($"Cannot execute instance hook {Name} because the test instance has not been created yet. This is likely a framework bug.");
}
- return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
+ return ResolveEffectiveExecutor(context).ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context.Metadata.TestDetails.ClassInstance, context, cancellationToken)
);
}
diff --git a/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs b/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
index 328093ca6d..a5519223b4 100644
--- a/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
+++ b/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
@@ -999,39 +999,10 @@ private static IHookExecutor GetHookExecutor(MethodInfo method)
}
}
- return new DefaultHookExecutor();
+ // Share the same sentinel singleton as source-gen so the precedence check in
+ // HookMethod.ResolveEffectiveExecutor (ReferenceEquals DefaultExecutor.Instance)
+ // recognizes this as "no explicit executor" and lets TestContext.CustomHookExecutor
+ // fill in via SetHookExecutor (#2666).
+ return DefaultExecutor.Instance;
}
}
-
-internal class DefaultHookExecutor : IHookExecutor
-{
- public ValueTask ExecuteBeforeTestDiscoveryHook(MethodMetadata testMethod, BeforeTestDiscoveryContext context, Func action)
- => action();
-
- public ValueTask ExecuteAfterTestDiscoveryHook(MethodMetadata testMethod, TestDiscoveryContext context, Func action)
- => action();
-
- public ValueTask ExecuteBeforeAssemblyHook(MethodMetadata testMethod, AssemblyHookContext context, Func action)
- => action();
-
- public ValueTask ExecuteAfterAssemblyHook(MethodMetadata testMethod, AssemblyHookContext context, Func action)
- => action();
-
- public ValueTask ExecuteBeforeClassHook(MethodMetadata testMethod, ClassHookContext context, Func action)
- => action();
-
- public ValueTask ExecuteAfterClassHook(MethodMetadata testMethod, ClassHookContext context, Func action)
- => action();
-
- public ValueTask ExecuteBeforeTestHook(MethodMetadata testMethod, TestContext context, Func action)
- => action();
-
- public ValueTask ExecuteAfterTestHook(MethodMetadata testMethod, TestContext context, Func action)
- => action();
-
- public ValueTask ExecuteBeforeTestSessionHook(MethodMetadata testMethod, TestSessionContext context, Func action)
- => action();
-
- public ValueTask ExecuteAfterTestSessionHook(MethodMetadata testMethod, TestSessionContext context, Func action)
- => action();
-}
diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs
index 594c1c9abf..8f821771ad 100644
--- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs
+++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs
@@ -1,7 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
-using TUnit.Core;
using TUnit.Core.Hooks;
-using TUnit.Core.Interfaces;
namespace TUnit.Engine.Helpers;
@@ -18,14 +15,11 @@ public static Task 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 with potential custom executor
- return ExecuteHookWithPotentialCustomExecutor(hook, context, cancellationToken).AsTask();
+ return hook.ExecuteAsync(context, cancellationToken).AsTask();
}
var timeoutMs = (int)timeout.Value.TotalMilliseconds;
@@ -43,7 +37,7 @@ static async Task CreateTimeoutHookActionAsync(
try
{
- await ExecuteHookWithPotentialCustomExecutor(hook, context, cts.Token);
+ await hook.ExecuteAsync(context, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
@@ -53,48 +47,6 @@ static async Task CreateTimeoutHookActionAsync(
}
}
- ///
- /// 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 ExecuteBeforeTestHook(hook, context, cancellationToken, customExecutor, testContext);
- }
- else if (hook is AfterTestHookMethod)
- {
- return ExecuteAfterTestHook(hook, context, cancellationToken, customExecutor, testContext);
- }
- }
-
- // No custom executor, use the hook's default executor
- return hook.ExecuteAsync(context, cancellationToken);
- }
-
- private static ValueTask ExecuteBeforeTestHook(StaticHookMethod hook, [DisallowNull] T context,
- CancellationToken cancellationToken, IHookExecutor customExecutor, TestContext testContext) =>
- customExecutor.ExecuteBeforeTestHook(
- hook.MethodInfo,
- testContext,
- () => hook.Body!.Invoke(context, cancellationToken)
- );
-
- private static ValueTask ExecuteAfterTestHook(StaticHookMethod hook, [DisallowNull] T context,
- CancellationToken cancellationToken, IHookExecutor customExecutor, TestContext testContext) =>
- customExecutor.ExecuteAfterTestHook(
- hook.MethodInfo,
- testContext,
- () => hook.Body!.Invoke(context, cancellationToken)
- );
-
///
/// Creates a timeout-aware action wrapper for a hook delegate
///
diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs
index 9a6a7f9001..969eba3d33 100644
--- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs
+++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs
@@ -302,11 +302,15 @@ public async ValueTask InvokeHookRegistrationEventReceiversAsync(HookRegisteredC
await receiver.OnHookRegistered(hookContext);
}
- // Apply the timeout from the context back to the hook method
if (hookContext is { Timeout: not null })
{
hookContext.HookMethod.Timeout = hookContext.Timeout;
}
+
+ if (hookContext.HookExecutor is not null)
+ {
+ hookContext.HookMethod.SetHookExecutor(hookContext.HookExecutor);
+ }
}
// First/Last event methods with fast-path checks
diff --git a/TUnit.Engine/Services/HookDelegateBuilder.cs b/TUnit.Engine/Services/HookDelegateBuilder.cs
index 6f78f1a99a..4e75537f66 100644
--- a/TUnit.Engine/Services/HookDelegateBuilder.cs
+++ b/TUnit.Engine/Services/HookDelegateBuilder.cs
@@ -561,41 +561,17 @@ private async ValueTask> CreateInstanceHookDelega
return new NamedHookDelegate(name, async (context, 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;
-
- // Skip skipped test instances
- if (context.Metadata.TestDetails.ClassInstance is SkippedTestInstance)
- {
- return;
- }
-
- if (context.Metadata.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.Metadata.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();
- }
+ // Precedence + skip/placeholder handling + CustomHookExecutor fallback all live
+ // in InstanceHookMethod.ExecuteAsync (via ResolveEffectiveExecutor), matching the
+ // static BeforeTestHookMethod/AfterTestHookMethod 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 a8ef282b22..14b4be56dc 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
@@ -840,6 +840,7 @@ namespace
public class HookRegisteredContext
{
public HookRegisteredContext(. hookMethod) { }
+ public .? HookExecutor { get; set; }
public . HookMethod { get; }
public string HookName { get; }
public ? Timeout { get; set; }
@@ -1975,6 +1976,7 @@ namespace .Exceptions
}
public class InconclusiveTestException : .
{
+ public InconclusiveTestException(string message) { }
public InconclusiveTestException(string message, exception) { }
}
public class SkipTestException : .
@@ -2015,12 +2017,13 @@ namespace .Exceptions
namespace .Executors
{
[(.Assembly | .Class | .Method)]
- public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public CultureAttribute(.CultureInfo cultureInfo) { }
public CultureAttribute(string cultureName) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
public class HookExecutorAttribute : .TUnitAttribute
@@ -2041,28 +2044,31 @@ namespace .Executors
}
[(.Assembly | .Class | .Method)]
[.("windows")]
- public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public STAThreadExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
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 e9ed0a0976..6f09adb018 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
@@ -840,6 +840,7 @@ namespace
public class HookRegisteredContext
{
public HookRegisteredContext(. hookMethod) { }
+ public .? HookExecutor { get; set; }
public . HookMethod { get; }
public string HookName { get; }
public ? Timeout { get; set; }
@@ -1975,6 +1976,7 @@ namespace .Exceptions
}
public class InconclusiveTestException : .
{
+ public InconclusiveTestException(string message) { }
public InconclusiveTestException(string message, exception) { }
}
public class SkipTestException : .
@@ -2015,12 +2017,13 @@ namespace .Exceptions
namespace .Executors
{
[(.Assembly | .Class | .Method)]
- public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public CultureAttribute(.CultureInfo cultureInfo) { }
public CultureAttribute(string cultureName) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
public class HookExecutorAttribute : .TUnitAttribute
@@ -2041,28 +2044,31 @@ namespace .Executors
}
[(.Assembly | .Class | .Method)]
[.("windows")]
- public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public STAThreadExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
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 f2934aed1c..4fe2e05012 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
@@ -840,6 +840,7 @@ namespace
public class HookRegisteredContext
{
public HookRegisteredContext(. hookMethod) { }
+ public .? HookExecutor { get; set; }
public . HookMethod { get; }
public string HookName { get; }
public ? Timeout { get; set; }
@@ -1975,6 +1976,7 @@ namespace .Exceptions
}
public class InconclusiveTestException : .
{
+ public InconclusiveTestException(string message) { }
public InconclusiveTestException(string message, exception) { }
}
public class SkipTestException : .
@@ -2015,12 +2017,13 @@ namespace .Exceptions
namespace .Executors
{
[(.Assembly | .Class | .Method)]
- public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public CultureAttribute(.CultureInfo cultureInfo) { }
public CultureAttribute(string cultureName) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
public class HookExecutorAttribute : .TUnitAttribute
@@ -2041,28 +2044,31 @@ namespace .Executors
}
[(.Assembly | .Class | .Method)]
[.("windows")]
- public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public STAThreadExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public TestExecutorAttribute([.(..PublicConstructors)] type) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
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 e0b73a707b..b1b8d80084 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
@@ -816,6 +816,7 @@ namespace
public class HookRegisteredContext
{
public HookRegisteredContext(. hookMethod) { }
+ public .? HookExecutor { get; set; }
public . HookMethod { get; }
public string HookName { get; }
public ? Timeout { get; set; }
@@ -1921,6 +1922,7 @@ namespace .Exceptions
}
public class InconclusiveTestException : .
{
+ public InconclusiveTestException(string message) { }
public InconclusiveTestException(string message, exception) { }
}
public class SkipTestException : .
@@ -1961,12 +1963,13 @@ namespace .Exceptions
namespace .Executors
{
[(.Assembly | .Class | .Method)]
- public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class CultureAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public CultureAttribute(.CultureInfo cultureInfo) { }
public CultureAttribute(string cultureName) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
public class HookExecutorAttribute : .TUnitAttribute
@@ -1986,28 +1989,31 @@ namespace .Executors
public InvariantCultureAttribute() { }
}
[(.Assembly | .Class | .Method)]
- public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public class STAThreadExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public STAThreadExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
{
public TestExecutorAttribute( type) { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
- public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., .
+ public sealed class TestExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., ., .
where T : ., new ()
{
public TestExecutorAttribute() { }
public int Order { get; }
public ScopeType { get; }
+ public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
}
diff --git a/TUnit.TestProject/CultureHookTests.cs b/TUnit.TestProject/CultureHookTests.cs
new file mode 100644
index 0000000000..4377e93e35
--- /dev/null
+++ b/TUnit.TestProject/CultureHookTests.cs
@@ -0,0 +1,99 @@
+using System.Globalization;
+using TUnit.Core.Executors;
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject;
+
+///
+/// Regression tests for https://github.com/thomhurst/TUnit/issues/5452 — CultureAttribute
+/// applied at method, class, and assembly level must also affect lifecycle hooks, not just
+/// the test body.
+///
+[EngineTest(ExpectedResult.Pass)]
+[Culture("de-AT")]
+public class CultureHookTests_ClassLevel
+{
+ private static string? _beforeClassCulture;
+
+ [Before(Class)]
+ public static Task BeforeClass()
+ {
+ _beforeClassCulture = CultureInfo.CurrentCulture.Name;
+ return Task.CompletedTask;
+ }
+
+ [After(Class)]
+ public static async Task AfterClass()
+ {
+ // Runs after the last test in this class — no subsequent test can read a captured
+ // value, so assert directly in the hook. A failure here fails the class teardown.
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+
+ [Before(Test)]
+ public async Task BeforeTest()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+
+ [After(Test)]
+ public async Task AfterTest()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+
+ [Test]
+ public async Task Test_Body_RunsInClassCulture()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+
+ [Test]
+ public async Task Test_BeforeClassHook_RanInClassCulture()
+ {
+ // Before(Class) hook must have executed in the class-level [Culture] context.
+ await Assert.That(_beforeClassCulture).IsEqualTo("de-AT");
+ }
+}
+
+[EngineTest(ExpectedResult.Pass)]
+[Culture("de-AT")]
+public class CultureHookTests_MethodLevelOverride
+{
+ [Test, Culture("fr-FR")]
+ public async Task MethodLevel_Overrides_ClassLevel()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("fr-FR");
+ }
+
+ [Before(Test)]
+ public async Task BeforeTest()
+ {
+ // Before(Test) shares the same CustomHookExecutor as the test body, so it runs
+ // under the most-specific [Culture] resolved for each test — fr-FR when the
+ // test method has its own override. This fixture intentionally contains a single
+ // test with [Culture("fr-FR")]; adding a test without that override would fail
+ // this assertion because the class-level de-AT would apply instead — use
+ // CultureHookTests_MethodLevelInheritsClass below for the no-override case.
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("fr-FR");
+ }
+}
+
+[EngineTest(ExpectedResult.Pass)]
+[Culture("de-AT")]
+public class CultureHookTests_MethodLevelInheritsClass
+{
+ // No method-level [Culture] override — class-level de-AT applies to both the test
+ // and its Before(Test) hook.
+ [Test]
+ public async Task Test_InheritsClassCulture()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+
+ [Before(Test)]
+ public async Task BeforeTest()
+ {
+ await Assert.That(CultureInfo.CurrentCulture.Name).IsEqualTo("de-AT");
+ }
+}
diff --git a/docs/docs/reference/command-line-flags.md b/docs/docs/reference/command-line-flags.md
index f013f694f0..13aac8fc16 100644
--- a/docs/docs/reference/command-line-flags.md
+++ b/docs/docs/reference/command-line-flags.md
@@ -138,11 +138,11 @@ Please note that for the coverage and trx report, you need to install [additiona
--report-html-filename
Path for the HTML test report file
- (default: TestResults/{AssemblyName}-report.html).
+ (default: TestResults/{'{AssemblyName}'}-report.html).
--junit-output-path
Path to output JUnit XML file
- (default: TestResults/{AssemblyName}-junit.xml).
+ (default: TestResults/{'{AssemblyName}'}-junit.xml).
The JUnit reporter is also togglable via TUNIT_ENABLE_JUNIT_REPORTER /
TUNIT_DISABLE_JUNIT_REPORTER environment variables.