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.