fix: cascade HookExecutorAttribute from class/assembly to hooks (#5462)#5512
fix: cascade HookExecutorAttribute from class/assembly to hooks (#5462)#5512
Conversation
HookExecutorAttribute was only honored when applied directly to a hook method. ReflectionHookDiscoveryService.GetHookExecutor and HookMetadataGenerator.GetHookExecutorType both only consulted method-level attributes, so [HookExecutor<T>] on the containing class or the assembly was silently ignored and the hook ran on DefaultExecutor. Mirror the pattern #5463 introduced for CultureAttribute / STAThreadExecutorAttribute / TestExecutorAttribute: implement IHookRegisteredEventReceiver and IScopedAttribute on the non-generic HookExecutorAttribute base. The generic HookExecutorAttribute<T> inherits the implementation. Cascading is delivered for free by the existing pipeline: - MethodMetadata.GetCustomAttributes() already aggregates method + class + assembly attributes in that order. - EventReceiverOrchestrator runs them through ScopedAttributeFilter (first wins per ScopeType), so method-level beats class-level beats assembly-level for ScopeType = typeof(IHookExecutor). - HookMethod.SetHookExecutor leaves _hookExecutorIsExplicit = false, so cascaded executors remain overridable by per-test CustomHookExecutor (#2666 path), while a method-level [HookExecutor<T>] still wins via the init-time _hookExecutorIsExplicit = true branch in ResolveEffectiveExecutor. No changes to discovery services — both source-gen and reflection modes are fixed via the receiver pipeline alone. Also tightens the base attribute with [AttributeUsage(Assembly|Class| Method)] (it had no AttributeUsage previously and defaulted to All) and adds [DynamicallyAccessedMembers(PublicConstructors)] to the constructor parameter and to the generic T parameter for AOT/trim correctness. Tests: HookExecutorHookTests.cs adds three regression fixtures mirroring CultureHookTests — class-level cascading, method-level override, and inherits-class — each using its own RecordingHookExecutor subclass so parallel runs stay isolated. Verified passing in both source-gen and reflection modes.
d5c5024 to
4aacd03
Compare
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
Overall this is a clean, well-scoped fix that correctly mirrors the CultureAttribute pattern established in #5463. The changes are minimal, targeted, and additive-only on the public API.
Core Change (HookExecutorAttribute.cs)
Minor style nit — inline ??= vs. named property accessor
CultureAttribute exposes its cached executor via a named property:
private CultureExecutor? _executor;
private CultureExecutor Executor => _executor ??= new CultureExecutor(cultureInfo);The new HookExecutorAttribute.OnHookRegistered inlines the caching:
context.HookExecutor = _executor ??= (IHookExecutor)Activator.CreateInstance(_hookExecutorType)!;This works, but extracting to a private property (private IHookExecutor Executor => _executor ??= (IHookExecutor)Activator.CreateInstance(_hookExecutorType)!;) would maintain consistency with the sibling pattern and slightly reduce noise in the hotpath method. Minor, but worth aligning for consistency.
Benign thread-safety note
The _executor ??= compound operation isn't atomically thread-safe — two threads could both observe null and both call Activator.CreateInstance. CultureAttribute has the same gap. Since IHookExecutor implementations are expected to be stateless (see GenericAbstractExecutor), the double-instantiation is harmless and wasted only one object allocation. Still, something to be aware of if a stateful executor is ever introduced.
ScopeType correctness
CultureAttribute uses ScopeType = typeof(ITestExecutor) (because it affects test execution primarily), while the new attribute uses typeof(IHookExecutor). This is semantically correct — HookExecutorAttribute only governs hooks — and ensures the two attributes don't compete for the same deduplication slot in ScopedAttributeFilter, which is the right behaviour.
Tests (HookExecutorHookTests.cs)
The bucket-per-executor-type isolation strategy in RecordingHookExecutorState is smart and avoids cross-fixture pollution. A few observations:
IsEqualTo(0) in MethodLevelOverride is load-bearing
var classLevelCount = RecordingHookExecutorState.Count(nameof(RecordingHookExecutor_F2ClassLevel));
await Assert.That(classLevelCount).IsEqualTo(0);This relies on HookExecutorAttribute not wrapping test method bodies (only hooks). That's currently true, but the assertion would silently start failing if the engine ever extends HookExecutor scope to test methods. A comment explaining this invariant ("HookExecutorAttribute applies to lifecycle hooks only, not test method bodies") would protect future readers.
Static state never reset
RecordingHookExecutorState._invocations is never cleared between runs. If tests are retried (e.g., flake-retry tooling) the counts accumulate, making the >= N assertions still pass but masking a zero count on a re-run. Not a current issue, but a design smell to note.
>= N vs. exact count assertions
The assertions are intentionally loose (IsGreaterThanOrEqualTo) to tolerate parallel execution ordering. That's pragmatic given that CultureHookTests can use exact equality because it checks the current state, not a counter. The looseness is acceptable, but it means a regression where the executor is called too many times (e.g., double-registration) would go undetected. Not a blocking concern.
Assembly-level coverage
The fix claims to cover assembly-level cascading (same pipeline), but no assembly-level test exists. Since the mechanism is shared with class-level, this is a gap in test documentation rather than correctness, but a follow-up test would be a good addition.
Snapshot files
All four TFM snapshots (net8.0, net9.0, net10.0, net472) are correctly updated. The .Net4_7 snapshot omitting [DynamicallyAccessedMembers] on the constructor parameter is expected behaviour.
Summary
The fix is correct and well-implemented. The issues raised above are minor observations — no blockers. The AOT annotations, AttributeUsage correction, and adherence to the IScopedAttribute/IHookRegisteredEventReceiver pipeline are all done right.
Summary
Fixes #5462.
HookExecutorAttributewas only honored when applied directly to a hook method — placing it on the containing class or the assembly was silently ignored, and the hook ran onDefaultExecutor. This is the inverse gap to #5452/#5463 (which fixedCultureAttribute/STAThreadExecutorAttribute/TestExecutorAttributenot applying to hooks at all).Approach
Mirror the pattern #5463 introduced. Make the non-generic
HookExecutorAttributebase implementIHookRegisteredEventReceiverandIScopedAttribute(withScopeType = typeof(IHookExecutor)); the genericHookExecutorAttribute<T>inherits the implementation. Cascading is delivered for free by the existing receiver pipeline:MethodMetadata.GetCustomAttributes()already aggregates method + class + assembly attributes, in that order.EventReceiverOrchestrator.InvokeHookRegistrationEventReceiversAsyncruns them throughScopedAttributeFilter(first wins perScopeType), so method-level beats class-level beats assembly-level automatically.HookMethod.SetHookExecutorleaves_hookExecutorIsExplicit = false, so cascaded executors remain overridable by per-testCustomHookExecutor(How to wrap all methods of a test correctly? #2666 path), while a method-level[HookExecutor<T>]still wins via the init-time_hookExecutorIsExplicit = truebranch inResolveEffectiveExecutor.No changes to discovery services — both source-gen (
HookMetadataGenerator) and reflection (ReflectionHookDiscoveryService) modes are fixed via the receiver pipeline alone, exactly as the issue suggested.The base attribute also gains
[AttributeUsage(Assembly | Class | Method)](it had noAttributeUsagepreviously and defaulted toAll) and[DynamicallyAccessedMembers(PublicConstructors)]on both the constructor parameter and the genericTparameter for AOT/trim correctness.Public API delta
HookExecutorAttribute(non-generic) now implementsIHookRegisteredEventReceiver+IScopedAttributeand exposesOrder,ScopeType,OnHookRegistered. Snapshot files updated for net8.0 / net9.0 / net10.0 / net472. Additive only —+semver:minor.Test plan
HookExecutorHookTests.csfixtures (mirroringCultureHookTests):HookExecutorHookTests_ClassLevel— class-level[HookExecutor<T>]cascades toBefore/After(Class)andBefore/After(Test)HookExecutorHookTests_MethodLevelOverride— method-level beats class-level (asserts class-level executor was not invoked)HookExecutorHookTests_InheritsClassLevel— class-level applies when no method-level overrideRecordingHookExecutorsubclass so parallel runs stay isolatedmain, then passing after fix--reflection)HookExecutorTests(5 tests, method-level coverage of every hook lifecycle slot) pass in both modes — no regression in the existing pathCultureHookTests_*(4 tests, the analogous [Bug]: Test hooks are run in default culture #5452 coverage) pass in both modesTUnit.Core.SourceGenerator.Tests(117 tests) pass — source-gen unaffectedTUnit.PublicAPI.Core_Library_Has_No_API_Changessnapshot test passes on net8.0, net9.0, net10.0, net472 with the accepted deltasdotnet build TUnit.slnx— 0 errors