fix: deduplicate virtual hook overrides across class hierarchy (#5428)#5441
fix: deduplicate virtual hook overrides across class hierarchy (#5428)#5441
Conversation
A virtual [Before(Test)]/[After(Test)] method overridden in a derived class was registered on both the base and derived types. Virtual dispatch caused both registrations to invoke the same override, executing it twice. Dedupe via MethodInfo.GetBaseDefinition() while walking derived to base.
There was a problem hiding this comment.
Code Review
Good fix for a subtle virtual dispatch bug. The core logic is correct — walking the hierarchy from derived to base and using GetBaseDefinition() to detect already-seen overrides is the right approach. Here are a few things worth considering:
Issues
1. Protected virtual hooks are silently not deduplicated
IsOverriddenByMoreDerivedHook uses BindingFlags.Public only:
var methodInfo = declaringType.GetMethod(
hook.MethodInfo.Name,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly,
...);If a user writes protected virtual / protected override hook methods, GetMethod returns null, the guard returns false, and both registrations still fire. The bug from #5428 reappears silently.
This is partially constrained by the [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] annotation on InstanceHookMethod.ClassType, which would require adding NonPublicMethods to safely extend this to protected methods under AOT. That change to the annotation would be the right companion fix, or at minimum the limitation should be documented.
2. Architectural smell: reflection lookup to rediscover what's already known
The core awkwardness is that InstanceHookMethod.MethodInfo is a MethodMetadata (a framework-level DTO), not a System.Reflection.MethodInfo. So IsOverriddenByMoreDerivedHook has to reverse-lookup the real MethodInfo from the declaring type by name + parameter types — which is exactly the kind of fragile string-based reflection that can break with overloads, explicit interface implementations, or non-public signatures.
A more robust architecture would store the resolved MethodInfo.GetBaseDefinition() token directly on InstanceHookMethod (or on MethodMetadata), either at registration time (source-gen or reflection discovery), or lazily cached. That would make the deduplication O(1) set lookup with no secondary reflection:
// In InstanceHookMethod / HookMethod:
public MethodInfo? BaseDefinition { get; init; }And deduplication becomes simply:
if (hook.BaseDefinition is not null && !seenBaseDefinitions.Add(hook.BaseDefinition))
continue;3. Source-gen mode not covered (CLAUDE.md dual-mode rule)
The fix is in TUnit.Engine/Services/HookDelegateBuilder.cs (reflection path only). Per the project rules, changes to core hook metadata collection must work in both modes. Does the source-generator register virtual hook overrides with separate entries too? If so, the source-gen path has the same double-execution bug and this fix won't help users running in source-gen mode.
Minor observations
- The regression test asserts via
throw new Exception(...)rather than using TUnit's assertion APIs — fine for a regression test, but usingAssert.That(SetupCallCount).IsEqualTo(1)would be more idiomatic. Before(Class)/After(Class)hooks are intentionally excluded from this fix since they useCreateStaticHookDelegateAsync(static methods can't be virtual in this sense). That's correct and doesn't need changing.- The duplicated
seenBaseDefinitionsguard in bothsourceHooksandopenTypeHookspaths within each method is correct — the sameHashSetspans both loops within a single type level, which is the right behavior.
Summary
The fix correctly solves the reported issue for the common case (public virtual hooks). The two areas that deserve a follow-up are: (1) extending coverage to protected virtual hooks (with the corresponding DAM annotation update), and (2) verifying or fixing the source-gen path. The longer-term architectural improvement of storing the base definition on InstanceHookMethod would make this whole class of bugs easier to reason about and prevent.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 12 |
TIP This summary will be updated as you push new changes. Give us feedback
- Pre-resolve BaseDefinition on InstanceHookMethod in the reflection discovery path so deduplication uses a stored MethodInfo instead of reverse-resolving by name+parameters at engine build time. - HookDelegateBuilder uses the stored BaseDefinition first and only falls back to reflection lookup when not set (e.g. source-generated path). - Switch the regression test to TUnit assertions, add an After(Test) override case, and verify dual-mode (source-gen + reflection). Source-generated and reflection hook discovery both filter on Public, so protected hooks aren't supported by either path — no separate fix needed for that scenario.
There was a problem hiding this comment.
Follow-up Review (after second commit 24d769)
The second commit addresses the three main points from the previous review. Here is a full assessment of the current state.
What was addressed
Pre-resolving BaseDefinition in the reflection path — done correctly. ReflectionHookDiscoveryService now calls method.GetBaseDefinition() at registration time and stores it on InstanceHookMethod.BaseDefinition. The engine prefers this stored value and only falls back to the name+parameter reflection lookup for the source-gen path. This is the right architecture — the reflection path now pays essentially zero extra cost at dedup time.
TUnit assertions in the regression test — done. The test now uses await Assert.That(SetupCalls).IsEqualTo(1) and also covers TeardownAsync with an override and a counter. Good.
[EditorBrowsable(EditorBrowsableState.Never)] on BaseDefinition — good defensive choice to keep framework-internal surface out of user-visible IntelliSense.
Remaining issues worth noting
1. TeardownCalls is never asserted
The regression test increments TeardownCalls in TeardownAsync and asserts SetupCalls == 1, but there is no corresponding assertion for TeardownCalls. If the After(Test) dedup regressed, the test would not catch it. Adding await Assert.That(TeardownCalls).IsEqualTo(1) in Override_Should_Run_Once (or a dedicated second test method) would complete the coverage.
2. ResolveBaseDefinition is the live path for source-gen mode — and it uses BindingFlags.DeclaredOnly
The comment says the fallback runs for source-gen. But BindingFlags.DeclaredOnly means: if the source-gen registered the hook under BaseTestClass (because the [Before(Test)] attribute lives on the base method), ResolveBaseDefinition is called with hook.ClassType == BaseTestClass. On that type, DeclaredOnly will find the method — that's fine. But if the registered type is the derived class and the attribute was inherited/re-declared, DeclaredOnly correctly finds the override. The logic is sound for a single level of inheritance.
However, consider a three-level hierarchy: A (base, [Before(Test)] virtual void Foo()), B : A ([Before(Test)] override void Foo()), C : B ([Before(Test)] override void Foo()). Walking from C to A:
Chook:ResolveBaseDefinitionfindsC.Foo,GetBaseDefinition()→A.Foo. Added toseenBaseDefinitions. ✓Bhook:ResolveBaseDefinitionfindsB.Foo,GetBaseDefinition()→A.Foo. Already seen → skipped. ✓Ahook:ResolveBaseDefinitionfindsA.Foo,GetBaseDefinition()→A.Foo. Already seen → skipped. ✓
This actually works correctly. The GetBaseDefinition() contract guarantees all overrides in a chain share the same root definition, so the HashSet correctly deduplicates the entire chain.
3. The [EditorBrowsable] attribute is redundant for BaseDefinition
InstanceHookMethod is itself decorated with [EditorBrowsable(EditorBrowsableState.Never)] at the class level (in non-DEBUG builds, via the inherited #if !DEBUG on HookMethod). Adding [EditorBrowsable] to an individual property of an already-hidden type is harmless but redundant. Not a bug — just noise.
4. The MethodInfo? BaseDefinition property is on InstanceHookMethod in TUnit.Core
TUnit.Core is the public API surface for test authors and is supposed to be AOT-compatible. Adding System.Reflection.MethodInfo (which is not AOT-friendly for general use) to a public record in TUnit.Core is a minor API surface concern. It's annotated [EditorBrowsable(Never)] which helps, but it does widen the DAM/AOT exposure. Longer-term, this could move to an internal sibling type or be wrapped with #if REFLECTION_BASED, but it's a minor concern and not a blocker given the [EditorBrowsable] guard.
Summary
The second commit genuinely improves the implementation — the pre-resolved BaseDefinition eliminates the main architectural smell from the first version, and the dedup logic is correct for all depths of virtual override chains. The primary actionable gap is the missing TeardownCalls assertion. Everything else is either working correctly or a cosmetic concern. The fix is solid and ready to merge once that assertion gap is addressed.
There was a problem hiding this comment.
Follow-up Review (third commit: 94a7a28)
The third commit adds [EngineTest(ExpectedResult.Pass)] to the regression test — a correct fix to ensure the test is executed by the engine harness and its result is validated.
Outstanding gap from previous review
TeardownCalls is still never asserted.
TeardownAsync increments TeardownCalls and After(Test) deduplication is separately applied in BuildAfterTestHooksAsync, but there is no assertion in Override_Should_Run_Once (or any other test method) that checks TeardownCalls == 1. If the After(Test) dedup regressed, no test would catch it.
The simplest fix — add one line to the existing test:
[Test]
[EngineTest(ExpectedResult.Pass)]
public async Task Override_Should_Run_Once()
{
await Assert.That(SetupCalls).IsEqualTo(1);
await Assert.That(TeardownCalls).IsEqualTo(1); // add this
}Note: TeardownCalls will be 0 at the time the test body runs (the After hook fires after the test), so to assert it correctly the count should be checked in a subsequent After(Test) hook or via a dedicated counter-assertion hook. The cleanest approach is a separate [After(Test)] assertion method in DerivedTestClass:
[After(Test)]
public async Task AssertTeardownRunsOnce()
{
await Assert.That(TeardownCalls).IsEqualTo(1);
}This would run after the TeardownAsync override, validating it fired exactly once.
Everything else is in good shape
All three substantive points from the first review were addressed in commit 24d769:
- Pre-resolved
BaseDefinitioneliminates the name+params reverse-lookup for the reflection path. - TUnit assertions are used throughout.
- Three-level virtual chain dedup is correct (verified by reasoning through
GetBaseDefinition()contract).
The fix is correct, well-structured, and ready to merge once the TeardownCalls coverage gap is closed.
There was a problem hiding this comment.
Follow-up Review (commits 68fd235 and 1d680db)
This is a final review covering the two remaining commits since the last round of feedback.
What changed
Commit 68fd235 — adds the AfterTeardownAssertion hook with Order = int.MaxValue to assert TeardownCalls == 1 after TeardownAsync fires. This closes the last substantive gap called out in the previous review: the After(Test) deduplication path now has an assertion that will catch a regression.
Commit 1d680db — updates the public API snapshot files for all target frameworks (net8.0, net9.0, net10.0, and net4.7) to include MethodInfo? BaseDefinition { get; init; } on InstanceHookMethod. This is the correct snapshot hygiene step — the verified files are committed (not .received.txt), satisfying the snapshot testing rule in CLAUDE.md.
Assessment
All three points from the original review and the single outstanding gap from the second/third review rounds are now resolved:
- Pre-resolved
BaseDefinitionon the reflection path — done in commit 24d769. - TUnit assertion idioms in the regression test — done in commit 24d769.
TeardownCallsassertion for theAfter(Test)dedup path — done in commit 68fd235, using[After(Test, Order = int.MaxValue)]which is an established pattern in this codebase (consistent with howNotInParallelAttribute.Orderand other ordering logic work).- Public API snapshot files — done in commit 1d680db.
Minor observation (no action needed)
AfterTeardownAssertion is not itself marked [EngineTest(ExpectedResult.Pass)] — but that is correct, because it is an [After(Test)] hook rather than a [Test] method. The assertion inside it will surface as a test failure on Override_Should_Run_Once if it throws, which is the right behaviour.
Conclusion
The implementation is correct, well-tested, and complete. The deduplication logic is sound for arbitrary inheritance depths (all overrides in a chain share the same GetBaseDefinition() root), the reflection path pays zero extra cost at dedup time (pre-resolved at registration), and the source-gen fallback is in place. This is ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.28.7 to 1.29.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.29.0 <!-- Release notes generated using configuration in .github/release.yml at v1.29.0 --> ## What's Changed ### Other Changes * 🤖 Update Mock Benchmark Results by @thomhurst in thomhurst/TUnit#5420 * fix(mocks): resolve build errors when mocking Azure SDK clients by @thomhurst in thomhurst/TUnit#5440 * fix: deduplicate virtual hook overrides across class hierarchy (#5428) by @thomhurst in thomhurst/TUnit#5441 * fix(mocks): unique __argArray locals per event in RaiseEvent dispatch (#5423) by @thomhurst in thomhurst/TUnit#5442 * refactor(mocks): extract MockTypeModel.Visibility helper by @thomhurst in thomhurst/TUnit#5443 * fix(mocks): preserve nullable annotations on generated event implementations by @thomhurst in thomhurst/TUnit#5444 * fix(mocks): preserve nullability on event handler types (#5425) by @thomhurst in thomhurst/TUnit#5445 ### Dependencies * chore(deps): update tunit to 1.28.7 by @thomhurst in thomhurst/TUnit#5416 * chore(deps): update dependency polyfill to v10 by @thomhurst in thomhurst/TUnit#5417 * chore(deps): update dependency polyfill to v10 by @thomhurst in thomhurst/TUnit#5418 * chore(deps): update dependency mockolate to 2.4.0 by @thomhurst in thomhurst/TUnit#5431 * chore(deps): update mstest to 4.2.1 by @thomhurst in thomhurst/TUnit#5433 * chore(deps): update dependency microsoft.net.test.sdk to 18.4.0 by @thomhurst in thomhurst/TUnit#5435 * chore(deps): update microsoft.testing to 2.2.1 by @thomhurst in thomhurst/TUnit#5432 * chore(deps): update dependency microsoft.testing.extensions.codecoverage to 18.6.2 by @thomhurst in thomhurst/TUnit#5437 * chore(deps): update dependency @docusaurus/theme-mermaid to ^3.10.0 by @thomhurst in thomhurst/TUnit#5438 * chore(deps): update docusaurus to v3.10.0 by @thomhurst in thomhurst/TUnit#5439 **Full Changelog**: thomhurst/TUnit@v1.28.7...v1.29.0 Commits viewable in [compare view](thomhurst/TUnit@v1.28.7...v1.29.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>
When a method declares [Before(Test)] / [After(Test)] and overrides a base method that already declares the same hook attribute, both registrations are invoked via virtual dispatch on the same instance — the override's body runs twice per test. The previous fix (#5428 / #5441) deduplicated this at runtime via MethodInfo.GetBaseDefinition(), which silently hid the duplication instead of surfacing it to the author and didn't address the [InheritsTests] variant in #5450. Replace the runtime dedup with a compile-time analyzer (TUnit0074, Error) plus a code fix that removes the redundant attribute. The analyzer walks the full IMethodSymbol.OverriddenMethod chain so transitive cases (gap in the middle of the override chain) are also caught, and the [InheritsTests] + abstract intermediate shape from #5450 fails to compile at the intermediate's override. Revert the runtime dedup: drop InstanceHookMethod.BaseDefinition, ResolveBaseDefinition / IsOverriddenByMoreDerivedHook from HookDelegateBuilder, and the GetBaseDefinition() init in ReflectionHookDiscoveryService. PublicAPI snapshots updated accordingly. Regression tests (VirtualHookOverrideTests + Bugs/5450) rewritten to the "attribute only on base, override without attribute" shape — the only shape TUnit0074 still allows. Assertions are inlined inside the hook bodies because After-hooks are sorted derived-class-first across the type hierarchy, so a separate verification hook on the derived class would run before the base teardown.
…5459) * feat: TUnit0074 analyzer for redundant hook attributes on overrides When a method declares [Before(Test)] / [After(Test)] and overrides a base method that already declares the same hook attribute, both registrations are invoked via virtual dispatch on the same instance — the override's body runs twice per test. The previous fix (#5428 / #5441) deduplicated this at runtime via MethodInfo.GetBaseDefinition(), which silently hid the duplication instead of surfacing it to the author and didn't address the [InheritsTests] variant in #5450. Replace the runtime dedup with a compile-time analyzer (TUnit0074, Error) plus a code fix that removes the redundant attribute. The analyzer walks the full IMethodSymbol.OverriddenMethod chain so transitive cases (gap in the middle of the override chain) are also caught, and the [InheritsTests] + abstract intermediate shape from #5450 fails to compile at the intermediate's override. Revert the runtime dedup: drop InstanceHookMethod.BaseDefinition, ResolveBaseDefinition / IsOverriddenByMoreDerivedHook from HookDelegateBuilder, and the GetBaseDefinition() init in ReflectionHookDiscoveryService. PublicAPI snapshots updated accordingly. Regression tests (VirtualHookOverrideTests + Bugs/5450) rewritten to the "attribute only on base, override without attribute" shape — the only shape TUnit0074 still allows. Assertions are inlined inside the hook bodies because After-hooks are sorted derived-class-first across the type hierarchy, so a separate verification hook on the derived class would run before the base teardown. * address review: combine null guards in analyzer + KeepLeadingTrivia in code fix - Merge the IsStandardHook/HookLevel/hookType checks into one if so the compiler tracks nullability without needing the null-forgiving operator. - Use KeepLeadingTrivia when removing the attribute list so any leading comment above the attribute is preserved on the method declaration.
Summary
[Before(Test)]/[After(Test)]method overridden in a derived class executed twice.HookDelegateBuildernow dedupes hooks byMethodInfo.GetBaseDefinition()while walking the hierarchy from derived to base.VirtualHookOverrideTests.Test plan
VirtualHookOverrideTests.DerivedTestClass.Override_Should_Run_Oncefails before fix, passes afterHookExecutionOrderTest(separate-method hierarchy) still passes