diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Member_Names.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Member_Names.verified.txt index ddb26dd05e..5b7654446e 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Member_Names.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Member_Names.verified.txt @@ -155,6 +155,13 @@ namespace TUnit.Mocks.Generated return new IEscapedNames_namespace_M3_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 3, "namespace", matchers); } + /// Configure the mock setup for namespace with every argument matched as Any<T>(). + public static IEscapedNames_namespace_M3_MockCall @namespace(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new IEscapedNames_namespace_M3_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 3, "namespace", matchers); + } + extension(global::TUnit.Mocks.Mock mock) { public global::TUnit.Mocks.PropertyMockCall @class diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Parameter_Names.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Parameter_Names.verified.txt index e3579eaf07..8075626d5b 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Parameter_Names.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Keyword_Parameter_Names.verified.txt @@ -134,6 +134,13 @@ namespace TUnit.Mocks.Generated var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { __fa_class.Matcher, __fa_return.Matcher }; return new ITest_Get_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Get", matchers); } + + /// Configure the mock setup for Get with every argument matched as Any<T>(). + public static ITest_Get_M1_MockCall Get(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new ITest_Get_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Get", matchers); + } } [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Reference_Type_Parameters.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Reference_Type_Parameters.verified.txt index 16fadb5171..f81c28f90d 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Reference_Type_Parameters.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Nullable_Reference_Type_Parameters.verified.txt @@ -173,6 +173,13 @@ namespace TUnit.Mocks.Generated return new IFoo_GetValue_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "GetValue", matchers); } + /// Configure the mock setup for GetValue with every argument matched as Any<T>(). + public static IFoo_GetValue_M1_MockCall GetValue(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new IFoo_GetValue_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "GetValue", matchers); + } + public static IFoo_Process_M2_MockCall Process(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg nonNull, global::TUnit.Mocks.Arguments.Arg nullable, global::TUnit.Mocks.Arguments.Arg obj) { var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { nonNull.Matcher, nullable.Matcher, obj.Matcher }; @@ -233,6 +240,13 @@ namespace TUnit.Mocks.Generated return new IFoo_Process_M2_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, "Process", matchers); } + /// Configure the mock setup for Process with every argument matched as Any<T>(). + public static IFoo_Process_M2_MockCall Process(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new IFoo_Process_M2_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, "Process", matchers); + } + public static IFoo_GetAsync_M3_MockCall GetAsync(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg key) { var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { key.Matcher }; diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Obsolete_Members.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Obsolete_Members.verified.txt index 75c955cf57..9fd5c76dc1 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Obsolete_Members.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_Obsolete_Members.verified.txt @@ -423,6 +423,13 @@ namespace TUnit.Mocks.Generated return new IDialogService_Show_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Show", matchers); } + /// Configure the mock setup for Show with every argument matched as Any<T>(). + public static IDialogService_Show_M0_MockCall Show(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new IDialogService_Show_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Show", matchers); + } + public static global::TUnit.Mocks.MockMethodCall ShowPanel(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg data) where TData : class { var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { data.Matcher }; diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Method_Interface.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Method_Interface.verified.txt index a2a21c410c..dee87e6a31 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Method_Interface.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Multi_Method_Interface.verified.txt @@ -129,6 +129,13 @@ namespace TUnit.Mocks.Generated return new ICalculator_Add_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Add", matchers); } + /// Configure the mock setup for Add with every argument matched as Any<T>(). + public static ICalculator_Add_M0_MockCall Add(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new ICalculator_Add_M0_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "Add", matchers); + } + public static ICalculator_Subtract_M1_MockCall Subtract(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg a, global::TUnit.Mocks.Arguments.Arg b) { var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { a.Matcher, b.Matcher }; @@ -157,6 +164,13 @@ namespace TUnit.Mocks.Generated return new ICalculator_Subtract_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Subtract", matchers); } + /// Configure the mock setup for Subtract with every argument matched as Any<T>(). + public static ICalculator_Subtract_M1_MockCall Subtract(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.AnyArgs _) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { global::TUnit.Mocks.Matchers.AnyMatcher.Instance, global::TUnit.Mocks.Matchers.AnyMatcher.Instance }; + return new ICalculator_Subtract_M1_MockCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "Subtract", matchers); + } + public static global::TUnit.Mocks.VoidMockMethodCall Reset(this global::TUnit.Mocks.Mock mock) { var matchers = global::System.Array.Empty(); diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index c1545d61dc..8c5cd9a2ec 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -676,6 +676,8 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false); EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false); } + + EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: false, receiverIsThis: false); } private static void GenerateGenericMethodExtensionBlock(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) @@ -716,6 +718,8 @@ private static void GenerateGenericMethodMembersInCurrentBlock(CodeWriter writer EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false); EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false); } + + EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: true, receiverIsThis: false); } internal static void GenerateGenericMethodMembersForWrapper(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) @@ -735,6 +739,8 @@ internal static void GenerateGenericMethodMembersForWrapper(CodeWriter writer, M EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true); EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true); } + + EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: true, receiverIsThis: true); } /// @@ -869,23 +875,103 @@ private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel meth writer.AppendLine($"var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {matcherArgs} }};"); } - if (useTypedWrapper) - { - var wrapperName = MockImplBuilder.GetGeneratedTypeName(GetWrapperName(safeName, method), model); - writer.AppendLine($"return new {wrapperName}(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else if (method.IsVoid || method.IsRefStructReturn) - { - writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else if (method.IsReturnTypeStaticAbstractInterface) - { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else + EmitReturnConstruction(writer, method, model, safeName, useTypedWrapper, setupReturnType); + } + } + + /// + /// Emits the dispatch tail shared by every setup overload: build a return statement constructing + /// the right wrapper / mock-call type given the matchers array already in scope as matchers. + /// + private static void EmitReturnConstruction(CodeWriter writer, MockMemberModel method, MockTypeModel model, + string safeName, bool useTypedWrapper, string setupReturnType) + { + if (useTypedWrapper) + { + var wrapperName = MockImplBuilder.GetGeneratedTypeName(GetWrapperName(safeName, method), model); + writer.AppendLine($"return new {wrapperName}(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); + } + else if (method.IsVoid || method.IsRefStructReturn) + { + writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); + } + else if (method.IsReturnTypeStaticAbstractInterface) + { + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); + } + else + { + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{setupReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); + } + } + + /// + /// Emits a single-argument shortcut overload that accepts AnyArgs and fills every + /// matchable parameter slot with AnyMatcher<T>.Instance. Skipped when the shortcut + /// would be ambiguous (the method name is not unique on the model), unhelpful (zero or one + /// matchable parameter), unable to infer method type parameters, or unsafe to mirror at this + /// layer (out / ref / ref-struct params). + /// + private static void EmitAnyArgsOverload(CodeWriter writer, MockMemberModel method, MockTypeModel model, + string safeName, bool captureModelTypeParameters, bool receiverIsThis) + { + // AnyArgs carries no typed argument information, so generic method type parameters + // cannot be inferred at the call site. Defer to the explicit per-parameter overload. + if (method.IsGenericMethod) return; + + // Out, ref, and ref-struct params change matcher arity or signature shape; rather than + // try to mirror those variations through the AnyArgs path, defer to the explicit form. + foreach (var p in method.Parameters) + { + if (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref) return; + if (p.IsRefStruct) return; + } + + // After the early-return loop above, every parameter is matchable. + if (method.Parameters.Length < 2) return; + + // Name uniqueness: same set of methods that drive extension-method emission. + int sameNameCount = 0; + foreach (var m in model.Methods) + { + if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue; + if (m.Name == method.Name) sameNameCount++; + } + if (sameNameCount > 1) return; + + var (useTypedWrapper, returnType, setupReturnType) = GetReturnTypeInfo(method, model, safeName); + + var typeParams = captureModelTypeParameters + ? MockImplBuilder.GetTypeParameterList(method) + : GetCombinedTypeParameterList(model, method); + var constraints = captureModelTypeParameters + ? MockImplBuilder.GetConstraintClauses(method) + : GetCombinedConstraintClauses(model, method); + + var safeMemberName = GetSafeMemberName(method.Name); + var paramListInner = "global::TUnit.Mocks.Arguments.AnyArgs _"; + var fullParamList = captureModelTypeParameters + ? paramListInner + : BuildExtensionMethodParameterList(model, paramListInner); + + var methodDeclarationPrefix = captureModelTypeParameters ? "public" : "public static"; + + writer.AppendLine(); + writer.AppendLine($"/// Configure the mock setup for {method.Name} with every argument matched as Any<T>()."); + using (writer.Block($"{methodDeclarationPrefix} {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) + { + if (receiverIsThis) { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{setupReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); + writer.AppendLine("var mock = this;"); } + + // AnyMatcher.Instance is stateless and shared — unlike Arg.Any(), it skips the + // CapturingMatcher wrap, so the AnyArgs path doesn't accumulate captured values per call. + var matcherArgs = string.Join(", ", method.Parameters.Select(p => + $"global::TUnit.Mocks.Matchers.AnyMatcher<{p.FullyQualifiedType}>.Instance")); + writer.AppendLine($"var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {matcherArgs} }};"); + + EmitReturnConstruction(writer, method, model, safeName, useTypedWrapper, setupReturnType); } } @@ -1029,24 +1115,7 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me writer.AppendLine($"var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {string.Join(", ", matcherExprs)} }};"); } - // Return statement - if (useTypedWrapper) - { - var wrapperName = MockImplBuilder.GetGeneratedTypeName(GetWrapperName(safeName, method), model); - writer.AppendLine($"return new {wrapperName}(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else if (method.IsVoid || method.IsRefStructReturn) - { - writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else if (method.IsReturnTypeStaticAbstractInterface) - { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } - else - { - writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{setupReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {method.MemberId}, \"{method.Name}\", matchers);"); - } + EmitReturnConstruction(writer, method, model, safeName, useTypedWrapper, setupReturnType); } } diff --git a/TUnit.Mocks.Tests/AnyArgsTests.cs b/TUnit.Mocks.Tests/AnyArgsTests.cs new file mode 100644 index 0000000000..18e80d5e7b --- /dev/null +++ b/TUnit.Mocks.Tests/AnyArgsTests.cs @@ -0,0 +1,155 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +/// +/// Test interfaces for the Arg.AnyArgs shortcut. The shortcut emits a single +/// setup overload that fills every matchable parameter with Any(), so a user +/// no longer has to write Any() once per parameter. +/// +public interface IFiveParamService +{ + int Compute(int a, int b, int c, int d, int e); + void Log(string source, string level, string message, int code, bool fatal); +} + +public interface ITwoParamService +{ + string Combine(string left, string right); +} + +public interface IOverloadedSumService +{ + int Sum(int a, int b); + int Sum(int a, int b, int c); + void Notify(string message); + void Notify(string source, string message); +} + +public interface IGenericAnyArgsService +{ + T Pick(T left, T right); +} + +public class AnyArgsTests +{ + [Test] + public async Task AnyArgs_Setup_Returns_Value_For_Any_Argument_Combination() + { + var mock = IFiveParamService.Mock(); + mock.Compute(AnyArgs()).Returns(42); + + var svc = mock.Object; + + await Assert.That(svc.Compute(0, 0, 0, 0, 0)).IsEqualTo(42); + await Assert.That(svc.Compute(1, 2, 3, 4, 5)).IsEqualTo(42); + await Assert.That(svc.Compute(-100, int.MaxValue, 0, 7, -1)).IsEqualTo(42); + } + + [Test] + public async Task AnyArgs_Setup_Works_For_Two_Param_Method() + { + var mock = ITwoParamService.Mock(); + mock.Combine(AnyArgs()).Returns("OK"); + + var svc = mock.Object; + + await Assert.That(svc.Combine("a", "b")).IsEqualTo("OK"); + await Assert.That(svc.Combine("", "")).IsEqualTo("OK"); + await Assert.That(svc.Combine(null!, null!)).IsEqualTo("OK"); + } + + [Test] + public async Task AnyArgs_Setup_Works_For_Void_Method() + { + var mock = IFiveParamService.Mock(); + mock.Log(AnyArgs()).Throws(new System.InvalidOperationException("boom")); + + var svc = mock.Object; + + await Assert.That(() => svc.Log("a", "b", "c", 0, false)) + .Throws(); + await Assert.That(() => svc.Log("x", "y", "z", 99, true)) + .Throws(); + } + + [Test] + public async Task AnyArgs_Verify_Catches_Any_Call() + { + var mock = IFiveParamService.Mock(); + var svc = mock.Object; + + svc.Compute(1, 2, 3, 4, 5); + svc.Compute(10, 20, 30, 40, 50); + + mock.Compute(AnyArgs()).WasCalled(Times.Exactly(2)); + } + + [Test] + public async Task AnyArgs_Equivalent_To_All_Any_Slots() + { + var mockA = IFiveParamService.Mock(); + var mockB = IFiveParamService.Mock(); + + mockA.Compute(AnyArgs()).Returns(7); + mockB.Compute(Any(), Any(), Any(), Any(), Any()).Returns(7); + + await Assert.That(mockA.Object.Compute(1, 2, 3, 4, 5)).IsEqualTo(7); + await Assert.That(mockB.Object.Compute(1, 2, 3, 4, 5)).IsEqualTo(7); + } + + [Test] + [SkipIfNotDynamicCodeSupported("Inspects generated extension class metadata via reflection; trimmer removes it under PublishTrimmed.")] + public async Task AnyArgs_NotEmitted_For_Overloaded_Method_Names() + { + var mock = IOverloadedSumService.Mock(); + mock.Sum(Any(), Any()).Returns(11); + mock.Sum(Any(), Any(), Any()).Returns(111); + + var svc = mock.Object; + + await Assert.That(svc.Sum(1, 2)).IsEqualTo(11); + await Assert.That(svc.Sum(1, 2, 3)).IsEqualTo(111); + + await Assert.That(HasAnyArgsOverload("TUnit_Mocks_Tests_IOverloadedSumService_MockMemberExtensions", "Sum")).IsFalse(); + await Assert.That(HasAnyArgsOverload("TUnit_Mocks_Tests_IOverloadedSumService_MockMemberExtensions", "Notify")).IsFalse(); + } + + [Test] + [SkipIfNotDynamicCodeSupported("Inspects generated extension class metadata via reflection; trimmer removes it under PublishTrimmed.")] + public async Task AnyArgs_Emitted_For_Unique_Method_Names() + { + await Assert.That(HasAnyArgsOverload("TUnit_Mocks_Tests_IFiveParamService_MockMemberExtensions", "Compute")).IsTrue(); + await Assert.That(HasAnyArgsOverload("TUnit_Mocks_Tests_IFiveParamService_MockMemberExtensions", "Log")).IsTrue(); + } + + [Test] + [SkipIfNotDynamicCodeSupported("Inspects generated extension class metadata via reflection; trimmer removes it under PublishTrimmed.")] + public async Task AnyArgs_NotEmitted_For_Generic_Methods() + { + await Assert.That(HasAnyArgsOverload("TUnit_Mocks_Tests_IGenericAnyArgsService_MockMemberExtensions", "Pick")).IsFalse(); + } + + // The generated extension class lives in TUnit.Mocks.Generated and is named + // _MockMemberExtensions; the AnyArgs overload appears as a 2-param + // extension method whose second parameter is typed AnyArgs. Keep these literal class + // names in sync with MockImplBuilder.GetSafeName. + private static bool HasAnyArgsOverload(string extensionsTypeName, string methodName) + { + var extensionsType = typeof(AnyArgsTests).Assembly.GetTypes() + .SingleOrDefault(t => t.Name == extensionsTypeName); + if (extensionsType is null) + { + return false; + } + + return extensionsType.GetMethods() + .Where(m => m.Name == methodName) + .Any(m => + { + var ps = m.GetParameters(); + return ps.Length == 2 && ps[1].ParameterType == typeof(AnyArgs); + }); + } +} diff --git a/TUnit.Mocks.Tests/SkipIfNotDynamicCodeSupportedAttribute.cs b/TUnit.Mocks.Tests/SkipIfNotDynamicCodeSupportedAttribute.cs new file mode 100644 index 0000000000..2023c6d414 --- /dev/null +++ b/TUnit.Mocks.Tests/SkipIfNotDynamicCodeSupportedAttribute.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; + +namespace TUnit.Mocks.Tests; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class SkipIfNotDynamicCodeSupportedAttribute : SkipAttribute +{ + public SkipIfNotDynamicCodeSupportedAttribute(string reason) : base(reason) + { + } + + public override Task ShouldSkip(TestRegisteredContext context) + { +#if NET + return Task.FromResult(!RuntimeFeature.IsDynamicCodeSupported); +#else + return Task.FromResult(false); +#endif + } +} diff --git a/TUnit.Mocks/Arguments/AnyArgs.cs b/TUnit.Mocks/Arguments/AnyArgs.cs new file mode 100644 index 0000000000..57c30d393f --- /dev/null +++ b/TUnit.Mocks/Arguments/AnyArgs.cs @@ -0,0 +1,16 @@ +namespace TUnit.Mocks.Arguments; + +/// +/// Sentinel type returned by . Passed to a generated mock setup or +/// verification overload, it stands in for a complete argument list of +/// matchers — one per parameter — so callers do not have to repeat Any() for each slot. +/// +/// The shortcut overload is only generated when the method's name is unique on the mocked type; +/// for overloaded names, generic methods, or methods with fewer than two matchable parameters, +/// use the explicit per-parameter form. +/// +/// +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public readonly struct AnyArgs +{ +} diff --git a/TUnit.Mocks/Arguments/Arg.cs b/TUnit.Mocks/Arguments/Arg.cs index eedac5d3fc..be2bcb973e 100644 --- a/TUnit.Mocks/Arguments/Arg.cs +++ b/TUnit.Mocks/Arguments/Arg.cs @@ -15,6 +15,13 @@ public static class Arg /// Matches any value — type is inferred from the parameter position. public static AnyArg Any() => AnyArg.Instance; + /// + /// Shortcut for setting up or verifying a mocked method when every argument should match + /// . Equivalent to passing Any() for each parameter. + /// Only available on non-generic methods whose name is unique on the mocked type. + /// + public static AnyArgs AnyArgs() => default; + /// Matches using exact equality. public static Arg Is(T value) => new(new ExactMatcher(value)); diff --git a/TUnit.Mocks/Matchers/AnyMatcher.cs b/TUnit.Mocks/Matchers/AnyMatcher.cs index 99c1dc73c7..df8dc1f9f9 100644 --- a/TUnit.Mocks/Matchers/AnyMatcher.cs +++ b/TUnit.Mocks/Matchers/AnyMatcher.cs @@ -17,8 +17,10 @@ internal sealed class AnyMatcher : IArgumentMatcher /// /// An argument matcher that matches any value of the specified type, including null. +/// Public for source-generator access; not intended for direct use — call instead. /// -internal sealed class AnyMatcher : IArgumentMatcher +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public sealed class AnyMatcher : IArgumentMatcher { /// /// Cached singleton — is stateless, so a single instance per closed @@ -26,6 +28,8 @@ internal sealed class AnyMatcher : IArgumentMatcher /// public static readonly AnyMatcher Instance = new(); + private AnyMatcher() { } + public bool Matches(T? value) => true; public bool Matches(object? value) => true; diff --git a/docs/docs/writing-tests/mocking/argument-matchers.md b/docs/docs/writing-tests/mocking/argument-matchers.md index bf81df5cb7..9fbed82606 100644 --- a/docs/docs/writing-tests/mocking/argument-matchers.md +++ b/docs/docs/writing-tests/mocking/argument-matchers.md @@ -15,6 +15,7 @@ TUnit.Mocks automatically imports the `Arg` class via `global using static`, so | Matcher | Matches | |---|---| | `Any()` / `Any()` | Any value of type T (including null) | +| `AnyArgs()` | Shortcut for matching every argument with `Any()` (only on uniquely named methods) | | `Is(value)` | Exact equality | | `Is(predicate)` | Values satisfying a predicate | | Raw value (e.g. `42`, `"hello"`) | Exact equality (implicit conversion) | @@ -47,6 +48,33 @@ svc.GetUser(1); // matches svc.GetUser(999); // matches ``` +### AnyArgs — match every parameter with one token + +When you only care that a method was called and don't want to constrain any of its arguments, repeating `Any()` for each parameter gets noisy: + +```csharp +mock.Compute(Any(), Any(), Any(), Any(), Any()).Returns(42); +``` + +`AnyArgs()` is a shortcut that fills every matchable parameter with `Any()` in one token: + +```csharp +mock.Compute(AnyArgs()).Returns(42); + +mock.Log(AnyArgs()).Throws(); + +// Works for verification too +mock.Compute(AnyArgs()).WasCalled(Times.Exactly(2)); +``` + +The two forms are equivalent — `AnyArgs()` simply expands to one `Arg.Any()` per parameter. + +:::note When the shortcut is generated +The `AnyArgs()` overload is only emitted when the method's name is unique on the mocked type. If the type has overloads of the same name (for example `Sum(int, int)` and `Sum(int, int, int)`), the shortcut would be ambiguous, so the generator omits it for those methods — use the explicit per-parameter form instead. + +The shortcut is also skipped for generic methods, methods with `out`, `ref`, or ref-struct parameters, and methods with fewer than two matchable parameters. Generic methods need typed arguments so C# can infer their method type parameters, and single-parameter methods are already as short with `Any()`. +::: + ### Exact Value Match a specific value using equality: