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.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.30.8 to 1.33.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.33.0 <!-- Release notes generated using configuration in .github/release.yml at v1.33.0 --> ## What's Changed ### Other Changes * perf: engine-wide performance optimizations by @thomhurst in thomhurst/TUnit#5520 * feat: Add TUnitSettings static API for programmatic configuration by @thomhurst in thomhurst/TUnit#5522 * perf: reduce allocations and improve hot-path performance by @thomhurst in thomhurst/TUnit#5524 * fix: enforce ParallelLimiter semaphore in TestRunner to prevent DependsOn bypass by @thomhurst in thomhurst/TUnit#5526 ### Dependencies * chore(deps): update tunit to 1.32.0 by @thomhurst in thomhurst/TUnit#5513 **Full Changelog**: thomhurst/TUnit@v1.32.0...v1.33.0 ## 1.32.0 <!-- Release notes generated using configuration in .github/release.yml at v1.32.0 --> ## What's Changed ### Other Changes * fix: auto-register correlated logging for minimal API hosts (#5503) by @thomhurst in thomhurst/TUnit#5511 * fix: cascade HookExecutorAttribute from class/assembly to hooks (#5462) by @thomhurst in thomhurst/TUnit#5512 ### Dependencies * chore(deps): update dependency polyfill to 10.3.0 by @thomhurst in thomhurst/TUnit#5508 * chore(deps): update tunit to 1.31.0 by @thomhurst in thomhurst/TUnit#5510 * chore(deps): update dependency polyfill to 10.3.0 by @thomhurst in thomhurst/TUnit#5509 **Full Changelog**: thomhurst/TUnit@v1.31.0...v1.32.0 ## 1.31.0 <!-- Release notes generated using configuration in .github/release.yml at v1.31.0 --> ## What's Changed ### Other Changes * feat(reporters): overhaul GitHub Actions step summary by @thomhurst in thomhurst/TUnit#5483 * fix: truncate large stdout/stderr in HTML report to prevent JSON serialization failure by @thomhurst in thomhurst/TUnit#5485 * feat(html-report): add failure clustering to test report by @thomhurst in thomhurst/TUnit#5490 * feat(html-report): add chevron affordance to failure cluster headers by @thomhurst in thomhurst/TUnit#5492 * feat(reporters): group GitHub summary failures by exception type by @thomhurst in thomhurst/TUnit#5491 * feat(reporters): add minimap sidebar navigator to HTML report by @thomhurst in thomhurst/TUnit#5494 * feat(html-report): add category/tag filter pills to toolbar by @thomhurst in thomhurst/TUnit#5496 * feat(html-report): omit redundant test body span from trace timeline by @thomhurst in thomhurst/TUnit#5497 * fix(tests): clear reporter env vars before each GitHubReporterTest to fix flaky CI on macOS/Windows by @thomhurst in thomhurst/TUnit#5499 * feat: add TestContext.MakeCurrent() for console output correlation by @thomhurst in thomhurst/TUnit#5502 * feat(html-report): add flaky test detection and summary section by @thomhurst in thomhurst/TUnit#5498 * fix: smarter stack trace filtering that preserves TUnit-internal traces by @thomhurst in thomhurst/TUnit#5506 * feat: add Activity baggage-based test context correlation by @thomhurst in thomhurst/TUnit#5505 ### Dependencies * chore(deps): update actions/github-script action to v9 by @thomhurst in thomhurst/TUnit#5476 * chore(deps): update tunit to 1.30.8 by @thomhurst in thomhurst/TUnit#5477 * chore(deps): update dependency polyfill to 10.2.0 by @thomhurst in thomhurst/TUnit#5482 * chore(deps): update dependency polyfill to 10.2.0 by @thomhurst in thomhurst/TUnit#5481 * chore(deps): update actions/upload-artifact action to v7.0.1 by @thomhurst in thomhurst/TUnit#5495 **Full Changelog**: thomhurst/TUnit@v1.30.8...v1.31.0 Commits viewable in [compare view](thomhurst/TUnit@v1.30.8...v1.33.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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