Skip to content

fix: share interface members with the class members implementing them - #854

Merged
vbreuss merged 4 commits into
mainfrom
fix/unify-implemented-interface-members
Aug 31, 2026
Merged

fix: share interface members with the class members implementing them#854
vbreuss merged 4 commits into
mainfrom
fix/unify-implemented-interface-members

Conversation

@vbreuss

@vbreuss vbreuss commented Aug 31, 2026

Copy link
Copy Markdown
Member

A combination mock re-lists the additional interface in its base list and emits an explicit implementation for every one of its members. That re-mapping is what makes a member the class implements non-virtually configurable through the interface slot, but applied to a member the mock already overrides it forked one member into two: separate member ids, separate registry keys, separate recorded interactions. mock.Mock.Setup.Multiply(...) then had no effect on ((ICalculator)mock).Multiply(...), and neither surface saw the other's interactions.

When the mocked class already implements the additional interface, rebase the interface members it implements overridably onto the class member - same containing type, so both surfaces resolve to one registry key and one member id

  • and stop re-implementing them, letting interface mapping resolve the slot to the mock's own override. Members the class implements non-virtually or explicitly keep their own containing type and stay re-implemented.

Matching requires the return/member type to agree on top of the containing-type- independent comparers, so a member hidden via new with a different signature never rebases. Indexers are excluded: they are keyed by their parameter signature rather than by the declaring type and already shared storage. Interface mocks are excluded as well - an inherited interface member already carries its declaring interface on both surfaces, and a hidden member is meant to stay separate.

A combination mock re-lists the additional interface in its base list and emits
an explicit implementation for every one of its members. That re-mapping is what
makes a member the class implements non-virtually configurable through the
interface slot, but applied to a member the mock already overrides it forked one
member into two: separate member ids, separate registry keys, separate recorded
interactions. `mock.Mock.Setup.Multiply(...)` then had no effect on
`((ICalculator)mock).Multiply(...)`, and neither surface saw the other's
interactions.

When the mocked class already implements the additional interface, rebase the
interface members it implements overridably onto the class member - same
containing type, so both surfaces resolve to one registry key and one member id
- and stop re-implementing them, letting interface mapping resolve the slot to
the mock's own override. Members the class implements non-virtually or
explicitly keep their own containing type and stay re-implemented.

Matching requires the return/member type to agree on top of the containing-type-
independent comparers, so a member hidden via `new` with a different signature
never rebases. Indexers are excluded: they are keyed by their parameter
signature rather than by the declaring type and already shared storage.
Interface mocks are excluded as well - an inherited interface member already
carries its declaring interface on both surfaces, and a hidden member is meant
to stay separate.
@vbreuss vbreuss self-assigned this Aug 31, 2026
@vbreuss vbreuss added the bug Something isn't working label Aug 31, 2026
@vbreuss
vbreuss requested a balanced review from Copilot August 31, 2026 15:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes combination mocks so overridable class/interface members share registry IDs and interactions.

Changes:

  • Adds member aliasing and rebasing.
  • Avoids duplicate interface implementations for aliased members.
  • Adds generator and runtime coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Tests/Mockolate.Tests/MockTests.ImplementedInterfaceTests.cs Tests shared runtime behavior.
Tests/Mockolate.SourceGenerators.Tests/MockTests.CombinationTests.cs Tests generated combination mocks.
Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs Initializes alias-aware IDs.
Source/Mockolate.SourceGenerators/Sources/Sources.MockCombination.cs Rebases implemented interfaces.
Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs Skips aliased implementations.
Source/Mockolate.SourceGenerators/Sources/Sources.MemberIds.cs Resolves aliases to shared IDs.
Source/Mockolate.SourceGenerators/Entities/Property.cs Allows containing-type rebasing.
Source/Mockolate.SourceGenerators/Entities/MockClass.cs Tracks implemented interfaces.
Source/Mockolate.SourceGenerators/Entities/Method.cs Allows containing-type rebasing.
Source/Mockolate.SourceGenerators/Entities/MemberAliases.cs Defines alias mappings.
Source/Mockolate.SourceGenerators/Entities/Event.cs Allows containing-type rebasing.
Source/Mockolate.SourceGenerators/Entities/Class.cs Implements member rebasing.
Suppressed comments (3)

