diff --git a/TUnit.Core/Attributes/Executors/HookExecutorAttribute.cs b/TUnit.Core/Attributes/Executors/HookExecutorAttribute.cs index 5cdd78c4e9..fc8171c928 100644 --- a/TUnit.Core/Attributes/Executors/HookExecutorAttribute.cs +++ b/TUnit.Core/Attributes/Executors/HookExecutorAttribute.cs @@ -1,11 +1,36 @@ +using System.Diagnostics.CodeAnalysis; using TUnit.Core.Interfaces; namespace TUnit.Core.Executors; -public class HookExecutorAttribute(Type type) : TUnitAttribute +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] +public class HookExecutorAttribute : TUnitAttribute, IHookRegisteredEventReceiver, IScopedAttribute { - public Type HookExecutorType { get; } = type; + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + private readonly Type _hookExecutorType; + + private IHookExecutor? _executor; + + public HookExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) + { + _hookExecutorType = type; + } + + public Type HookExecutorType => _hookExecutorType; + + /// + public int Order => 0; + + /// + public Type ScopeType => typeof(IHookExecutor); + + /// + public ValueTask OnHookRegistered(HookRegisteredContext context) + { + context.HookExecutor = _executor ??= (IHookExecutor)Activator.CreateInstance(_hookExecutorType)!; + return default(ValueTask); + } } [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] -public sealed class HookExecutorAttribute() : HookExecutorAttribute(typeof(T)) where T : IHookExecutor, new(); +public sealed class HookExecutorAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() : HookExecutorAttribute(typeof(T)) where T : IHookExecutor, new(); 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 09c9145279..8b52caf557 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 @@ -2035,13 +2035,17 @@ namespace .Executors public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } } - public class HookExecutorAttribute : .TUnitAttribute + [(.Assembly | .Class | .Method)] + public class HookExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { - public HookExecutorAttribute( type) { } + public HookExecutorAttribute([.(..PublicConstructors)] type) { } public HookExecutorType { get; } + public int Order { get; } + public ScopeType { get; } + public . OnHookRegistered(.HookRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class HookExecutorAttribute : . + public sealed class HookExecutorAttribute<[.(..PublicConstructors)] T> : . where T : ., new () { public HookExecutorAttribute() { } 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 7ca7bd632b..0622541817 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 @@ -2035,13 +2035,17 @@ namespace .Executors public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } } - public class HookExecutorAttribute : .TUnitAttribute + [(.Assembly | .Class | .Method)] + public class HookExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { - public HookExecutorAttribute( type) { } + public HookExecutorAttribute([.(..PublicConstructors)] type) { } public HookExecutorType { get; } + public int Order { get; } + public ScopeType { get; } + public . OnHookRegistered(.HookRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class HookExecutorAttribute : . + public sealed class HookExecutorAttribute<[.(..PublicConstructors)] T> : . where T : ., new () { public HookExecutorAttribute() { } 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 aabe70fe99..0a1d3fcbe5 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 @@ -2035,13 +2035,17 @@ namespace .Executors public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } } - public class HookExecutorAttribute : .TUnitAttribute + [(.Assembly | .Class | .Method)] + public class HookExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { - public HookExecutorAttribute( type) { } + public HookExecutorAttribute([.(..PublicConstructors)] type) { } public HookExecutorType { get; } + public int Order { get; } + public ScopeType { get; } + public . OnHookRegistered(.HookRegisteredContext context) { } } [(.Assembly | .Class | .Method)] - public sealed class HookExecutorAttribute : . + public sealed class HookExecutorAttribute<[.(..PublicConstructors)] T> : . where T : ., new () { public HookExecutorAttribute() { } 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 6efe709542..0266604c25 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 @@ -1977,10 +1977,14 @@ namespace .Executors public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } } - public class HookExecutorAttribute : .TUnitAttribute + [(.Assembly | .Class | .Method)] + public class HookExecutorAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public HookExecutorAttribute( type) { } public HookExecutorType { get; } + public int Order { get; } + public ScopeType { get; } + public . OnHookRegistered(.HookRegisteredContext context) { } } [(.Assembly | .Class | .Method)] public sealed class HookExecutorAttribute : . diff --git a/TUnit.TestProject/HookExecutorHookTests.cs b/TUnit.TestProject/HookExecutorHookTests.cs new file mode 100644 index 0000000000..e448cff67d --- /dev/null +++ b/TUnit.TestProject/HookExecutorHookTests.cs @@ -0,0 +1,164 @@ +using System.Collections.Concurrent; +using TUnit.Core; +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +/// +/// Regression tests for https://github.com/thomhurst/TUnit/issues/5462 — HookExecutorAttribute +/// applied at class (and assembly) level must cascade to hooks declared in the scope, not just +/// to hooks where the attribute sits directly on the method. Mirrors CultureHookTests, which is +/// the analogous coverage for #5452. +/// +internal static class RecordingHookExecutorState +{ + // Each fixture uses its own executor type (and therefore its own bucket) so that + // assertions are not affected by parallel-running fixtures. + private static readonly ConcurrentDictionary> _invocations = new(); + + public static void Record(string bucket, string hookName) + { + _invocations.GetOrAdd(bucket, _ => new ConcurrentBag()).Add(hookName); + } + + public static int Count(string bucket) + { + return _invocations.TryGetValue(bucket, out var bag) ? bag.Count : 0; + } +} + +public sealed class RecordingHookExecutor_F1ClassLevel : GenericAbstractExecutor +{ + protected override async ValueTask ExecuteAsync(Func action) + { + RecordingHookExecutorState.Record(nameof(RecordingHookExecutor_F1ClassLevel), "executed"); + await action(); + } +} + +public sealed class RecordingHookExecutor_F2ClassLevel : GenericAbstractExecutor +{ + protected override async ValueTask ExecuteAsync(Func action) + { + RecordingHookExecutorState.Record(nameof(RecordingHookExecutor_F2ClassLevel), "executed"); + await action(); + } +} + +public sealed class RecordingHookExecutor_F2MethodOverride : GenericAbstractExecutor +{ + protected override async ValueTask ExecuteAsync(Func action) + { + RecordingHookExecutorState.Record(nameof(RecordingHookExecutor_F2MethodOverride), "executed"); + await action(); + } +} + +public sealed class RecordingHookExecutor_F3Inherits : GenericAbstractExecutor +{ + protected override async ValueTask ExecuteAsync(Func action) + { + RecordingHookExecutorState.Record(nameof(RecordingHookExecutor_F3Inherits), "executed"); + await action(); + } +} + +[EngineTest(ExpectedResult.Pass)] +[HookExecutor] +public class HookExecutorHookTests_ClassLevel +{ + [Before(Class)] + public static Task BeforeClass() + { + return Task.CompletedTask; + } + + [After(Class)] + public static async Task AfterClass() + { + // Runs after the last test in this class — assert directly. By the end of the + // class lifecycle the class-level [HookExecutor] must have wrapped at least + // Before(Class), the per-test Before(Test)/After(Test) for both tests, and this + // After(Class) hook itself. We just need ≥ 1 to confirm cascading worked at all. + var count = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F1ClassLevel)); + await Assert.That(count).IsGreaterThanOrEqualTo(1); + } + + [Before(Test)] + public Task BeforeTest() + { + return Task.CompletedTask; + } + + [After(Test)] + public Task AfterTest() + { + return Task.CompletedTask; + } + + [Test] + public async Task ClassLevelHookExecutor_RanForBeforeClassHook() + { + // Before(Class) ran before this test. With the class-level [HookExecutor<...>] + // cascading, the recording executor must have been invoked at least once. + var count = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F1ClassLevel)); + await Assert.That(count).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task ClassLevelHookExecutor_RanForBeforeTestHook() + { + // By the time this test body runs, Before(Test) has executed for it. The recording + // executor should have been invoked at least twice — once for Before(Class) (shared + // across both tests) and once for the most recent Before(Test). + var count = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F1ClassLevel)); + await Assert.That(count).IsGreaterThanOrEqualTo(2); + } +} + +[EngineTest(ExpectedResult.Pass)] +[HookExecutor] +public class HookExecutorHookTests_MethodLevelOverride +{ + [Before(Test), HookExecutor] + public Task BeforeTest() + { + return Task.CompletedTask; + } + + [Test] + public async Task MethodLevel_HookExecutor_OverridesClassLevel() + { + // The Before(Test) hook above carries its own [HookExecutor], + // which must beat the class-level [HookExecutor] for this hook. + // Method-override executor must be invoked, class-level executor must not be — + // there is no other hook in this fixture for the class-level executor to wrap. + var methodOverrideCount = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F2MethodOverride)); + await Assert.That(methodOverrideCount).IsGreaterThanOrEqualTo(1); + + var classLevelCount = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F2ClassLevel)); + await Assert.That(classLevelCount).IsEqualTo(0); + } +} + +[EngineTest(ExpectedResult.Pass)] +[HookExecutor] +public class HookExecutorHookTests_InheritsClassLevel +{ + // No method-level [HookExecutor] override — class-level RecordingHookExecutor_F3Inherits + // applies to the Before(Test) hook. + [Before(Test)] + public Task BeforeTest() + { + return Task.CompletedTask; + } + + [Test] + public async Task InheritsClassHookExecutor_ForBeforeTest() + { + var count = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F3Inherits)); + await Assert.That(count).IsGreaterThanOrEqualTo(1); + } +}