Skip to content

fix: handle explicit interface impl with different return types in mock generator#5564

Merged
thomhurst merged 2 commits intomainfrom
fix/mock-generator-explicit-interface-dedup
Apr 15, 2026
Merged

fix: handle explicit interface impl with different return types in mock generator#5564
thomhurst merged 2 commits intomainfrom
fix/mock-generator-explicit-interface-dedup

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Fixes #5559 — mock generator failed to compile when mocking interfaces inheriting from IEnumerable<T> or having same-named members with different return types across multiple interfaces.

  • Method/property dedup: Added two-level deduplication — signature keys (name+params) detect collisions, full keys (name+params+return type) distinguish genuinely different methods that need explicit interface implementation
  • Explicit impl delegation: Added ExplicitInterfaceCanDelegate flag — interface return types (e.g. IEnumerator<T>IEnumerator) delegate safely; incompatible types (int vs string) get their own engine dispatch
  • Sealed override leak: Sealed overrides now register in seen sets to block base virtuals from generating invalid override

Test plan

  • 3 snapshot tests for IEnumerable interface variants (generic, direct, nested generic)
  • 8 behavioral tests for IEnumerable mocking (EnumerableInterfaceTests)
  • 22 kitchen sink interface tests — void/return/async/overloaded/generic/ref/out/in/nullable/properties/events/explicit interface with colliding return types
  • 29 kitchen sink abstract class tests — abstract/virtual/non-virtual/protected/override/constructor params
  • 25 kitchen sink concrete class tests — override/new/sealed/virtual/non-virtual methods and properties
  • 28 kitchen sink inheritance tests — 3-level hierarchy (L0→L1→L2), sealed overrides, new hiding, constructor forwarding
  • All 888 behavioral tests pass
  • All 164 snapshot tests pass across net8.0/net9.0/net10.0/net472
  • All 41 HTTP mock tests pass (Azure SDK types exercise class hierarchy dedup)

…ck generator (#5559)

The mock source generator failed to compile when mocking interfaces
that inherit from IEnumerable<T> or have same-named members with
different return types across multiple interfaces. Three bugs fixed:

1. Method/property deduplication used signature keys without return
   types, dropping explicit interface implementations like
   IEnumerable.GetEnumerator() when IEnumerable<T>.GetEnumerator()
   was already seen. Added two-level dedup: signature keys for
   primary collision detection, full keys (with return type) to
   distinguish genuinely different methods needing explicit impl.

2. Explicit interface method implementations blindly delegated to
   the public method, failing when return types are incompatible
   (e.g. string vs int). Added ExplicitInterfaceCanDelegate flag
   using a TypeKind heuristic — interface return types delegate
   safely (IEnumerator<T> : IEnumerator), class/struct mismatches
   get their own engine dispatch.

3. Sealed overrides in class hierarchies were skipped but not
   registered in seen sets, allowing base virtuals to leak through
   and generate invalid override on sealed members.
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 15, 2026

Not up to standards ⛔

🔴 Issues 4 medium · 10 minor

Alerts:
⚠ 14 issues (≤ 0 issues of at least minor severity)

Results:
14 new issues

Category Results
BestPractice 4 medium
2 minor
CodeStyle 8 minor

View in Codacy

🟢 Metrics 155 complexity

Metric Results
Complexity 155

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 15, 2026

Code Review

The fix is well-reasoned and the test coverage is excellent — 888 behavioral tests across interfaces, abstract classes, concrete classes, and multi-level inheritance, plus 3 snapshot tests for the IEnumerable variants. The two-level deduplication design is sound for the target use case.

Issue 1: IsReturnTypeConvertible heuristic can produce broken generated code

MemberDiscovery.csIsReturnTypeConvertible:

private static bool IsReturnTypeConvertible(ITypeSymbol explicitReturnType)
{
    return explicitReturnType.TypeKind == TypeKind.Interface;
}

The comment says "likely implements it" but likely is doing a lot of work here. Consider:

interface IAlpha { IFoo Method(); }
interface IBeta  { IBar Method(); }  // IFoo and IBar are unrelated interfaces

interface IMyService : IAlpha, IBeta { }

When IBar.Method() (the explicit impl) is encountered, IsReturnTypeConvertible returns true because IBar is an interface. The generator emits:

IBar IBeta.Method() => Method();  // Method() returns IFoo — compile error!

The symptom is a C# compile error in generated code (CS0266: Cannot implicitly convert type 'IFoo' to 'IBar'), which is arguably worse than a runtime exception — it silently breaks mocking for any user who creates such an interface pair.

Better approach: At the point of collision detection, both ITypeSymbols are available. Use Roslyn's type hierarchy directly:

// In the collision branch, after retrieving existingIdx:
var existingMethodReturnType = /* ITypeSymbol of the already-seen method's return type */;
var canDelegate = IsConvertible(existingMethodReturnType, method.ReturnType);

private static bool IsConvertible(ITypeSymbol publicReturnType, ITypeSymbol explicitReturnType)
{
    // The public method returns publicReturnType; the explicit impl must return explicitReturnType.
    // Delegation is safe only if publicReturnType is assignable to explicitReturnType.
    if (explicitReturnType.TypeKind != TypeKind.Interface) return false;
    if (SymbolEqualityComparer.Default.Equals(publicReturnType, explicitReturnType)) return true;
    return publicReturnType.AllInterfaces.Any(i =>
        SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, explicitReturnType.OriginalDefinition));
}

To make this practical you'd need to store the ITypeSymbol alongside existingIdx in the dictionary (e.g., Dictionary<string, (int index, ITypeSymbol returnType)>), or just keep existingIdx and look it up through a parallel List<ITypeSymbol>. This properly handles the IEnumerator/IEnumerator case while safely falling back to engine dispatch for unrelated interface types.


Issue 2: existingIdx is captured but never used in the interface loop

MemberDiscovery.cs lines ~68, ~201:

if (seenMethods.TryGetValue(key, out var existingIdx))
{
    // existingIdx is never referenced below
    var fullKey = GetFullMethodKey(method);
    ...
}

The variable exists to enable Issue 1's fix (accessing the existing method's return type symbol) but currently dead code. Either use it for the proper assignability check, or rename to _ to signal intentional discard. As-is it looks like unfinished work.


Issue 3: Misleading comment on explicit interface property generation

MockImplBuilder.csGenerateInterfaceProperty:

if (prop.ExplicitInterfaceName is not null)
{
    // Explicit interface implementation — delegates to the public property
    // with the same name (different return type from a different interface).
    writer.AppendLine($"{prop.ReturnType} {prop.ExplicitInterfaceName}.{prop.Name}");
    writer.OpenBrace();
    if (prop.HasGetter)
    {
        writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, ...");

The comment says "delegates to the public property" but the code dispatches independently through the engine with a dedicated MemberId — it does not call the public property accessor. The behavior is actually correct (explicit interface properties with different return types must have independent setup/verify), but the comment will mislead future maintainers. Consider:

// Explicit interface property with different return type.
// Dispatches independently through the engine (not a delegation — return types are incompatible).

Issue 4: Duplicated two-level dedup logic between DiscoverMembers and DiscoverMembersFromMultipleTypes

The identical collision-detection block appears twice:

// In DiscoverMembers:
if (seenMethods.TryGetValue(key, out var existingIdx))
{
    var fullKey = GetFullMethodKey(method);
    if (!seenFullMethods.Add(fullKey)) continue;
    var canDelegate = IsReturnTypeConvertible(method.ReturnType);
    methods.Add(CreateMethodModel(method, ref memberIdCounter, interfaceFqn, interfaceFqn, canDelegate));
}

// Exact same block in DiscoverMembersFromMultipleTypes

Similarly for the property collision check. Each bugfix or enhancement (like fixing Issue 1) will need to be applied in two places. Extracting a shared helper — e.g., ProcessInterfaceMember(IMethodSymbol, ...) — would make both methods thin coordinators and keep the dedup policy in one place. This is the higher-priority refactor given the heuristic is likely to evolve.


Minor: CollectStaticAbstractMember doesn't receive seenFullMethods

If a static abstract method had the same name+params but a different return type across two interfaces (unusual but theoretically possible), the full-key dedup wouldn't run. Low-priority since static abstract methods with covariant return types across interfaces are unlikely in practice, but worth noting for completeness.


Overall

The core design — two-level deduplication with delegation vs. engine-dispatch fallback — is the right approach for this problem. The sealed-override fix is also correct and addresses a real gap. The main concern is that the IsReturnTypeConvertible heuristic is a correctness time bomb for users with unrelated interfaces that happen to share a method name: it will silently generate uncompilable mock code. The Roslyn APIs needed for a proper check are available at the point where the decision is made, so this seems worth the small additional complexity before merging.

Replace the TypeKind heuristic in IsReturnTypeConvertible with a
proper check using Roslyn's AllInterfaces. The heuristic returned
true for any interface return type, which would generate broken
delegation code for unrelated interfaces sharing a method name
(e.g. IAlpha.Method():IFoo vs IBeta.Method():IBar).

Now stores the ITypeSymbol alongside the method index in
seenMethods to enable CanDelegateReturnType to verify the public
return type actually implements the explicit interface's return type.

Also fixes misleading comment on explicit interface property
generation that said "delegates" when it dispatches independently.
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review

All four issues flagged in the previous review have been addressed in the second commit. Here's the audit:

Issues from Previous Review — All Resolved ✅

Issue 1 — IsReturnTypeConvertible heuristic: Fixed. Replaced with CanDelegateReturnType(ITypeSymbol publicReturnType, ITypeSymbol explicitReturnType) using proper Roslyn AllInterfaces traversal. The fix correctly handles the IAlpha/IBeta scenario (unrelated interfaces sharing a method name with different return types) that would have produced CS0266 compile errors in generated code.

Issue 2 — Dead existingIdx variable: Fixed. The seenMethods dictionary now stores (int Index, ITypeSymbol? ReturnType) and existing.ReturnType is actively passed to CanDelegateReturnType, making the delegation decision semantically correct.

Issue 3 — Misleading comment on GenerateInterfaceProperty: Fixed. The comment now reads "Dispatches independently through the engine with a dedicated MemberId", which accurately describes the behavior.

Sealed-override registration: The NonMockableEntry = (-1, null) sentinel for seenMethods is a clean way to block base virtuals without allocating a dedicated data structure. Good.


Remaining Minor Points (non-blocking)

Duplicated collision-detection logic — the same two-level dedup block (TryGetValue → seenFullMethods.Add → CanDelegateReturnType → methods.Add) appears in both DiscoverMembers and DiscoverMembersFromMultipleTypes, and the property collision check similarly. Any future evolution to CanDelegateReturnType would need to be applied in both places. An AddInterfaceMethod helper that owns the entire collision policy would prevent drift, though this is a refactor, not a correctness issue.

CollectStaticAbstractMember doesn't receive seenFullMethods — the overloaded-static-abstract edge case (same name+params, different return type across two static-abstract interfaces) would fall through to the old single-level dedup. Extremely unlikely in practice, but worth a // TODO: extend seenFullMethods to CollectStaticAbstractMember if static covariant return types become relevant comment if you want to leave a breadcrumb.


Overall

The fix is correct and safe. The critical correctness hazard (heuristic delegation emitting uncompilable code for unrelated interface pairs) has been eliminated. Test coverage is thorough — 8 IEnumerable behavioral tests, 3 snapshot tests, and 112 kitchen-sink tests across interfaces/abstract/concrete/inheritance. Good to merge.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review

All four issues flagged in the previous review have been addressed in the second commit. Here is the audit:

Issues from Previous Review — All Resolved

Issue 1 — IsReturnTypeConvertible heuristic: Fixed. Replaced with CanDelegateReturnType(ITypeSymbol publicReturnType, ITypeSymbol explicitReturnType) using proper Roslyn AllInterfaces traversal. The fix correctly handles the IAlpha/IBeta scenario (unrelated interfaces sharing a method name with different return types) that would have produced CS0266 compile errors in generated code.

Issue 2 — Dead existingIdx variable: Fixed. The seenMethods dictionary now stores (int Index, ITypeSymbol? ReturnType) and existing.ReturnType is actively passed to CanDelegateReturnType, making the delegation decision semantically correct.

Issue 3 — Misleading comment on GenerateInterfaceProperty: Fixed. The comment now reads "Dispatches independently through the engine with a dedicated MemberId", which accurately describes the behavior.

Sealed-override registration: The NonMockableEntry = (-1, null) sentinel for seenMethods is a clean way to block base virtuals without allocating a dedicated data structure. Good call.


Remaining Minor Points (non-blocking)

Duplicated collision-detection logic — the same two-level dedup block (TryGetValue + seenFullMethods.Add + CanDelegateReturnType + methods.Add) appears in both DiscoverMembers and DiscoverMembersFromMultipleTypes, and the property collision check is similarly duplicated. Any future evolution to CanDelegateReturnType would need to be applied in both places. An AddInterfaceMethod helper that owns the entire collision policy would prevent drift. Non-blocking refactor.

CollectStaticAbstractMember does not receive seenFullMethods — the overloaded-static-abstract edge case (same name+params, different return type across two static-abstract interfaces) would fall through to single-level dedup. Extremely unlikely in practice, but worth a comment as a breadcrumb.


Overall

The fix is correct and safe. The critical correctness hazard — the heuristic delegation that would emit uncompilable code for unrelated interface pairs — has been eliminated. Test coverage is thorough: 8 IEnumerable behavioral tests, 3 snapshot tests, and 112 kitchen-sink tests across interfaces/abstract/concrete/inheritance hierarchies. Good to merge.

@thomhurst thomhurst enabled auto-merge (squash) April 15, 2026 18:28
@thomhurst thomhurst merged commit 4406816 into main Apr 15, 2026
15 of 16 checks passed
@thomhurst thomhurst deleted the fix/mock-generator-explicit-interface-dedup branch April 15, 2026 19:02
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 17, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.33.0 to
1.35.2.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.35.2

<!-- Release notes generated using configuration in .github/release.yml
at v1.35.2 -->

## What's Changed
### Other Changes
* fix: restore SourceLink and deterministic builds in published packages
by @​thomhurst in thomhurst/TUnit#5579
### Dependencies
* chore(deps): update tunit to 1.35.0 by @​thomhurst in
thomhurst/TUnit#5578


**Full Changelog**:
thomhurst/TUnit@v1.35.0...v1.35.2

## 1.35.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.35.0 -->

## What's Changed
### Other Changes
* fix: support open generic transitive auto-mocks by @​thomhurst in
thomhurst/TUnit#5568
* refactor: separate test and lifecycle tracing by @​thomhurst in
thomhurst/TUnit#5572
* fix: expand nested And/Or expectations in failure messages (#​5573) by
@​thomhurst in thomhurst/TUnit#5577
### Dependencies
* chore(deps): update tunit to 1.34.5 by @​thomhurst in
thomhurst/TUnit#5566
* chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5538
* chore(deps): update verify to 31.16.0 by @​thomhurst in
thomhurst/TUnit#5570
* chore(deps): update verify to 31.16.1 by @​thomhurst in
thomhurst/TUnit#5574
* chore(deps): update gittools/actions action to v4 by @​thomhurst in
thomhurst/TUnit#5575


**Full Changelog**:
thomhurst/TUnit@v1.34.5...v1.35.0

## 1.34.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.34.5 -->

## What's Changed
### Other Changes
* fix: cap test output at 1M chars to prevent OOM by @​thomhurst in
thomhurst/TUnit#5561
* fix: handle explicit interface impl with different return types in
mock generator by @​thomhurst in
thomhurst/TUnit#5564
* fix: include XML documentation files in NuGet packages by @​thomhurst
in thomhurst/TUnit#5565
### Dependencies
* chore(deps): update tunit to 1.34.0 by @​thomhurst in
thomhurst/TUnit#5562


**Full Changelog**:
thomhurst/TUnit@v1.34.0...v1.34.5

## 1.34.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.34.0 -->

## What's Changed
### Other Changes
* refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and
auto-inject handlers by @​thomhurst in
thomhurst/TUnit#5532
* feat: add Dev Drive setup for Windows in CI workflow by @​thomhurst in
thomhurst/TUnit#5544
* fix: start session activity before discovery so discovery spans parent
correctly by @​thomhurst in thomhurst/TUnit#5534
* feat: cross-process test log correlation via OTLP receiver by
@​thomhurst in thomhurst/TUnit#5533
* refactor: use natural OTEL trace propagation instead of synthetic
TraceIds by @​thomhurst in thomhurst/TUnit#5557
* fix: route ITestOutput writes through synchronized
ConcurrentStringWriter by @​thomhurst in
thomhurst/TUnit#5558
### Dependencies
* chore(deps): update tunit to 1.33.0 by @​thomhurst in
thomhurst/TUnit#5527
* chore(deps): update dependency dompurify to v3.4.0 by @​thomhurst in
thomhurst/TUnit#5537
* chore(deps): update dependency docusaurus-plugin-llms to ^0.3.1 by
@​thomhurst in thomhurst/TUnit#5541
* chore(deps): update dependency microsoft.sourcelink.github to 10.0.202
by @​thomhurst in thomhurst/TUnit#5543
* chore(deps): update dependency microsoft.entityframeworkcore to 10.0.6
by @​thomhurst in thomhurst/TUnit#5542
* chore(deps): update dependency
microsoft.templateengine.authoring.templateverifier to 10.0.202 by
@​thomhurst in thomhurst/TUnit#5546
* chore(deps): update dependency microsoft.templateengine.authoring.cli
to v10.0.202 by @​thomhurst in
thomhurst/TUnit#5545
* chore(deps): update dependency system.commandline to 2.0.6 by
@​thomhurst in thomhurst/TUnit#5547
* chore(deps): update microsoft.aspnetcore to 10.0.6 by @​thomhurst in
thomhurst/TUnit#5548
* chore(deps): update dependency nuget.protocol to 7.3.1 by @​thomhurst
in thomhurst/TUnit#5549
* chore(deps): update microsoft.extensions to 10.0.6 by @​thomhurst in
thomhurst/TUnit#5550
* chore(deps): update dependency dotnet-sdk to v10.0.202 by @​thomhurst
in thomhurst/TUnit#5551
* chore(deps): update opentelemetry by @​thomhurst in
thomhurst/TUnit#5552
* chore(deps): update microsoft.extensions to 10.5.0 by @​thomhurst in
thomhurst/TUnit#5554


**Full Changelog**:
thomhurst/TUnit@v1.33.0...v1.34.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.33.0...v1.35.2).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.33.0&new-version=1.35.2)](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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Mock for generic IEnumerable<T> members doesn't generate properly

1 participant