Source/Mockolate.SourceGenerators/Entities/Class.cs:284

  • A same-named virtual property can coexist with an explicit implementation of this interface property. In that case this heuristic aliases to the unrelated public property and changes interface dispatch instead of preserving the explicit implementation. Use the compiler's actual interface-member mapping and rebase only when its mapped property is overridable.
		Property? target = implementor.AllProperties().FirstOrDefault(candidate
			=> candidate is { IsIndexer: false, ExplicitImplementation: null, IsStatic: false, } &&
			   candidate.Type == property.Type &&
			   Property.ContainingTypeIndependentEqualityComparer.Equals(candidate, property));

Source/Mockolate.SourceGenerators/Entities/Class.cs:305

  • This can alias an interface event to an unrelated virtual event when the class explicitly implements the interface event and also declares a public event with the same name and type. Suppressing re-implementation then changes which event the interface slot addresses. Resolve the actual interface implementation symbol before creating the alias.
		Event? target = implementor.AllEvents().FirstOrDefault(candidate
			=> candidate is { ExplicitImplementation: null, IsStatic: false, } &&
			   candidate.Type == @event.Type &&
			   Event.ContainingTypeIndependentEqualityComparer.Equals(candidate, @event));

Source/Mockolate.SourceGenerators/Entities/Class.cs:263

  • Generic interface implementations are missed when corresponding type parameters use different names. IFoo.Echo<T>(T) can be implemented by Echo<U>(U), but Name, parameter Fullname, and ReturnType compare the literal T/U names, so the member is not rebased and its setup/interaction state remains forked. Compare generic signatures by type-parameter ordinal rather than identifier.
			   candidate.ReturnType == method.ReturnType &&
			   Method.ContainingTypeIndependentEqualityComparer.Equals(candidate, method));

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Source/Mockolate.SourceGenerators/Entities/Class.cs Outdated
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Test Results

    24 files  ±  0      24 suites  ±0   12m 25s ⏱️ -48s
 4 397 tests + 20   4 395 ✅ + 20  2 💤 ±0  0 ❌ ±0 
27 919 runs  +120  27 915 ✅ +120  4 💤 ±0  0 ❌ ±0 

