fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675)#5680
fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675)#5680
Conversation
Generated extension methods named Equals, GetHashCode, ToString, or GetType
on Mock<T> were unreachable: instance methods on object always win overload
resolution against extensions, so `mock.Equals(other).Returns(true)` failed
with CS1061 ("'bool' does not contain a definition for 'Returns'").
Rename the generated setup helpers to EqualsOf/GetHashCodeOf/ToStringOf/
GetTypeOf so the typed setup stays reachable. The "Object" rename to
"Object_" (Mock<T>.Object property collision) is unchanged.
Adds T8 in KitchenSinkEdgeCasesTests covering the IEquatable<T> scenario
end-to-end: setup, direct invocation, interface-cast invocation, and
WasCalled/WasNeverCalled tracking.
There was a problem hiding this comment.
Code Review
This PR correctly diagnoses and fixes a real C# overload resolution limitation: instance methods on object always beat extension methods, so the generator-emitted Equals(...) extension was unreachable on Mock<T>. The EqualsOf/GetHashCodeOf/etc. disambiguation approach is pragmatic and the explanatory comments are clear. The restored T8 test is well-structured, covering setup, direct call, interface-cast, and verification tracking.
A few things worth calling out:
1. Breaking change from _-suffix to Of-suffix (most significant concern)
The old MockMemberNames set would have generated Equals_, GetHashCode_, ToString_, GetType_. This PR renames those to EqualsOf, GetHashCodeOf, ToStringOf, GetTypeOf. Any existing user who was calling mock.Equals_(...) or mock.GetHashCode_() as a setup method will get a compile error after upgrading.
This is a breaking API change and should be explicitly noted in the changelog/release notes. Was the _-suffix approach actually usable before (i.e. did users ever reach those methods), or was it always broken due to the same overload-resolution problem? If the latter, documenting this as a bugfix rather than a breaking change would be accurate — but worth being explicit about.
2. Naming inconsistency: Object_ vs EqualsOf
MockMemberNames still uses the _-suffix convention for "Object" (→ Object_), while the four object method collisions now use the Of-suffix convention (→ EqualsOf etc.). These two strategies coexist in the same function without explanation.
The two cases are logically different (Object clashes with the Mock<T>.Object property, not an inherited object method), but a future maintainer reading GetSafeMemberName will see two divergent strategies without a clear reason why. A comment distinguishing the two collision kinds would help, or unifying on one strategy if feasible.
3. Only EqualsOf is tested — GetHashCodeOf, ToStringOf, GetTypeOf are not
Since GetHashCode, ToString, and GetType are also moved from MockMemberNames to ObjectMemberDisambiguations (changing their generated names), they should each have at least a minimal test. Right now there's no coverage that mock.GetHashCodeOf() is reachable and routes correctly, which matters if/when someone overrides GetHashCode in a mockable type.
4. Alternative design worth noting for the future
The core tension here (extension methods losing to instance methods on object) is a recurring C# limitation. A longer-term alternative worth considering is a mock.Setup(x => x.Equals(other)).Returns(true) expression-tree approach, which sidesteps overload resolution entirely because the lambda captures the method symbolically. This would be a larger architectural investment, but it would eliminate the entire class of EqualsOf/GetTypeOf-style API awkwardness. Not blocking for this PR, but worth a ticket.
Summary
The fix is correct and the test is well-written. The main actionable items are:
- Document the
Equals_→EqualsOfrename as a breaking change (or clarify that the old API was always broken/unreachable) - Add a comment in
GetSafeMemberNameexplaining whyObjectuses_-suffix whileobjectmethods useOf-suffix - Add tests for
GetHashCodeOf,ToStringOf,GetTypeOfto match the coverage forEqualsOf
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| BestPractice | 1 minor |
| CodeStyle | 2 minor |
🟢 Metrics 0 complexity
Metric Results Complexity 0
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
- Expand WHY comments in MockMembersBuilder to contrast the two distinct shadowing problems (Object property vs. inherited object instance methods) and explain why they get different fixes (underscore suffix vs. "Of" suffix). - Extend T8 to also cover GetHashCodeOf and ToStringOf. Document inline that GetTypeOf is unreachable because GetType is not virtual on object.
|
Review feedback addressed in 6e68366: Applied
Deferred / not applicable
Test status: T8 passes; full |
There was a problem hiding this comment.
Follow-up Review
The second commit ("chore(mocks): address review feedback") directly addressed all three actionable items from the previous review. Here's the status:
Previous feedback — resolved
1. Breaking change documentation ✅
The PR description now explicitly calls out the Equals_ → EqualsOf rename, explains the blast radius (small — the _-suffix form was already functionally broken for the overload-resolution reason), and flags it as a breaking change.
2. Naming inconsistency (Object_ vs EqualsOf) ✅
The code now has clear two-block comments at lines 19–41 of MockMembersBuilder.cs distinguishing the two collision kinds (property collision vs. inherited instance method collision) and why they get different fixes. A future maintainer will understand GetSafeMemberName immediately.
3. Missing test coverage for GetHashCodeOf / ToStringOf ✅
T8 now tests setup, invocation, and WasCalled verification for both GetHashCodeOf and ToStringOf, alongside EqualsOf. The inline comment explaining why GetTypeOf is excluded (GetType is not virtual on object) is clear and sufficient.
New observation: no snapshot coverage for the renamed extension names
None of the 44 .verified.txt snapshot files reference EqualsOf, GetHashCodeOf, ToStringOf, or GetTypeOf — and none of the existing snapshot test types happen to override these object methods. That means the generated extension names for these members are only exercised at runtime (via T8), not at the source-generator snapshot layer.
If the generator produces a regression (e.g. falls back to Equals_ instead of EqualsOf due to a future refactor), the snapshot tests won't catch it — only T8 will. Since CLAUDE.md flags snapshot testing as required for source-generator output changes, a snapshot test for SelfEquatable (or a minimal IEquatable<T> interface) would provide earlier, cheaper detection at the code-gen layer. This isn't blocking but is worth a follow-up ticket or a snapshot added in this PR.
Minor: GetTypeOf dead API kept for symmetry
The GetTypeOf entry in ObjectMemberDisambiguations generates an extension method that can never be meaningfully used (since GetType is non-virtual and can't be overridden). The class-level comment documents this clearly, so it won't surprise a reader. This is fine as-is, but if the mocks team ever revisits the API surface, removing it would reduce noise in generated code and IntelliSense.
Summary
The fix is correct, well-explained, and the test is comprehensive. The only gap worth tracking is the missing snapshot coverage for the new *Of extension names, so regressions at the code-gen layer are detected without running the full runtime suite. Overall this is good work — the two-commit structure made it easy to track what changed in response to review.
Round-2 review feedback on PR #5680: - Add SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf snapshot in TUnit.Mocks.SourceGenerator.Tests so the disambiguated extension helpers (EqualsOf / GetHashCodeOf / ToStringOf) have explicit source-gen coverage instead of relying solely on the runtime T8 test. - Remove the GetType -> GetTypeOf entry from ObjectMemberDisambiguations. GetType is non-virtual on object so it can never be overridden, which made the generated helper dead code. Update related comments accordingly.
|
Round-2 review feedback applied in f4a63be: 1. Snapshot coverage for 2. Drop unreachable Verification:
|
There was a problem hiding this comment.
Follow-up Review (Round 3)
The third commit (f4a63be — "chore(mocks): add *Of snapshot test, drop unreachable GetTypeOf") addresses both remaining items from the previous round-2 review. All prior feedback is now resolved.
Previous feedback — fully resolved
1. Snapshot coverage for *Of extension names ✅
SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf.verified.txt is now committed and the corresponding MockGeneratorTests.SelfEquatable_Generates_EqualsOf_GetHashCodeOf_ToStringOf test exercises the snapshot path. If a future refactor accidentally reverts EqualsOf → Equals_, the source-gen snapshot catches it without needing to run the full runtime suite.
2. Dead GetTypeOf entry removed ✅
GetType → GetTypeOf has been dropped from ObjectMemberDisambiguations. Since GetType is non-virtual on object it cannot be overridden, so the generated extension helper would have been dead code and IntelliSense noise. The comment in MockMembersBuilder.cs documents the reasoning clearly.
Remaining observations (non-blocking)
These are minor enough that they do not warrant another revision — just noting them for the record.
Snapshot type name vs. runtime type name
The snapshot test uses a class called SelfEquatableSnapshot (to avoid a name collision in the test compilation context), while the runtime test uses SelfEquatable. They test the same code path and both are clearly named — just worth knowing if either name is ever searched for in the future.
object.Equals(object?) override intercept path
The generated implementation overrides both bool Equals(SelfEquatable? other) and bool Equals(object? obj), with the latter assigned member-id 1. There is currently no test or snapshot that exercises mock.EqualsOf(Arg.Any<object?>()) (the object-parameter overload). This is a fairly obscure path and the infrastructure is clearly correct given how member-id 0 works, so this is an optional follow-up rather than a blocking concern.
Summary
This PR is in good shape. All three rounds of review feedback have been addressed, the fix is correct, the test coverage is solid at both the source-gen (snapshot) and runtime layers, and the code comments explain the two distinct shadowing mechanisms clearly. Ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to 1.39.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 Commits viewable in [compare view](thomhurst/TUnit@v1.37.10...v1.39.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
Equals,GetHashCode,ToString,GetType) toEqualsOf/GetHashCodeOf/ToStringOf/GetTypeOfso they are no longer shadowed byobject's instance methods onMock<T>.T8_Self_Referential_IEquatable_Mockablekitchen-sink test, covering setup, direct invocation, interface-cast invocation, andWas(Never)Calledtracking.Breaking change
Yes — minor. Prior to this PR, the four
object-method names were already inMockMemberNamesand emitted with a trailing underscore (Equals_,GetHashCode_,ToString_,GetType_). Those underscore-suffixed names are NOT shadowed byobject(different identifier), so they were technically reachable at the call site. Anyone relying onmock.Equals_(...)/mock.GetHashCode_(...)/mock.ToString_(...)/mock.GetType_(...)will need to migrate to the new*Ofnames. The expected blast radius is small (the underscore form is undocumented and the only realistic use case —Equals— was the broken one users hit and reported as #5675), but flagging it explicitly here.The
Object->Object_rename (Mock.Object property collision) is unchanged.Why
Mock<T>inheritsobject.Equals(object?)as an instance method. C# overload resolution always prefers an instance method over an extension method, somock.Equals(other).Returns(...)bound tobool object.Equals(object?)and produced CS1061. Renaming at the emission site is the only fix that works uniformly for both interface and class mocks (class mocks go throughBuildForPartialMockand return runtimeMock<T>from another assembly, so a typed-instance-method approach is not viable).Test plan
TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs) passes — now also coversGetHashCodeOfandToStringOf(review feedback)TUnit.Mocks.Testsfull suite (net10.0): 953/953TUnit.Mocks.SourceGenerator.Tests(net10.0): 45/45Closes #5675