Skip to content

Commit 53bd350

Browse files
committed
fix(mocks): respect non-public type accessibility in generated mocks (#5426)
Generated mock wrapper, extension, and verification-wrapper types were always emitted as `public`, causing CS9338/CS0051 inconsistent-accessibility errors when mocking an `internal` interface. Track the source type's effective accessibility on MockTypeModel and emit `internal` when the type (or any containing type) is not public.
1 parent ebcaf3e commit 53bd350

8 files changed

Lines changed: 115 additions & 17 deletions

File tree

TUnit.Mocks.SourceGenerator/Builders/MockBridgeBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public static string Build(MockTypeModel model)
3636
private static void BuildBridgeInterface(CodeWriter writer, MockTypeModel model, string safeName)
3737
{
3838
writer.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
39-
using (writer.Block($"public interface {safeName}Mockable : {model.FullyQualifiedName}"))
39+
var visibility = model.IsPublic ? "public" : "internal";
40+
using (writer.Block($"{visibility} interface {safeName}Mockable : {model.FullyQualifiedName}"))
4041
{
4142
bool first = true;
4243

TUnit.Mocks.SourceGenerator/Builders/MockEventsBuilder.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ public static string Build(MockTypeModel model)
1616

1717
using (writer.Block("namespace TUnit.Mocks.Generated"))
1818
{
19+
var visibility = model.IsPublic ? "public" : "internal";
20+
1921
// Lightweight struct holding engine reference — no allocation
2022
writer.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
21-
using (writer.Block($"public readonly struct {safeName}_MockEvents"))
23+
using (writer.Block($"{visibility} readonly struct {safeName}_MockEvents"))
2224
{
2325
writer.AppendLine($"internal readonly global::TUnit.Mocks.MockEngine<{mockableType}> Engine;");
2426
writer.AppendLine();
@@ -27,7 +29,7 @@ public static string Build(MockTypeModel model)
2729

2830
writer.AppendLine();
2931

30-
using (writer.Block($"public static class {safeName}_MockEventsExtensions"))
32+
using (writer.Block($"{visibility} static class {safeName}_MockEventsExtensions"))
3133
{
3234
// Extension property on Mock<T> — non-nullable, only present when type has events
3335
using (writer.Block($"extension(global::TUnit.Mocks.Mock<{mockableType}> mock)"))

TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public static string Build(MockTypeModel model)
3737
using (writer.Block("namespace TUnit.Mocks.Generated"))
3838
{
3939
// Extension methods class
40-
using (writer.Block($"public static class {safeName}_MockMemberExtensions"))
40+
var visibility = model.IsPublic ? "public" : "internal";
41+
using (writer.Block($"{visibility} static class {safeName}_MockMemberExtensions"))
4142
{
4243
bool firstMember = true;
4344

@@ -83,7 +84,7 @@ public static string Build(MockTypeModel model)
8384
{
8485
if (!ShouldGenerateTypedWrapper(method, hasEvents)) continue;
8586
writer.AppendLine();
86-
GenerateUnifiedSealedClass(writer, method, safeName, instanceEventArray);
87+
GenerateUnifiedSealedClass(writer, method, safeName, instanceEventArray, visibility);
8788
}
8889
}
8990

@@ -133,7 +134,7 @@ private static string GetSafeMemberName(string name)
133134
=> MockMemberNames.Contains(name) ? name + "_" : name;
134135

135136
private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberModel method, string safeName,
136-
EquatableArray<MockEventModel> events)
137+
EquatableArray<MockEventModel> events, string visibility)
137138
{
138139
var setupReturnType = method.IsAsync && !method.IsVoid
139140
? method.UnwrappedReturnType
@@ -147,32 +148,33 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode
147148
// Ref struct returns use the void wrapper (can't use ref structs as generic type args)
148149
if (method.IsVoid || method.IsRefStructReturn)
149150
{
150-
GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams, method.SpanReturnElementType, method.ReturnType,
151+
GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams, method.SpanReturnElementType, method.ReturnType, visibility,
151152
isAsync: method.IsAsync, isValueTask: method.IsValueTask);
152153
}
153154
else if (method.IsReturnTypeStaticAbstractInterface)
154155
{
155156
// Static-abstract interface returns can't be used as generic type args (CS8920)
156157
// Use object? to preserve .Returns() capability
157-
GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, "object?", events, method.Parameters, hasRefStructParams, allNonOutParams);
158+
GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, "object?", events, method.Parameters, hasRefStructParams, allNonOutParams, visibility);
158159
}
159160
else
160161
{
161-
GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters, hasRefStructParams, allNonOutParams,
162+
GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters, hasRefStructParams, allNonOutParams, visibility,
162163
isAsync: method.IsAsync, isValueTask: method.IsValueTask, fullReturnType: method.ReturnType);
163164
}
164165
}
165166

166167
private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapperName,
167168
List<MockParameterModel> nonOutParams, string returnType, EquatableArray<MockEventModel> events,
168169
EquatableArray<MockParameterModel> allParameters, bool hasRefStructParams, List<MockParameterModel> allNonOutParams,
170+
string visibility,
169171
bool isAsync = false, bool isValueTask = false, string? fullReturnType = null)
170172
{
171173
var builderType = $"global::TUnit.Mocks.Setup.MethodSetupBuilder<{returnType}>";
172174
var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref);
173175