Results for commit 0bb4e50. ± Comparison against base commit 0e57c9e.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.87GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 232.7 ns 6.05 ns 5.65 ns 0.72 1.7 KB 1.00
Mockolate 321.0 ns 4.36 ns 3.86 ns 1.00 1.7 KB 1.00
Imposter 1,389.2 ns 51.62 ns 48.28 ns 4.33 8.8 KB 5.17
TUnitMocks 178.3 ns 3.43 ns 3.21 ns 0.56 1.34 KB 0.79
Moq 14,440.5 ns 47.99 ns 42.55 ns 44.99 12.51 KB 7.34
NSubstitute 5,692.8 ns 69.13 ns 61.28 ns 17.74 9.05 KB 5.31
FakeItEasy 230,730.1 ns 784.73 ns 695.64 ns 718.83 15.26 KB 8.96
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 371.8 ns 4.33 ns 3.84 ns 0.85 1.93 KB 1.00
Mockolate 1 437.5 ns 3.44 ns 3.21 ns 1.00 1.93 KB 1.00
Imposter 1 660.3 ns 14.57 ns 13.63 ns 1.51 4.04 KB 2.09
TUnitMocks 1 560.9 ns 7.58 ns 6.72 ns 1.28 2.02 KB 1.04
Moq 1 86,059.1 ns 416.46 ns 325.14 ns 196.73 14.55 KB 7.54
NSubstitute 1 5,616.6 ns 22.46 ns 19.91 ns 12.84 9.12 KB 4.72
FakeItEasy 1 4,738.3 ns 153.47 ns 143.55 ns 10.83 8.06 KB 4.18
baseline* 10 663.2 ns 15.08 ns 14.10 ns 0.77 2.14 KB 1.00
Mockolate 10 856.1 ns 7.22 ns 6.76 ns 1.00 2.14 KB 1.00
Imposter 10 1,247.8 ns 13.38 ns 12.51 ns 1.46 5.52 KB 2.58
TUnitMocks 10 1,752.8 ns 3.74 ns 2.92 ns 2.05 3.73 KB 1.74
Moq 10 92,692.5 ns 418.08 ns 370.62 ns 108.28 18.46 KB 8.63
NSubstitute 10 8,269.3 ns 23.94 ns 18.69 ns 9.66 12.07 KB 5.64
FakeItEasy 10 7,881.2 ns 87.69 ns 82.03 ns 9.21 15.42 KB 7.20
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Platinum 8370C CPU 2.80GHz (Max: 3.39GHz), 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 807.4 ns 50.25 ns 47.00 ns 0.81 3.77 KB 1.00
Mockolate 1 993.6 ns 30.49 ns 28.52 ns 1.00 3.77 KB 1.00
Imposter 1 918.5 ns 8.55 ns 8.00 ns 0.93 5.16 KB 1.37
Moq 1 166,506.4 ns 851.98 ns 796.94 ns 167.71 20.52 KB 5.45
NSubstitute 1 10,653.4 ns 64.75 ns 57.40 ns 10.73 12.84 KB 3.41
FakeItEasy 1 11,637.3 ns 56.72 ns 53.06 ns 11.72 13.62 KB 3.62
baseline* 10 1,977.3 ns 34.29 ns 30.40 ns 0.69 4.82 KB 1.00
Mockolate 10 2,862.5 ns 21.37 ns 19.99 ns 1.00 4.82 KB 1.00
Imposter 10 2,157.2 ns 25.12 ns 23.50 ns 0.75 7.97 KB 1.65
Moq 10 176,915.4 ns 797.25 ns 745.74 ns 61.81 28.92 KB 6.00
NSubstitute 10 24,837.4 ns 74.64 ns 69.81 ns 8.68 25.63 KB 5.32
FakeItEasy 10 24,561.7 ns 120.20 ns 112.43 ns 8.58 32.98 KB 6.84
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 355.2 ns 4.75 ns 3.97 ns 1.13 1.57 KB 1.00
Mockolate 314.5 ns 3.12 ns 2.44 ns 1.00 1.57 KB 1.00
Imposter 408.1 ns 3.35 ns 3.14 ns 1.30 2.38 KB 1.52
TUnitMocks 485.4 ns 4.99 ns 4.67 ns 1.54 1.99 KB 1.27
Moq 97,924.4 ns 362.78 ns 302.94 ns 311.35 8.88 KB 5.66
NSubstitute 4,550.4 ns 30.32 ns 28.36 ns 14.47 7.71 KB 4.91
FakeItEasy 4,661.2 ns 18.43 ns 16.34 ns 14.82 6.81 KB 4.33
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 537.7 ns 23.85 ns 22.31 ns 0.83 2.41 KB 1.00
Mockolate 1 649.4 ns 25.22 ns 23.59 ns 1.00 2.41 KB 1.00
Imposter 1 544.1 ns 25.05 ns 23.43 ns 0.84 3.13 KB 1.29
TUnitMocks 1 488.3 ns 12.57 ns 11.14 ns 0.75 1.64 KB 0.68
Moq 1 12,382.9 ns 212.19 ns 198.48 ns 19.09 10.47 KB 4.34
NSubstitute 1 7,840.4 ns 127.59 ns 113.11 ns 12.09 11.45 KB 4.74
FakeItEasy 1 9,026.7 ns 130.06 ns 108.61 ns 13.92 11.47 KB 4.75
baseline* 10 1,005.4 ns 6.34 ns 5.62 ns 0.93 2.91 KB 1.00
Mockolate 10 1,076.7 ns 26.08 ns 24.39 ns 1.00 2.91 KB 1.00
Imposter 10 1,193.4 ns 28.69 ns 25.43 ns 1.11 4.67 KB 1.61
TUnitMocks 10 1,685.6 ns 35.16 ns 31.17 ns 1.57 3.94 KB 1.35
Moq 10 18,971.3 ns 351.85 ns 329.12 ns 17.63 18.36 KB 6.32
NSubstitute 10 17,670.7 ns 187.64 ns 156.69 ns 16.42 21.08 KB 7.25
FakeItEasy 10 21,415.1 ns 434.66 ns 406.59 ns 19.90 31.04 KB 10.68
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 3.21GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.400
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 17.00 ns 0.124 ns 0.103 ns 0.92 160 B 1.00
Mockolate 18.42 ns 0.332 ns 0.311 ns 1.00 160 B 1.00
Imposter 283.45 ns 3.666 ns 2.862 ns 15.39 2248 B 14.05
TUnitMocks 36.50 ns 0.938 ns 0.878 ns 1.98 200 B 1.25
Moq 1,438.51 ns 17.707 ns 15.697 ns 78.12 2096 B 13.10
NSubstitute 1,959.65 ns 9.367 ns 8.304 ns 106.43 5048 B 31.55
FakeItEasy 1,834.71 ns 46.379 ns 43.383 ns 99.64 2763 B 17.27

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

