fix(mocks): skip non-virtual 'new' methods when discovering mockable members#5330
fix(mocks): skip non-virtual 'new' methods when discovering mockable members#5330
Conversation
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| UnusedCode | 1 minor |
🟢 Metrics 42 complexity
Metric Results Complexity 42
TIP This summary will be updated as you push new changes. Give us feedback
Code ReviewThis PR correctly identifies and fixes a real bug (CS0506/CS0111) caused by the mock source generator emitting Asymmetry Between Methods/Events vs PropertiesThe fix uses two different mechanisms for the three member kinds:
The sentinel works, but it overloads the meaning of the dictionary (index vs "hidden" marker). A small improvement would be a dedicated Potential Interaction: Static Abstract Members + Sentinel
if (seenProperties.ContainsKey(key)) break;This means if a class hierarchy introduces a non-virtual property named Missing Test: Event Hiding With Direct Raise/Subscribe
public new event EventHandler? StatusChanged; // hides base virtual
public virtual event EventHandler<string>? MessageReceived; // remains virtualThe 26 new tests cover methods, properties, overloads, wrap mode, and the 3-level hierarchy well. However, there is no test that directly raises or subscribes to The Core Fix Is CorrectThe walk order (derived → base) ensures non-virtual hiding members are processed first, claiming their key before the base virtual is seen. The conditions are: // Non-virtual: just claim the key (derived-first walk ensures this blocks the base virtual)
seenMethods.Add(key);
// Virtual/Abstract/Override: only add if key not already claimed
if (!seenMethods.Add(key)) continue;
methods.Add(...);This is logically sound. The property Minor: Comment in Non-Virtual Property Branch// Non-virtual properties that hide a base virtual (via 'new') must
// still be recorded so the base virtual is not collected later.
if (!seenProperties.ContainsKey(key))
{
seenProperties[key] = -1;
}The guard Summary: The fix is correct and well-tested. The main suggestions are: (1) consider a dedicated |
…members (#5326) When a derived class hides a base virtual method/property/event with 'new', the mock source generator was still collecting the base virtual and emitting 'override', causing CS0506 (override of non-virtual) and CS0111 (duplicate extension methods). Now non-virtual members in the class hierarchy are recorded in the seen-sets to block their base virtuals from being collected.
a6ee6d9 to
3fe6cdb
Compare
Address review feedback: add test for non-hidden event raise/subscribe on ComplexDerivedService, remove unused OnStatusChanged/OnMessageReceived helpers from test fixture classes.
There was a problem hiding this comment.
Code Review
Good fix for a subtle but impactful bug. The class hierarchy traversal correctly uses a "seen-first" strategy where derived members are processed before base members, so recording non-virtual new members in the seen-sets before reaching base virtuals is the right approach.
Architectural Concern: Sentinel Value in Typed Dictionary
The HiddenByNew = -1 sentinel stored in Dictionary<string, int> is a code smell. The type system doesn't enforce or communicate the dual meaning of the int (either a valid list index or a sentinel), which makes the API fragile and surprising to future readers:
// Current: int can mean "valid index" or "special hidden state"
private const int HiddenByNew = -1;
Dictionary<string, int> seenProperties;A cleaner approach would be to use a nullable int, which makes the two states explicit in the type:
// Better: null explicitly means "hidden by new, no index"
Dictionary<string, int?> seenProperties;
// When hiding: seenProperties[key] = null;
// When collecting: seenProperties[key] = properties.Count;
// At use sites:
if (seenProperties.TryGetValue(key, out var existingIndex))
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter);
}
// else: hidden by 'new', skip
}This eliminates the magic constant and makes intent explicit via the type system. The XML doc comment on the constant is a sign that the design needs documentation to compensate for the non-obvious encoding.
Asymmetry: Methods/Events vs Properties
Methods and events use HashSet<string> (no sentinel needed) because there is no accessor-merging step. The HiddenByNew sentinel is only necessary because the dictionary conflates two concerns: "have we seen this key?" and "what is the index?". An alternative is separating them into a HashSet<string> hiddenByNewKeys plus Dictionary<string, int> propertyIndices, which eliminates the sentinel at the cost of two structures. The int? approach is probably the better middle ground.
Minor: XML Doc Covers Properties but Not Indexers
The HiddenByNew constant's XML comment only mentions "non-virtual property" but the sentinel is also stored in seenProperties for indexers (key = "I:[...]"). The comment should say "property or indexer".
Missing Test: Hidden Indexer
The PR tests hidden methods, properties, and events via new, but no test covers a class with a new hidden indexer. Since the indexer code path uses the same HiddenByNew guard (lines 99-105 and 204-210), a test with a new-hidden indexer would complete the coverage of the sentinel logic.
Everything Else Looks Good
- The traversal order (derived-first via
ProcessClassMembers, then base classes, then interfaces) is correct — derivednewmembers are recorded before base virtuals are encountered. new virtualre-introduced virtual chains work correctly becauseIsVirtual == truetakes the collection path, and the base virtual is blocked by the already-seen key.- The
!seenProperties.ContainsKey(key)guard (line 312) correctly avoids overwriting an already-collected virtual with the sentinel when walking further up the hierarchy. - 26 new tests covering complex overload mixing, 3-level hierarchies, wrap mode, and strict mode provide excellent regression coverage.
The fix is correct and the behaviour is well-validated. The main actionable suggestion is replacing the -1 sentinel with int? for type-safe clarity.
There was a problem hiding this comment.
Code Review
Good fix for a real-world pain point (Azure SDK patterns). The approach of walking the hierarchy most-derived-first and using seen-sets to block base virtuals is correct and the test coverage is thorough. A few observations:
Design Concern: Sentinel value in the properties dictionary
The HiddenByNew = -1 sentinel stored in seenProperties (a Dictionary<string, int> otherwise holding list indices) is the main design weakness. It creates two obligations:
- Every call site that reads from
seenPropertiesmust now guard against-1. - If someone adds a new call site and forgets the guard, they get an
ArgumentOutOfRangeExceptionat runtime in the generator — a silent, hard-to-diagnose failure.
The asymmetry with methods/events (where a plain HashSet handles this naturally via Add returning false) makes this feel like an accidental difference rather than a deliberate one.
A simpler approach would be a dedicated HashSet<string> hiddenProperties alongside seenProperties:
// Instead of seenProperties[key] = HiddenByNew
hiddenProperties.Add(key);
// Instead of if (existingIndex != HiddenByNew)
if (!hiddenProperties.Contains(key) && seenProperties.TryGetValue(key, out var existingIndex))
MergePropertyAccessors(...);This eliminates the sentinel, removes the four != HiddenByNew guards, and makes the seenProperties dictionary a pure index-map again. The guard in CollectStaticAbstractMember (seenProperties.ContainsKey(key)) would also need to include the hidden-check, but that's explicit and clear.
Missing indexer coverage in ProcessClassMembers
The ProcessClassMembers switch only handles IPropertySymbol when !property.IsIndexer. There's no case for indexers, meaning a new indexer hiding a base virtual indexer won't be blocked. This is pre-existing, but since the PR touches this area, it would be worth either:
- Adding the indexer case with the same sentinel/hidden-set logic, or
- Adding a comment explicitly noting the limitation
else if (!seenProperties.ContainsKey(key)) — minor readability
// Current (line ~312):
else if (!seenProperties.ContainsKey(key))
{
seenProperties[key] = HiddenByNew;
}
// Equivalent and more idiomatic:
else
{
seenProperties.TryAdd(key, HiddenByNew);
}The intent is the same but TryAdd communicates "only if absent" more directly.
What's good
- The hierarchy traversal order (most-derived first) is exactly right — the
newmember is encountered before its base virtual, so the block-by-key mechanism works correctly for all levels including 3-level chains. new virtualis handled correctly for free:IsVirtual=truemeans it gets collected and blocks the base via the seen-set, which is exactly what you want.- Test coverage is excellent. The
ComplexDerivedServicetest class exercising mixed overloads/properties/events in one type is particularly valuable. - The sentinel-guard additions to the interface-merging loops (lines ~83, ~102, ~188, ~207) are correct — a class property hidden by
newshould also block merging from an interface that declares the same property name.
Overall this is a solid, well-reasoned fix. The sentinel-value design is worth revisiting before this pattern spreads to more call sites, but it doesn't block the fix from being correct today.
Address review feedback: - Replace Dictionary<string, int> + HiddenByNew sentinel with Dictionary<string, int?> where null means hidden-by-new. Makes the two states (valid index vs hidden) explicit in the type system. - Add indexer case to ProcessClassMembers so 'new' indexers hiding base virtual indexers are correctly blocked.
There was a problem hiding this comment.
Code Review — Follow-up
Previous reviews flagged two main concerns; checking whether they've been addressed in the latest commit ():
✅ Sentinel value replaced with int?
The HiddenByNew = -1 magic constant and Dictionary<string, int> have been replaced with Dictionary<string, int?> throughout. null now encodes "hidden by new" and HasValue guards at call sites are explicit and type-safe. This fully resolves the main design concern from the prior reviews.
✅ Indexer case added to ProcessClassMembers
A dedicated case IPropertySymbol indexer when indexer.IsIndexer: branch now handles the same null-sentinel logic for indexers. This closes the pre-existing gap that was noted.
Remaining minor item: else if (!seenProperties.ContainsKey(key))
This pattern is still used in three places to write null (the "hidden" sentinel) only when the key isn't already present:
It's functionally correct, but TryAdd communicates intent more directly and avoids a double-lookup:
Minor and non-blocking — the current code is readable.
Missing test: new-hidden indexer
The test suite covers hidden methods, properties, and events, but there's still no test for a class that hides a base virtual indexer with new this[...]. The indexer code path now has the same guard logic, so a test would complete the coverage. Not blocking.
Summary
The two structural issues from the prior reviews have been cleanly resolved. The fix is correct, the int? encoding is idiomatic, and the 26+ tests provide strong regression coverage. The remaining points above are low-priority polish items.
Approving.
There was a problem hiding this comment.
Code Review — Follow-up
Previous reviews flagged two main concerns; checking whether they've been addressed in the latest commit (f2091895f9af):
✅ Sentinel value replaced with int?
The HiddenByNew = -1 magic constant and Dictionary<string, int> have been replaced with Dictionary<string, int?> throughout. null now encodes "hidden by new" and HasValue guards at call sites are explicit and type-safe. This fully resolves the main design concern from the prior reviews.
✅ Indexer case added to ProcessClassMembers
A dedicated case IPropertySymbol indexer when indexer.IsIndexer: branch now handles the same null-sentinel logic for indexers. This closes the pre-existing gap that was noted.
Remaining minor item: else if (!seenProperties.ContainsKey(key))
This pattern is still used in three places to write null (the "hidden" sentinel) only when the key isn't already present:
else if (!seenProperties.ContainsKey(key))
{
seenProperties[key] = null;
}It's functionally correct, but TryAdd communicates intent more directly and avoids a double-lookup:
else
{
seenProperties.TryAdd(key, null);
}Minor and non-blocking — the current code is readable.
Missing test: new-hidden indexer
The test suite covers hidden methods, properties, and events, but there's still no test for a class that hides a base virtual indexer with new this[...]. The indexer code path now has the same guard logic, so a test would complete the coverage. Not blocking.
Summary
The two structural issues from the prior reviews have been cleanly resolved. The fix is correct, the int? encoding is idiomatic, and the 26+ tests provide strong regression coverage. The remaining points above are low-priority polish items.
Approving.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.23.7 to 1.24.13. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.24.13 <!-- Release notes generated using configuration in .github/release.yml at v1.24.13 --> ## What's Changed ### Other Changes * perf(mocks): optimize MockEngine for lower allocation and faster verification by @thomhurst in thomhurst/TUnit#5319 * Remove defunct `UseTestingPlatformProtocol` reference for vscode by @erwinkramer in thomhurst/TUnit#5328 * perf(aspnetcore): prevent thread pool starvation during parallel WebApplicationTest server init by @thomhurst in thomhurst/TUnit#5329 * fix TUnit0073 for when type from from another assembly by @SimonCropp in thomhurst/TUnit#5322 * Fix implicit conversion operators bypassed in property injection casts by @Copilot in thomhurst/TUnit#5317 * fix(mocks): skip non-virtual 'new' methods when discovering mockable members by @thomhurst in thomhurst/TUnit#5330 * feat(mocks): IFoo.Mock() discovery with generic fallback and ORP resolution by @thomhurst in thomhurst/TUnit#5327 ### Dependencies * chore(deps): update tunit to 1.24.0 by @thomhurst in thomhurst/TUnit#5315 * chore(deps): update aspire to 13.2.1 by @thomhurst in thomhurst/TUnit#5323 * chore(deps): update verify to 31.14.0 by @thomhurst in thomhurst/TUnit#5325 ## New Contributors * @erwinkramer made their first contribution in thomhurst/TUnit#5328 **Full Changelog**: thomhurst/TUnit@v1.24.0...v1.24.13 ## 1.24.0 <!-- Release notes generated using configuration in .github/release.yml at v1.24.0 --> ## What's Changed ### Other Changes * perf: optimize TUnit.Mocks hot paths by @thomhurst in thomhurst/TUnit#5304 * fix: resolve System.Memory version conflict on .NET Framework (net462) by @thomhurst in thomhurst/TUnit#5303 * fix: resolve CS0460/CS0122/CS0115 when mocking concrete classes from external assemblies by @thomhurst in thomhurst/TUnit#5310 * feat(mocks): parameterless Returns() and ReturnsAsync() for async methods by @thomhurst in thomhurst/TUnit#5309 * Fix typo in NUnit manual migration guide by @aa-ko in thomhurst/TUnit#5312 * refactor(mocks): unify Mock.Of<T>() and Mock.OfPartial<T>() into single API by @thomhurst in thomhurst/TUnit#5311 * refactor(mocks): clean up Mock API surface by @thomhurst in thomhurst/TUnit#5314 * refactor(mocks): remove generic/untyped overloads from public API by @thomhurst in thomhurst/TUnit#5313 ### Dependencies * chore(deps): update tunit to 1.23.7 by @thomhurst in thomhurst/TUnit#5305 * chore(deps): update dependency mockolate to 2.1.1 by @thomhurst in thomhurst/TUnit#5307 ## New Contributors * @aa-ko made their first contribution in thomhurst/TUnit#5312 **Full Changelog**: thomhurst/TUnit@v1.23.7...v1.24.0 Commits viewable in [compare view](thomhurst/TUnit@v1.23.7...v1.24.13). </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 #5326
new(e.g., Azure SDK'sBlobClient.WithSnapshothidingBlobBaseClient.WithSnapshot), the mock source generator was still collecting the base virtual and emittingoverride, causing CS0506 (override of non-virtual) and CS0111 (duplicate extension methods).MergePropertyAccessorscalls to handle the sentinel index used for hidden properties.Test plan
PartialMockTestscovering:newmethod hiding (configure non-hidden virtual, fallback, strict mode, verification)newproperty hidingnew+overridein the same class (methods and properties)new virtualre-introducing virtual chainnewmethods