174176
writer.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
175-
using (writer.Block($"public sealed class {wrapperName} : global::TUnit.Mocks.Verification.ICallVerification"))
177+
using (writer.Block($"{visibility} sealed class {wrapperName} : global::TUnit.Mocks.Verification.ICallVerification"))
176178
{
177179
// Fields
178180
writer.AppendLine("private readonly global::TUnit.Mocks.IMockEngineAccess _engine;");
@@ -277,14 +279,15 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper
277279
private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperName,
278280
List<MockParameterModel> nonOutParams, EquatableArray<MockEventModel> events,
279281
EquatableArray<MockParameterModel> allParameters, bool hasRefStructParams, List<MockParameterModel> allNonOutParams,
280-
string? spanReturnElementType = null, string? spanReturnType = null,
282+
string? spanReturnElementType, string? spanReturnType,
283+
string visibility,
281284
bool isAsync = false, bool isValueTask = false)
282285
{
283286
var builderType = "global::TUnit.Mocks.Setup.VoidMethodSetupBuilder";
284287
var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref);
285288

286289
writer.AppendLine($"[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
287-
using (writer.Block($"public sealed class {wrapperName} : global::TUnit.Mocks.Verification.ICallVerification"))
290+
using (writer.Block($"{visibility} sealed class {wrapperName} : global::TUnit.Mocks.Verification.ICallVerification"))
288291
{
289292
// Fields
290293
writer.AppendLine("private readonly global::TUnit.Mocks.IMockEngineAccess _engine;");

TUnit.Mocks.SourceGenerator/Builders/MockStaticExtensionBuilder.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ public static string Build(MockTypeModel model)
2323

2424
using (writer.Block("namespace TUnit.Mocks"))
2525
{
26-
using (writer.Block($"public static class {extensionClassName}_MockStaticExtension"))
26+
var visibility = model.IsPublic ? "public" : "internal";
27+
using (writer.Block($"{visibility} static class {extensionClassName}_MockStaticExtension"))
2728
{
2829
using (writer.Block($"extension({model.FullyQualifiedName})"))
2930
{
30-
using (writer.Block($"public static global::{mockNamespace}.{shortName}Mock Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose)"))
31+
using (writer.Block($"{visibility} static global::{mockNamespace}.{shortName}Mock Mock(global::TUnit.Mocks.MockBehavior behavior = global::TUnit.Mocks.MockBehavior.Loose)"))
3132
{
3233
writer.AppendLine($"return (global::{mockNamespace}.{shortName}Mock)global::TUnit.Mocks.Mock.Of<{mockableType}>(behavior);");
3334
}

TUnit.Mocks.SourceGenerator/Builders/MockWrapperTypeBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public static string Build(MockTypeModel model)
3737

3838
using (writer.Block($"namespace {mockNamespace}"))
3939
{
40-
using (writer.Block($"public sealed class {safeName}Mock : global::TUnit.Mocks.Mock<{mockableType}>, {model.FullyQualifiedName}"))
40+
var visibility = model.IsPublic ? "public" : "internal";
41+
using (writer.Block($"{visibility} sealed class {safeName}Mock : global::TUnit.Mocks.Mock<{mockableType}>, {model.FullyQualifiedName}"))
4142
{
4243
writer.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
4344
writer.AppendLine($"internal {safeName}Mock({mockableType} mockObject, global::TUnit.Mocks.MockEngine<{mockableType}> engine)");

TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ public static ImmutableArray<MockTypeModel> TransformToModels(GeneratorSyntaxCon
174174
),
175175
AdditionalInterfaceNames = new EquatableArray<string>(additionalInterfaceNames.MoveToImmutable()),
176176
Constructors = singleTypeModel.Constructors,
177-
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract) || events.Any(e => e.IsStaticAbstract)
177+
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract) || events.Any(e => e.IsStaticAbstract),
178+
IsPublic = IsEffectivelyPublic(namedType)
178179
};
179180

180181
return ImmutableArray.Create(singleTypeModel, multiTypeModel);
@@ -319,6 +320,7 @@ private static bool HasStaticAbstractMembers(INamedTypeSymbol interfaceType)
319320
Properties = EquatableArray<MockMemberModel>.Empty,
320321
Events = EquatableArray<MockEventModel>.Empty,
321322
AllInterfaces = EquatableArray<string>.Empty,
323+
IsPublic = IsEffectivelyPublic(delegateType),
322324
};
323325
}
324326

@@ -367,10 +369,27 @@ private static ImmutableArray<MockTypeModel> BuildModelWithTransitiveDependencie
367369
.ToImmutableArray()
368370
),
369371
Constructors = constructors,
370-
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract) || events.Any(e => e.IsStaticAbstract)
372+
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract) || events.Any(e => e.IsStaticAbstract),
373+
IsPublic = IsEffectivelyPublic(namedType)
371374
};
372375
}
373376

377+
/// <summary>
378+
/// Returns true if the type's effective accessibility is public — i.e., the type itself
379+
/// and all its containing types are declared public. Generated wrapper/extension classes
380+
/// for types that are not effectively public must themselves be internal to avoid
381+
/// CS9338 / CS0051 inconsistent accessibility errors. (See issue #5426.)
382+
/// </summary>
383+
private static bool IsEffectivelyPublic(INamedTypeSymbol type)
384+
{
385+
for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType)
386+
{
387+
if (t.DeclaredAccessibility != Accessibility.Public)
388+
return false;
389+
}
390+
return true;
391+
}
392+
374393
// ─── IFoo.Mock() static extension discovery ────────────────────
375394

376395
/// <summary>

TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ internal sealed record MockTypeModel : IEquatable<MockTypeModel>
2424
public EquatableArray<MockConstructorModel> Constructors { get; init; } = EquatableArray<MockConstructorModel>.Empty;
2525
public bool HasStaticAbstractMembers { get; init; }
2626

27+
/// <summary>
28+
/// True if the mocked type's effective accessibility is public (the type itself and all
29+
/// containing types are public). When false, generated wrapper/extension types must be
30+
/// declared <c>internal</c> to avoid CS9338/CS0051 inconsistent accessibility errors.
31+
/// </summary>
32+
public bool IsPublic { get; init; } = true;
33+
2734
public bool Equals(MockTypeModel? other)
2835
{
2936
if (other is null) return false;
@@ -35,6 +42,7 @@ public bool Equals(MockTypeModel? other)
3542
&& IsPartialMock == other.IsPartialMock
3643
&& IsDelegateType == other.IsDelegateType
3744
&& IsWrapMock == other.IsWrapMock
45+
&& IsPublic == other.IsPublic
3846
&& Methods.Equals(other.Methods)
3947
&& Properties.Equals(other.Properties)
4048
&& Events.Equals(other.Events)
@@ -53,6 +61,7 @@ public override int GetHashCode()
5361
hash = hash * 31 + IsPartialMock.GetHashCode();
5462
hash = hash * 31 + IsDelegateType.GetHashCode();
5563
hash = hash * 31 + IsWrapMock.GetHashCode();
64+
hash = hash * 31 + IsPublic.GetHashCode();
5665
hash = hash * 31 + Methods.GetHashCode();
5766
hash = hash * 31 + Properties.GetHashCode();
5867
hash = hash * 31 + Events.GetHashCode();
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
namespace TUnit.Mocks.Tests;
2+
3+
// Reproduction and regression tests for https://github.com/thomhurst/TUnit/issues/5426
4+
// CS9338 / CS0051: Generated mock wrapper and extension classes were always emitted as `public`,
5+
// even when the source interface (or its containing type) was non-public. This produced
6+
// inconsistent-accessibility errors at compile time.
7+
//
8+
// These tests are *compile-time* regressions: if the source generator emits `public` for any of
9+
// the types below, this file fails to compile and the test project will not build.
10+
public class Issue5426Tests
11+
{
12+
internal interface IInternalDatabaseService
13+
{
14+
Task<int> GetOpenOrdersAsync();
15+
Task UpdateOrderProgressAsync(int orderId, InternalProcessingStatus status);
16+
}
17+
18+
internal enum InternalProcessingStatus
19+
{
20+
Pending,
21+
Done,
22+
}
23+
24+
internal sealed record InternalOrderId(int Value);
25+
26+
internal interface IInternalOrderRepository
27+
{
28+
InternalOrderId Get(int id);
29+
}
30+
31+
[Test]
32+
public async Task Can_Mock_Internal_Interface_With_Generic_Task_Method()
33+
{
34+
var mock = Mock.Of<IInternalDatabaseService>(MockBehavior.Loose);
35+
mock.GetOpenOrdersAsync().Returns(42);
36+
37+
var result = await mock.Object.GetOpenOrdersAsync();
38+
39+
await Assert.That(result).IsEqualTo(42);
40+
}
41+
42+
[Test]
43+
public async Task Can_Mock_Internal_Interface_With_Internal_Parameter_Type()
44+
{
45+
var mock = Mock.Of<IInternalDatabaseService>(MockBehavior.Loose);
46+
mock.UpdateOrderProgressAsync(Arg.Any<int>(), Arg.Any<InternalProcessingStatus>())
47+
.Returns();
48+
49+
await mock.Object.UpdateOrderProgressAsync(1, InternalProcessingStatus.Done);
50+
}
51+
52+
[Test]
53+
public async Task Can_Mock_Internal_Interface_With_Internal_Return_Type()
54+
{
55+
var mock = Mock.Of<IInternalOrderRepository>(MockBehavior.Loose);
56+
mock.Get(Arg.Any<int>()).Returns(new InternalOrderId(7));
57+
58+
var result = mock.Object.Get(1);
59+
60+
await Assert.That(result.Value).IsEqualTo(7);
61+
}
62+
}

0 commit comments

Comments
 (0)