…dexers

Follow-up to sharing interface members with the class members implementing
them. Which class member implements which interface member was derived by
matching signatures, which missed members differing only in a nullability
annotation or in the name of a generic type parameter, and skipped indexers
entirely.

MockClass now asks Roslyn (FindImplementationForInterfaceMember) which class
member fills each interface slot and carries the pairings in the equatable
model. RebaseOnto looks that implementation up in the mock's own surface, so
an implementation the mock cannot override - non-virtual, sealed, explicit,
or with an inaccessible signature - keeps its own containing type and stays
re-implemented, which is the only way to reach that slot. This makes the
guarantee local instead of resting on the member filter in Class' ctor, and
replaces the coarse ImplementedInterfaces name check.

Indexers now rebase too. They had been left forked on the grounds that their
setups already match by access, but their value storage is keyed by a
signature index derived from the containing type, so `mock[1] = 5` followed
by `((I)mock)[1]` read two different slots.
…members

Rebasing rewrote the member's only containing type, so generated setup,
verify and raise documentation of a shared member named the class member
even on the interface surface. Members now keep a DeclaredContainingType
that survives rebasing and feeds all documentation, while registry keys,
member ids and backing-field names stay on the rebased ContainingType.

Since DeclaredContainingType participates in record equality, a rebased
member can no longer equal its target, making the equal-skip guard in
MemberAliases.Add dead code: removed, with the invariant documented.

Also build the FindImplementation lookups eagerly in the MockClass
constructor instead of lazily, and cover raising a shared event through
the interface surface, a get-only interface property implemented with a
setter, and two interfaces implemented by the same class member.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss marked this pull request as ready for review August 31, 2026 18:43
Copilot AI review requested due to automatic review settings August 31, 2026 18:43
@vbreuss
vbreuss merged commit b1569f2 into main Aug 31, 2026
18 checks passed
@vbreuss
vbreuss deleted the fix/unify-implemented-interface-members branch August 31, 2026 18:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment on lines +255 to +256
new EquatableArray<Property>(Properties.AsArray()
.Select(property => Rebase(property, implementor, aliases)).ToArray()),
github-actions Bot added a commit that referenced this pull request Aug 31, 2026
…lass members implementing them (#854) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Aug 31, 2026
…lass members implementing them (#854) by Valentin Breuß
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants