From e5ea45c0bcaba7082d510b82e46ba348013d23d9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:06:09 +0100 Subject: [PATCH 1/3] fix(mocks): omit inherited explicit constraints Closes #6670 --- .../Builders/MockImplBuilder.cs | 2 +- .../Issue6670Tests.cs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 723f1a08b6..6899c932c3 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -588,7 +588,7 @@ private static void GenerateInterfaceMethod(CodeWriter writer, MockMemberModel m var signatureReturnType = (method.IsVoid && !method.IsAsync) ? "void" : method.ReturnType; var paramList = GetParameterList(method); var typeParams = GetTypeParameterList(method); - var constraints = GetConstraintClauses(method); + var constraints = GetConstraintClauses(method, forExplicitImplementation: method.ExplicitInterfaceName is not null); writer.AppendLineIfNotEmpty(method.ObsoleteAttribute); diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs new file mode 100644 index 0000000000..f6e32e378f --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs @@ -0,0 +1,42 @@ +namespace TUnit.Mocks.SourceGenerator.Tests; + +/// +/// Regression: https://github.com/thomhurst/TUnit/issues/6670 +/// A derived interface can hide a generic base method with a different return type. The base +/// slot then needs an explicit implementation, which cannot repeat inherited constraints such +/// as notnull (CS0460). +/// +public class Issue6670Tests : SnapshotTestBase +{ + [Test] + public async Task Hidden_Generic_Interface_Method_Does_Not_Repeat_Constraints_On_Explicit_Implementation() + { + var source = """ + using System.Collections.Generic; + using TUnit.Mocks; + + public interface ITest : ITestParent + { + new IList Get() where T : notnull; + } + + public interface ITestParent + { + IEnumerable Get() where T : notnull; + } + + public class TestUsage + { + void M() + { + var mock = ITest.Mock(); + } + } + """; + + var errors = GetGeneratedCompilationErrors(source); + var constraintErrors = errors.Where(error => error.Id == "CS0460").ToList(); + + await Assert.That(constraintErrors).IsEmpty(); + } +} From 7b9242639b547cb4f16559975be745ee399a5901 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:38:14 +0100 Subject: [PATCH 2/3] fix(mocks): forward explicit generic calls Add TUnit.Engine.Tests coverage for reflection and NativeAOT execution modes. --- .../Builders/MockImplBuilder.cs | 2 +- tests/TUnit.Engine.Tests/Issue6670Tests.cs | 21 +++ .../Issue6670Tests.cs | 54 +++--- ...ce_Method_Generation_Snapshot.verified.txt | 172 ++++++++++++++++++ .../Bugs/6670/Issue6670MockTests.cs | 32 ++++ .../TUnit.TestProject.csproj | 2 + 6 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 tests/TUnit.Engine.Tests/Issue6670Tests.cs create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Hidden_Generic_Interface_Method_Generation_Snapshot.verified.txt create mode 100644 tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 6899c932c3..594a9b283a 100644 --- a/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -599,7 +599,7 @@ private static void GenerateInterfaceMethod(CodeWriter writer, MockMemberModel m // Return type is compatible (e.g. IEnumerable.GetEnumerator → IEnumerable.GetEnumerator) // — delegate to the public method. var argPassList = GetArgPassList(method); - writer.AppendLine($"{signatureReturnType} {method.ExplicitInterfaceName}.{EscapeIdentifier(method.Name)}{typeParams}({paramList}){constraints} => {EscapeIdentifier(method.Name)}({argPassList});"); + writer.AppendLine($"{signatureReturnType} {method.ExplicitInterfaceName}.{EscapeIdentifier(method.Name)}{typeParams}({paramList}){constraints} => {EscapeIdentifier(method.Name)}{typeParams}({argPassList});"); } else { diff --git a/tests/TUnit.Engine.Tests/Issue6670Tests.cs b/tests/TUnit.Engine.Tests/Issue6670Tests.cs new file mode 100644 index 0000000000..850c61c637 --- /dev/null +++ b/tests/TUnit.Engine.Tests/Issue6670Tests.cs @@ -0,0 +1,21 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class Issue6670Tests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Hidden_Generic_Interface_Method_Mock_Works() + { + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._6670/Issue6670MockTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs index f6e32e378f..50209b1337 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Issue6670Tests.cs @@ -8,35 +8,43 @@ namespace TUnit.Mocks.SourceGenerator.Tests; /// public class Issue6670Tests : SnapshotTestBase { - [Test] - public async Task Hidden_Generic_Interface_Method_Does_Not_Repeat_Constraints_On_Explicit_Implementation() - { - var source = """ - using System.Collections.Generic; - using TUnit.Mocks; + private const string Source = """ + using System.Collections.Generic; + using TUnit.Mocks; - public interface ITest : ITestParent - { - new IList Get() where T : notnull; - } + public interface ITest : ITestParent + { + new IList Get() where T : notnull; + } - public interface ITestParent - { - IEnumerable Get() where T : notnull; - } + public interface ITestParent + { + IEnumerable Get() where T : notnull; + } - public class TestUsage + public class TestUsage + { + void M() { - void M() - { - var mock = ITest.Mock(); - } + var mock = ITest.Mock(); } - """; + } + """; - var errors = GetGeneratedCompilationErrors(source); - var constraintErrors = errors.Where(error => error.Id == "CS0460").ToList(); + [Test] + public async Task Hidden_Generic_Interface_Method_Does_Not_Repeat_Constraints_On_Explicit_Implementation() + { + var errors = GetGeneratedCompilationErrors(Source); + var genericImplementationErrors = errors + .Where(error => error.Id is "CS0411" or "CS0460") + .ToList(); - await Assert.That(constraintErrors).IsEmpty(); + await Assert.That(genericImplementationErrors).IsEmpty(); + } + + [Test] + public Task Hidden_Generic_Interface_Method_Generation_Snapshot() + { + return VerifyGeneratorOutput(Source); } } diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Hidden_Generic_Interface_Method_Generation_Snapshot.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Hidden_Generic_Interface_Method_Generation_Snapshot.verified.txt new file mode 100644 index 0000000000..1ba4548b22 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Hidden_Generic_Interface_Method_Generation_Snapshot.verified.txt @@ -0,0 +1,172 @@ +// +#pragma warning disable +#nullable enable + +public sealed class ITestMock : global::TUnit.Mocks.Mock, global::ITest +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal ITestMock(global::ITest mockObject, global::TUnit.Mocks.MockEngine engine) + : base(mockObject, engine) { } + + global::System.Collections.Generic.IList global::ITest.Get() => Object.Get(); + + global::System.Collections.Generic.IEnumerable global::ITestParent.Get() => ((global::ITestParent)Object).Get(); +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +file sealed class ITestMockImpl : global::ITest, 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; } + + internal ITestMockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public global::System.Collections.Generic.IList Get() where T : notnull + { + return _engine.HandleCallWithReturn>(0, "Get", global::System.Array.Empty(), global::System.Array.Empty(), global::TUnit.Mocks.TypeArguments.Of.Value); + } + + global::System.Collections.Generic.IEnumerable global::ITestParent.Get() => Get(); + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public void RaiseEvent(string eventName, object? args) + { + throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock."); + } +} + +internal static class ITestMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + internal static global::TUnit.Mocks.Mock CreateAutoMock(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new ITestMockImpl(engine); + engine.Raisable = impl; + var mock = new ITestMock(impl, engine); + return mock; + } + + internal static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + if (constructorArgs.Length > 0) throw new global::System.ArgumentException($"Interface mock 'global::ITest' does not support constructor arguments, but {constructorArgs.Length} were provided."); + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new ITestMockImpl(engine); + engine.Raisable = impl; + var mock = new ITestMock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class ITest_MockMemberExtensions + { + public static global::TUnit.Mocks.MockMethodCall> Get(this global::TUnit.Mocks.Mock mock) where T : notnull + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Get", matchers, global::TUnit.Mocks.TypeArguments.Of.Value); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.SetupAllProperties(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static global::TUnit.Mocks.Diagnostics.MockDiagnostics GetDiagnostics(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock mock, string? stateName) + => global::TUnit.Mocks.Mock.SetState(mock, stateName); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void InState(this global::TUnit.Mocks.Mock mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + public global::System.Collections.Generic.IReadOnlyList Invocations => global::TUnit.Mocks.Mock.Invocations(mock); + + public global::TUnit.Mocks.MockBehavior Behavior => global::TUnit.Mocks.Mock.Behavior(mock); + + public global::TUnit.Mocks.IDefaultValueProvider? DefaultValueProvider + { + get => global::TUnit.Mocks.Mock.GetDefaultValueProvider(mock); + set => global::TUnit.Mocks.Mock.SetDefaultValueProvider(mock, value); + } + } + #endif + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class ITest_MockStaticExtension + { + extension(global::ITest _) + { + public static global::ITestMock Mock() + { + return (global::ITestMock)global::ITestMockFactory.CreateAutoMock(global::TUnit.Mocks.Mock.DefaultBehavior); + } + + public static global::ITestMock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return (global::ITestMock)global::ITestMockFactory.CreateAutoMock(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs b/tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs new file mode 100644 index 0000000000..8880910e3f --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs @@ -0,0 +1,32 @@ +using TUnit.Mocks; +using TUnit.Mocks.Generated; + +namespace TUnit.TestProject.Bugs._6670; + +public interface ITest : ITestParent +{ + new IList Get() where T : notnull; +} + +public interface ITestParent +{ + IEnumerable Get() where T : notnull; +} + +public class Issue6670MockTests +{ + private readonly ITestMock _test = ITest.Mock(); + + [Test] + public async Task Hidden_Generic_Interface_Method_Mock_Works() + { + var configured = new List { "configured" }; + _test.Get().Returns(configured); + + ITest derived = _test; + ITestParent parent = _test; + + await Assert.That(derived.Get()).IsSameReferenceAs(configured); + await Assert.That(parent.Get()).IsSameReferenceAs(configured); + } +} diff --git a/tests/TUnit.TestProject/TUnit.TestProject.csproj b/tests/TUnit.TestProject/TUnit.TestProject.csproj index bb2f9a8485..0404324657 100644 --- a/tests/TUnit.TestProject/TUnit.TestProject.csproj +++ b/tests/TUnit.TestProject/TUnit.TestProject.csproj @@ -44,6 +44,8 @@ + + From 9660eb27028cf58e475ed00413232cb09f870474 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:44:43 +0100 Subject: [PATCH 3/3] test(mocks): move issue regression coverage Mock test pipeline runs normal and NativeAOT modes across all supported target frameworks. --- tests/TUnit.Engine.Tests/Issue6670Tests.cs | 21 ------------------- .../Issue6670Tests.cs} | 18 +++++++--------- .../TUnit.TestProject.csproj | 2 -- 3 files changed, 8 insertions(+), 33 deletions(-) delete mode 100644 tests/TUnit.Engine.Tests/Issue6670Tests.cs rename tests/{TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs => TUnit.Mocks.Tests/Issue6670Tests.cs} (56%) diff --git a/tests/TUnit.Engine.Tests/Issue6670Tests.cs b/tests/TUnit.Engine.Tests/Issue6670Tests.cs deleted file mode 100644 index 850c61c637..0000000000 --- a/tests/TUnit.Engine.Tests/Issue6670Tests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Shouldly; -using TUnit.Engine.Tests.Enums; - -namespace TUnit.Engine.Tests; - -public class Issue6670Tests(TestMode testMode) : InvokableTestBase(testMode) -{ - [Test] - public async Task Hidden_Generic_Interface_Method_Mock_Works() - { - await RunTestsWithFilter( - "/*/TUnit.TestProject.Bugs._6670/Issue6670MockTests/*", - [ - result => result.ResultSummary.Outcome.ShouldBe("Completed"), - result => result.ResultSummary.Counters.Total.ShouldBe(1), - result => result.ResultSummary.Counters.Passed.ShouldBe(1), - result => result.ResultSummary.Counters.Failed.ShouldBe(0), - result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) - ]); - } -} diff --git a/tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs b/tests/TUnit.Mocks.Tests/Issue6670Tests.cs similarity index 56% rename from tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs rename to tests/TUnit.Mocks.Tests/Issue6670Tests.cs index 8880910e3f..d6251d3bfb 100644 --- a/tests/TUnit.TestProject/Bugs/6670/Issue6670MockTests.cs +++ b/tests/TUnit.Mocks.Tests/Issue6670Tests.cs @@ -1,30 +1,28 @@ using TUnit.Mocks; -using TUnit.Mocks.Generated; -namespace TUnit.TestProject.Bugs._6670; +namespace TUnit.Mocks.Tests; -public interface ITest : ITestParent +public interface IIssue6670Test : IIssue6670TestParent { new IList Get() where T : notnull; } -public interface ITestParent +public interface IIssue6670TestParent { IEnumerable Get() where T : notnull; } -public class Issue6670MockTests +public class Issue6670Tests { - private readonly ITestMock _test = ITest.Mock(); - [Test] public async Task Hidden_Generic_Interface_Method_Mock_Works() { + var mock = IIssue6670Test.Mock(); var configured = new List { "configured" }; - _test.Get().Returns(configured); + mock.Get().Returns(configured); - ITest derived = _test; - ITestParent parent = _test; + IIssue6670Test derived = mock; + IIssue6670TestParent parent = mock; await Assert.That(derived.Get()).IsSameReferenceAs(configured); await Assert.That(parent.Get()).IsSameReferenceAs(configured); diff --git a/tests/TUnit.TestProject/TUnit.TestProject.csproj b/tests/TUnit.TestProject/TUnit.TestProject.csproj index 0404324657..bb2f9a8485 100644 --- a/tests/TUnit.TestProject/TUnit.TestProject.csproj +++ b/tests/TUnit.TestProject/TUnit.TestProject.csproj @@ -44,8 +44,6 @@ - -