From 95a702ecd6f4e5647d880c3ae38b22195062928a Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 30 Jul 2026 00:47:11 +0100
Subject: [PATCH] fix(mocks): dispatch abstract indexers through the engine
instead of calling base
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CreateIndexerModel never set IsAbstractMember, so every class indexer took
the virtual-member path in GenerateOverrideIndexer and emitted a
`return base[...];` / `base[...] = value;` fallback. For an abstract
indexer there is no base implementation, so the generated mock failed to
compile with CS0205 — blocking e.g. DbDataReader, which declares two
abstract indexers.
Carry IsAbstractMember/IsVirtualMember on the indexer model like methods
and properties already do; the existing abstract branches in
GenerateOverrideIndexer then emit engine-only dispatch. Virtual indexers
keep their base fallback.
Fixes #6516
---
.../Discovery/MemberDiscovery.cs | 2 +
.../MockGeneratorTests.cs | 39 ++++
...t_Class_With_Abstract_Indexer.verified.txt | 203 ++++++++++++++++++
tests/TUnit.Mocks.Tests/Issue6516Tests.cs | 89 ++++++++
4 files changed, 333 insertions(+)
create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt
create mode 100644 tests/TUnit.Mocks.Tests/Issue6516Tests.cs
diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
index efee1ed4f4..7230a05466 100644
--- a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
+++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
@@ -915,6 +915,8 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i
DeclaringInterfaceName = declaringInterfaceName,
NullableAnnotation = indexer.Type.NullableAnnotation.ToString(),
SmartDefault = indexer.Type.GetSmartDefault(indexer.Type.IsNullableAnnotated()),
+ IsAbstractMember = indexer.IsAbstract,
+ IsVirtualMember = indexer.IsVirtual || indexer.IsOverride,
OverrideAccessModifier = overrideAccessModifier,
GetterAccessModifier = GetAccessorAccessModifier(indexer.GetMethod, overrideAccessModifier, compilationAssembly),
SetterAccessModifier = GetAccessorAccessModifier(indexer.SetMethod, overrideAccessModifier, compilationAssembly),
diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
index cff8d39625..ef4e9590c7 100644
--- a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
+++ b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
@@ -2382,6 +2382,45 @@ public class TestUsage
AssertNoGeneratedError(source, "CS0535");
}
+ [Test]
+ public Task Abstract_Class_With_Abstract_Indexer()
+ {
+ // Regression #6516: an abstract indexer took the virtual-member path and emitted a
+ // `base[...]` fallback — CS0205, there is no base implementation. Abstract indexers must
+ // dispatch through the engine only, like abstract methods/properties (DbDataReader shape).
+ var source = """
+ using TUnit.Mocks;
+
+ public abstract class Repository
+ {
+ public abstract object this[int index] { get; }
+ public abstract object this[string key] { get; }
+ public abstract string this[int a, int b] { get; set; }
+ public virtual string this[bool flag] { get => "base"; set { } }
+ }
+
+ public class TestUsage
+ {
+ void M()
+ {
+ var mock = Repository.Mock();
+ }
+ }
+ """;
+
+ var output = GetGeneratedOutput(source);
+
+ // Virtual indexer keeps its base fallback; abstract ones must not reference base at all.
+ AssertContains(output, "return base[flag];");
+ AssertDoesNotContain(output, "return base[index];");
+ AssertDoesNotContain(output, "return base[key];");
+ AssertDoesNotContain(output, "base[a, b]");
+
+ AssertNoGeneratedError(source, "CS0205");
+
+ return VerifyGeneratorOutput(source);
+ }
+
private static void AssertNoGeneratedError(string source, string errorId)
{
foreach (var diagnostic in GetGeneratedCompilationErrors(source))
diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt
new file mode 100644
index 0000000000..60d9f89cb1
--- /dev/null
+++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt
@@ -0,0 +1,203 @@
+//
+#pragma warning disable
+#nullable enable
+
+file sealed class RepositoryMockImpl : global::Repository, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject
+{
+ private readonly global::TUnit.Mocks.MockEngine _engine;
+
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; }
+
+ [global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
+ internal RepositoryMockImpl(global::TUnit.Mocks.MockEngine engine) : base()
+ {
+ _engine = engine;
+ }
+
+ public override object this[int index]
+ {
+ get => _engine.HandleCallWithReturn