From 4f62bcd445accb2acf64ac58a9ec619284e8edf2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:41:22 +0000 Subject: [PATCH 1/7] feat(mocks): add non-generic AnyMatcher for ref struct arg positions --- TUnit.Mocks/Matchers/AnyMatcher.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/TUnit.Mocks/Matchers/AnyMatcher.cs b/TUnit.Mocks/Matchers/AnyMatcher.cs index 5ea02ba145..078256388c 100644 --- a/TUnit.Mocks/Matchers/AnyMatcher.cs +++ b/TUnit.Mocks/Matchers/AnyMatcher.cs @@ -2,6 +2,19 @@ namespace TUnit.Mocks.Matchers; +/// +/// A non-generic argument matcher that matches any value including null. +/// Used for ref struct parameter positions where the generic AnyMatcher<T> cannot be used. +/// +internal sealed class AnyMatcher : IArgumentMatcher +{ + public static AnyMatcher Instance { get; } = new(); + + public bool Matches(object? value) => true; + + public string Describe() => "Any"; +} + /// /// An argument matcher that matches any value of the specified type, including null. /// From 23bd5ea497523ac06548215e83f617ec3a98137a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:42:29 +0000 Subject: [PATCH 2/7] feat(mocks): add RefStructArg with allows ref struct for net9.0+ --- TUnit.Mocks/Arguments/RefStructArg.cs | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 TUnit.Mocks/Arguments/RefStructArg.cs diff --git a/TUnit.Mocks/Arguments/RefStructArg.cs b/TUnit.Mocks/Arguments/RefStructArg.cs new file mode 100644 index 0000000000..6e05229067 --- /dev/null +++ b/TUnit.Mocks/Arguments/RefStructArg.cs @@ -0,0 +1,28 @@ +#if NET9_0_OR_GREATER + +namespace TUnit.Mocks.Arguments; + +/// +/// Represents an argument matcher for a ref struct parameter in mock setup and verification expressions. +/// Since ref struct types cannot be used as generic type arguments for , +/// this type uses the allows ref struct anti-constraint (C# 13+) to accept them. +/// +/// The ref struct parameter type. +public readonly struct RefStructArg where T : allows ref struct +{ + /// Gets the argument matcher. Public for generated code access. Not intended for direct use. + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public IArgumentMatcher Matcher { get; } + + /// Creates a RefStructArg with a matcher. Public for generated code access. Not intended for direct use. + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public RefStructArg(IArgumentMatcher matcher) + { + Matcher = matcher; + } + + /// Matches any value of the ref struct type. This is currently the only supported matcher for ref struct parameters. + public static RefStructArg Any => new(Mocks.Matchers.AnyMatcher.Instance); +} + +#endif From 83f9745da6d9523b222dfbfd767bdc82be6f2a02 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:46:55 +0000 Subject: [PATCH 3/7] feat(mocks): source gen emits RefStructArg with #if NET9_0_OR_GREATER blocks --- .../Builders/MockImplBuilder.cs | 66 ++++++- .../Builders/MockMembersBuilder.cs | 164 +++++++++++++++--- 2 files changed, 199 insertions(+), 31 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index e35f5c568b..74fd068fb5 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -188,7 +188,21 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me } } - var argsArray = GetArgsArrayExpression(method); + string argsArray; + if (HasRefStructParams(method)) + { + // Emit #if block so the variable is defined under both branches + writer.AppendLine("#if NET9_0_OR_GREATER"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); + writer.AppendLine("#else"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); + writer.AppendLine("#endif"); + argsArray = "__args"; + } + else + { + argsArray = GetArgsArrayExpression(method, false); + } var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -461,7 +475,20 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } } - var argsArray = GetArgsArrayExpression(method); + string argsArray; + if (HasRefStructParams(method)) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); + writer.AppendLine("#else"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); + writer.AppendLine("#endif"); + argsArray = "__args"; + } + else + { + argsArray = GetArgsArrayExpression(method, false); + } var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -551,7 +578,20 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode } } - var argsArray = GetArgsArrayExpression(method); + string argsArray; + if (HasRefStructParams(method)) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); + writer.AppendLine("#else"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); + writer.AppendLine("#endif"); + argsArray = "__args"; + } + else + { + argsArray = GetArgsArrayExpression(method, false); + } var hasOutRef = HasOutRefParams(method); @@ -955,14 +995,22 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method } } - private static string GetArgsArrayExpression(MockMemberModel method) + private static bool HasRefStructParams(MockMemberModel method) + => method.Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + + private static string GetArgsArrayExpression(MockMemberModel method, bool includeRefStructSentinels) { - // Only include non-out, non-ref-struct parameters in args array - // (ref structs cannot be boxed into object?[]) - var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + var nonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); + if (includeRefStructSentinels) + { + if (nonOutParams.Count == 0) return "global::System.Array.Empty()"; + var args = string.Join(", ", nonOutParams.Select(p => p.IsRefStruct ? "null" : p.Name)); + return $"new object?[] {{ {args} }}"; + } + var matchableParams = nonOutParams.Where(p => !p.IsRefStruct).ToList(); if (matchableParams.Count == 0) return "global::System.Array.Empty()"; - var args = string.Join(", ", matchableParams.Select(p => p.Name)); - return $"new object?[] {{ {args} }}"; + var argsStr = string.Join(", ", matchableParams.Select(p => p.Name)); + return $"new object?[] {{ {argsStr} }}"; } /// diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 725e364b9c..c2c1f63e8f 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -110,21 +110,23 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode var wrapperName = GetWrapperName(safeName, method); var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + var hasRefStructParams = HasRefStructParams(method); + var allNonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); // Ref struct returns use the void wrapper (can't use generic type args with ref structs) if (method.IsVoid || method.IsRefStructReturn) { - GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters); + GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams); } else { - GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters); + GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters, hasRefStructParams, allNonOutParams); } } private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapperName, List nonOutParams, string returnType, EquatableArray events, - EquatableArray allParameters) + EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams) { var builderType = $"global::TUnit.Mocks.Setup.MethodSetupBuilder<{returnType}>"; var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); @@ -198,11 +200,30 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper if (nonOutParams.Count >= 1) { writer.AppendLine(); - GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); - writer.AppendLine(); - GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); - writer.AppendLine(); - GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + if (hasRefStructParams) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine("#else"); + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + writer.AppendLine("#endif"); + } + else + { + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + } } // Typed out/ref parameter setters @@ -239,7 +260,7 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperName, List nonOutParams, EquatableArray events, - EquatableArray allParameters) + EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams) { var builderType = "global::TUnit.Mocks.Setup.VoidMethodSetupBuilder"; var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); @@ -307,9 +328,24 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa if (nonOutParams.Count >= 1) { writer.AppendLine(); - GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); - writer.AppendLine(); - GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + if (hasRefStructParams) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine("#else"); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + writer.AppendLine("#endif"); + } + else + { + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + } } // Typed out/ref parameter setters @@ -359,6 +395,21 @@ private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, + string returnType, string wrapperName, List allNonOutParams) + { + var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); + var funcType = $"global::System.Func<{typeList}, {returnType}>"; + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); + + writer.AppendLine("/// Configure a typed computed return value using the actual method parameters."); + using (writer.Block($"public {wrapperName} Returns({funcType} factory)")) + { + writer.AppendLine($"EnsureSetup().Returns(args => factory({castArgs}));"); + writer.AppendLine("return this;"); + } + } + private static void GenerateTypedCallbackOverload(CodeWriter writer, List nonOutParams, string wrapperName) { @@ -374,6 +425,21 @@ private static void GenerateTypedCallbackOverload(CodeWriter writer, List nonOutParams, + string wrapperName, List allNonOutParams) + { + var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); + var actionType = $"global::System.Action<{typeList}>"; + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); + + writer.AppendLine("/// Execute a typed callback using the actual method parameters."); + using (writer.Block($"public {wrapperName} Callback({actionType} callback)")) + { + writer.AppendLine($"EnsureSetup().Callback(args => callback({castArgs}));"); + writer.AppendLine("return this;"); + } + } + private static void GenerateTypedThrowsOverload(CodeWriter writer, List nonOutParams, string wrapperName) { @@ -389,6 +455,21 @@ private static void GenerateTypedThrowsOverload(CodeWriter writer, List nonOutParams, + string wrapperName, List allNonOutParams) + { + var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); + var funcType = $"global::System.Func<{typeList}, global::System.Exception>"; + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); + + writer.AppendLine("/// Configure a typed computed exception using the actual method parameters."); + using (writer.Block($"public {wrapperName} Throws({funcType} exceptionFactory)")) + { + writer.AppendLine($"EnsureSetup().Throws(args => exceptionFactory({castArgs}));"); + writer.AppendLine("return this;"); + } + } + private static void GenerateTypedEventRaises(CodeWriter writer, EquatableArray events, string wrapperName) { @@ -448,7 +529,35 @@ private static string BuildCastArgs(List nonOutParams) $"({p.FullyQualifiedType})args[{i}]!")); } + private static string BuildCastArgs(List nonRefStructParams, List allNonOutParams) + { + return string.Join(", ", nonRefStructParams.Select(p => + { + var realIndex = allNonOutParams.IndexOf(p); + return $"({p.FullyQualifiedType})args[{realIndex}]!"; + })); + } + + private static bool HasRefStructParams(MockMemberModel method) + => method.Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) + { + if (HasRefStructParams(method)) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true); + writer.AppendLine("#else"); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + writer.AppendLine("#endif"); + } + else + { + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + } + } + + private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs) { // For async methods (Task/ValueTask), unwrap the return type so users write .Returns(5) not .Returns(Task.FromResult(5)) // For void-async methods (Task/ValueTask), IsVoid is already true @@ -474,7 +583,7 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth returnType = $"global::TUnit.Mocks.MockMethodCall<{setupReturnType}>"; } - var paramList = GetArgParameterList(method); + var paramList = GetArgParameterList(method, includeRefStructArgs); var typeParams = GetTypeParameterList(method); var constraints = GetConstraintClauses(method); @@ -484,9 +593,10 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { - // Build matchers array (exclude out and ref struct params) - var matchableParams = method.Parameters - .Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + // Build matchers array + var matchableParams = includeRefStructArgs + ? method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList() + : method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); if (matchableParams.Count == 0) { @@ -576,13 +686,23 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod } } - private static string GetArgParameterList(MockMemberModel method) + private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs) { - // Only include non-out, non-ref-struct parameters as Arg in setup - // (ref structs cannot be used as generic type arguments) - return string.Join(", ", method.Parameters - .Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct) - .Select(p => $"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}")); + var parts = new List(); + foreach (var p in method.Parameters) + { + if (p.Direction == ParameterDirection.Out) continue; + if (p.IsRefStruct) + { + if (includeRefStructArgs) + parts.Add($"global::TUnit.Mocks.Arguments.RefStructArg<{p.FullyQualifiedType}> {p.Name}"); + } + else + { + parts.Add($"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}"); + } + } + return string.Join(", ", parts); } private static string GetTypeParameterList(MockMemberModel method) From 8a7f41d93098f57a65e2049e40a70db7b93b6251 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:51:42 +0000 Subject: [PATCH 4/7] test(mocks): update ref struct snapshot for RefStructArg support --- ...ace_With_RefStruct_Parameters.verified.txt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt index bfb4a1902d..7e9308795d 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt @@ -41,12 +41,22 @@ namespace TUnit.Mocks.Generated public void Process(global::System.ReadOnlySpan data) { - _engine.HandleCall(0, "Process", global::System.Array.Empty()); + #if NET9_0_OR_GREATER + var __args = new object?[] { null }; + #else + var __args = global::System.Array.Empty(); + #endif + _engine.HandleCall(0, "Process", __args); } public int Parse(global::System.ReadOnlySpan text) { - return _engine.HandleCallWithReturn(1, "Parse", global::System.Array.Empty(), default); + #if NET9_0_OR_GREATER + var __args = new object?[] { null }; + #else + var __args = global::System.Array.Empty(); + #endif + return _engine.HandleCallWithReturn(1, "Parse", __args, default); } public string GetName() @@ -72,17 +82,33 @@ namespace TUnit.Mocks.Generated { public static class IBufferProcessor_MockMemberExtensions { + #if NET9_0_OR_GREATER + public static global::TUnit.Mocks.VoidMockMethodCall Process(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.RefStructArg> data) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { data.Matcher }; + return new global::TUnit.Mocks.VoidMockMethodCall(mock.Engine, 0, "Process", matchers); + } + #else public static global::TUnit.Mocks.VoidMockMethodCall Process(this global::TUnit.Mocks.Mock mock) { var matchers = global::System.Array.Empty(); return new global::TUnit.Mocks.VoidMockMethodCall(mock.Engine, 0, "Process", matchers); } + #endif + #if NET9_0_OR_GREATER + public static global::TUnit.Mocks.MockMethodCall Parse(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.RefStructArg> text) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { text.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(mock.Engine, 1, "Parse", matchers); + } + #else public static global::TUnit.Mocks.MockMethodCall Parse(this global::TUnit.Mocks.Mock mock) { var matchers = global::System.Array.Empty(); return new global::TUnit.Mocks.MockMethodCall(mock.Engine, 1, "Parse", matchers); } + #endif public static global::TUnit.Mocks.MockMethodCall GetName(this global::TUnit.Mocks.Mock mock) { From 5e713cc0509fd622767151498de1dad6f3288c17 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:55:30 +0000 Subject: [PATCH 5/7] test(mocks): add RefStructArg runtime tests for net9.0+ --- TUnit.Mocks.Tests/RefStructTests.cs | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/TUnit.Mocks.Tests/RefStructTests.cs b/TUnit.Mocks.Tests/RefStructTests.cs index d0b554dc13..95fa05d6b9 100644 --- a/TUnit.Mocks.Tests/RefStructTests.cs +++ b/TUnit.Mocks.Tests/RefStructTests.cs @@ -42,6 +42,8 @@ public async Task Normal_Method_Returns_Configured_Value() await Assert.That(name).IsEqualTo("processor-1"); } +#if !NET9_0_OR_GREATER + [Test] public async Task Void_RefStruct_Method_Callback_Fires() { @@ -123,6 +125,8 @@ public async Task NonVoid_RefStruct_Param_Verification() await Assert.That(true).IsTrue(); } +#endif + [Test] public async Task Void_Normal_Method_Still_Works() { @@ -140,6 +144,8 @@ public async Task Void_Normal_Method_Still_Works() mock.Clear().WasCalled(Times.Once); } +#if !NET9_0_OR_GREATER + [Test] public async Task Mixed_Params_ArgMatching_On_NonRefStruct_Params() { @@ -179,4 +185,83 @@ public async Task Mixed_Params_Verification_With_Matcher() mock.Send(Arg.Any()).WasCalled(Times.Exactly(3)); await Assert.That(true).IsTrue(); } + +#endif + +#if NET9_0_OR_GREATER + + [Test] + public async Task RefStructArg_Any_Matches_Void_Method() + { + // Arrange + var wasCalled = false; + var mock = Mock.Of(); + mock.Process(RefStructArg>.Any).Callback(() => wasCalled = true); + + // Act + mock.Object.Process(new byte[] { 1, 2, 3 }); + + // Assert + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task RefStructArg_Any_Matches_Return_Method() + { + // Arrange + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(99); + + // Act + var result = mock.Object.Parse("test".AsSpan()); + + // Assert + await Assert.That(result).IsEqualTo(99); + } + + [Test] + public async Task RefStructArg_Mixed_Params_Works() + { + // Arrange — Compute(int id, ReadOnlySpan data) + var mock = Mock.Of(); + mock.Compute(1, RefStructArg>.Any).Returns(100); + mock.Compute(2, RefStructArg>.Any).Returns(200); + + // Act + var result1 = mock.Object.Compute(1, new byte[] { 0xFF }); + var result2 = mock.Object.Compute(2, ReadOnlySpan.Empty); + + // Assert + await Assert.That(result1).IsEqualTo(100); + await Assert.That(result2).IsEqualTo(200); + } + + [Test] + public async Task RefStructArg_Verification_With_Any() + { + // Arrange + var mock = Mock.Of(); + mock.Object.Process(new byte[] { 1, 2, 3 }); + mock.Object.Process(ReadOnlySpan.Empty); + + // Assert + mock.Process(RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task RefStructArg_Mixed_Verification() + { + // Arrange — Send(string destination, ReadOnlySpan payload) + var mock = Mock.Of(); + mock.Object.Send("server-a", new byte[] { 1, 2, 3 }); + mock.Object.Send("server-b", ReadOnlySpan.Empty); + + // Assert — verify with both Arg and RefStructArg> + mock.Send("server-a", RefStructArg>.Any).WasCalled(Times.Once); + mock.Send(Arg.Any(), RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + +#endif } From af1ee1493c67aa325c185a9ce23a81e703f8484d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:21:27 +0000 Subject: [PATCH 6/7] refactor(mocks): address code review feedback on RefStructArg PR - Extract EmitArgsArrayVariable helper in MockImplBuilder to deduplicate 3 identical #if NET9_0_OR_GREATER args array blocks - Move HasRefStructParams to computed property on MockMemberModel (derived from Parameters, excluded from equality) - Merge 3 pairs of GenerateTyped*Overload methods and 2 BuildCastArgs overloads into single methods with optional allNonOutParams parameter - Fix O(n^2) IndexOf in BuildCastArgs by pre-building a dictionary - Add net9.0+ equivalents of disabled tests using RefStructArg.Any - Add XML remarks to RefStructArg documenting limitations --- .../Builders/MockImplBuilder.cs | 60 ++++--------- .../Builders/MockMembersBuilder.cs | 74 +++------------ .../Models/MockMemberModel.cs | 7 ++ TUnit.Mocks.Tests/RefStructTests.cs | 89 +++++++++++++++++++ TUnit.Mocks/Arguments/RefStructArg.cs | 8 ++ 5 files changed, 129 insertions(+), 109 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 74fd068fb5..a619f90008 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -188,21 +188,7 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me } } - string argsArray; - if (HasRefStructParams(method)) - { - // Emit #if block so the variable is defined under both branches - writer.AppendLine("#if NET9_0_OR_GREATER"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); - writer.AppendLine("#else"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); - writer.AppendLine("#endif"); - argsArray = "__args"; - } - else - { - argsArray = GetArgsArrayExpression(method, false); - } + var argsArray = EmitArgsArrayVariable(writer, method); var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -475,20 +461,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } } - string argsArray; - if (HasRefStructParams(method)) - { - writer.AppendLine("#if NET9_0_OR_GREATER"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); - writer.AppendLine("#else"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); - writer.AppendLine("#endif"); - argsArray = "__args"; - } - else - { - argsArray = GetArgsArrayExpression(method, false); - } + var argsArray = EmitArgsArrayVariable(writer, method); var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -578,20 +551,7 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode } } - string argsArray; - if (HasRefStructParams(method)) - { - writer.AppendLine("#if NET9_0_OR_GREATER"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); - writer.AppendLine("#else"); - writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); - writer.AppendLine("#endif"); - argsArray = "__args"; - } - else - { - argsArray = GetArgsArrayExpression(method, false); - } + var argsArray = EmitArgsArrayVariable(writer, method); var hasOutRef = HasOutRefParams(method); @@ -995,8 +955,18 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method } } - private static bool HasRefStructParams(MockMemberModel method) - => method.Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + private static string EmitArgsArrayVariable(CodeWriter writer, MockMemberModel method) + { + if (!method.HasRefStructParams) + return GetArgsArrayExpression(method, false); + + writer.AppendLine("#if NET9_0_OR_GREATER"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); + writer.AppendLine("#else"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); + writer.AppendLine("#endif"); + return "__args"; + } private static string GetArgsArrayExpression(MockMemberModel method, bool includeRefStructSentinels) { diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index c2c1f63e8f..362b94ef9b 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -110,7 +110,7 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode var wrapperName = GetWrapperName(safeName, method); var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); - var hasRefStructParams = HasRefStructParams(method); + var hasRefStructParams = method.HasRefStructParams; var allNonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); // Ref struct returns use the void wrapper (can't use generic type args with ref structs) @@ -381,22 +381,7 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa } private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, - string returnType, string wrapperName) - { - var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); - var funcType = $"global::System.Func<{typeList}, {returnType}>"; - var castArgs = BuildCastArgs(nonOutParams); - - writer.AppendLine("/// Configure a typed computed return value using the actual method parameters."); - using (writer.Block($"public {wrapperName} Returns({funcType} factory)")) - { - writer.AppendLine($"EnsureSetup().Returns(args => factory({castArgs}));"); - writer.AppendLine("return this;"); - } - } - - private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, - string returnType, string wrapperName, List allNonOutParams) + string returnType, string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var funcType = $"global::System.Func<{typeList}, {returnType}>"; @@ -411,22 +396,7 @@ private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, - string wrapperName) - { - var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); - var actionType = $"global::System.Action<{typeList}>"; - var castArgs = BuildCastArgs(nonOutParams); - - writer.AppendLine("/// Execute a typed callback using the actual method parameters."); - using (writer.Block($"public {wrapperName} Callback({actionType} callback)")) - { - writer.AppendLine($"EnsureSetup().Callback(args => callback({castArgs}));"); - writer.AppendLine("return this;"); - } - } - - private static void GenerateTypedCallbackOverload(CodeWriter writer, List nonOutParams, - string wrapperName, List allNonOutParams) + string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var actionType = $"global::System.Action<{typeList}>"; @@ -441,22 +411,7 @@ private static void GenerateTypedCallbackOverload(CodeWriter writer, List nonOutParams, - string wrapperName) - { - var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); - var funcType = $"global::System.Func<{typeList}, global::System.Exception>"; - var castArgs = BuildCastArgs(nonOutParams); - - writer.AppendLine("/// Configure a typed computed exception using the actual method parameters."); - using (writer.Block($"public {wrapperName} Throws({funcType} exceptionFactory)")) - { - writer.AppendLine($"EnsureSetup().Throws(args => exceptionFactory({castArgs}));"); - writer.AppendLine("return this;"); - } - } - - private static void GenerateTypedThrowsOverload(CodeWriter writer, List nonOutParams, - string wrapperName, List allNonOutParams) + string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var funcType = $"global::System.Func<{typeList}, global::System.Exception>"; @@ -523,27 +478,18 @@ private static void GenerateTypedOutRefMethods(CodeWriter writer, EquatableArray private static string ToPascalCase(string name) => string.IsNullOrEmpty(name) ? name : char.ToUpperInvariant(name[0]) + name[1..]; - private static string BuildCastArgs(List nonOutParams) + private static string BuildCastArgs(List nonOutParams, List? allNonOutParams = null) { - return string.Join(", ", nonOutParams.Select((p, i) => - $"({p.FullyQualifiedType})args[{i}]!")); - } + if (allNonOutParams is null) + return string.Join(", ", nonOutParams.Select((p, i) => $"({p.FullyQualifiedType})args[{i}]!")); - private static string BuildCastArgs(List nonRefStructParams, List allNonOutParams) - { - return string.Join(", ", nonRefStructParams.Select(p => - { - var realIndex = allNonOutParams.IndexOf(p); - return $"({p.FullyQualifiedType})args[{realIndex}]!"; - })); + var indexMap = allNonOutParams.Select((p, i) => (p, i)).ToDictionary(x => x.p, x => x.i); + return string.Join(", ", nonOutParams.Select(p => $"({p.FullyQualifiedType})args[{indexMap[p]}]!")); } - private static bool HasRefStructParams(MockMemberModel method) - => method.Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); - private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) { - if (HasRefStructParams(method)) + if (method.HasRefStructParams) { writer.AppendLine("#if NET9_0_OR_GREATER"); EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true); diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index a2ae032191..e8b1a0b67d 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace TUnit.Mocks.SourceGenerator.Models; @@ -31,6 +32,12 @@ internal sealed record MockMemberModel : IEquatable public bool IsProtected { get; init; } public bool IsRefStructReturn { get; init; } + /// + /// Returns true if the method has any non-out ref struct parameters. + /// Computed from — does not participate in equality. + /// + public bool HasRefStructParams => Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + public bool Equals(MockMemberModel? other) { if (other is null) return false; diff --git a/TUnit.Mocks.Tests/RefStructTests.cs b/TUnit.Mocks.Tests/RefStructTests.cs index 95fa05d6b9..92fef06cb5 100644 --- a/TUnit.Mocks.Tests/RefStructTests.cs +++ b/TUnit.Mocks.Tests/RefStructTests.cs @@ -263,5 +263,94 @@ public async Task RefStructArg_Mixed_Verification() await Assert.That(true).IsTrue(); } + [Test] + public async Task RefStructArg_Void_Method_Throws_Configured_Exception() + { + // Arrange + var mock = Mock.Of(); + mock.Process(RefStructArg>.Any).Throws(); + + // Act & Assert + IBufferProcessor processor = mock.Object; + var ex = Assert.Throws(() => + { + processor.Process(new byte[] { 1 }); + }); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task RefStructArg_NonVoid_Method_Returns_Configured_Value() + { + // Arrange — Parse takes ReadOnlySpan param but returns int + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(42); + + // Act + IBufferProcessor processor = mock.Object; + var result = processor.Parse("hello".AsSpan()); + + // Assert + await Assert.That(result).IsEqualTo(42); + } + + [Test] + public async Task RefStructArg_NonVoid_Method_Verification() + { + // Arrange + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(0); + + // Act + IBufferProcessor processor = mock.Object; + processor.Parse("abc".AsSpan()); + processor.Parse("xyz".AsSpan()); + + // Assert + mock.Parse(RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task RefStructArg_Mixed_Params_ArgMatching_On_NonRefStruct_Params() + { + // Arrange — Compute(int id, ReadOnlySpan data) returns int + // Both params participate in matching on net9.0+ via RefStructArg.Any + var mock = Mock.Of(); + mock.Compute(1, RefStructArg>.Any).Returns(100); + mock.Compute(2, RefStructArg>.Any).Returns(200); + + // Act + IMixedProcessor processor = mock.Object; + var result1 = processor.Compute(1, new byte[] { 0xFF }); + var result2 = processor.Compute(2, ReadOnlySpan.Empty); + var result3 = processor.Compute(99, new byte[] { 0x00 }); + + // Assert — argument matching works on the int param + await Assert.That(result1).IsEqualTo(100); + await Assert.That(result2).IsEqualTo(200); + await Assert.That(result3).IsEqualTo(0); // no setup for id=99, returns default + } + + [Test] + public async Task RefStructArg_Mixed_Params_Verification_With_Matcher() + { + // Arrange + var mock = Mock.Of(); + IMixedProcessor processor = mock.Object; + + // Act + processor.Send("server-a", new byte[] { 1, 2, 3 }); + processor.Send("server-b", ReadOnlySpan.Empty); + processor.Send("server-a", new byte[] { 4, 5, 6 }); + + // Assert — verify by the string destination (non-ref-struct param) with RefStructArg.Any + mock.Send("server-a", RefStructArg>.Any).WasCalled(Times.Exactly(2)); + mock.Send("server-b", RefStructArg>.Any).WasCalled(Times.Once); + mock.Send(Arg.Any(), RefStructArg>.Any).WasCalled(Times.Exactly(3)); + await Assert.That(true).IsTrue(); + } + #endif } diff --git a/TUnit.Mocks/Arguments/RefStructArg.cs b/TUnit.Mocks/Arguments/RefStructArg.cs index 6e05229067..8abc5e54f9 100644 --- a/TUnit.Mocks/Arguments/RefStructArg.cs +++ b/TUnit.Mocks/Arguments/RefStructArg.cs @@ -7,6 +7,14 @@ namespace TUnit.Mocks.Arguments; /// Since ref struct types cannot be used as generic type arguments for , /// this type uses the allows ref struct anti-constraint (C# 13+) to accept them. /// +/// +/// Only matching is supported. Exact value matching and predicate matching are not +/// available for ref struct parameters because ref structs cannot be boxed or stored in closures. +/// +/// Ref struct parameters are excluded from the typed Callback, Returns, and Throws +/// delegate overloads because lambdas cannot capture ref struct values. Use the untyped +/// Action/Func<object?[], ...> overloads if you need to react to all arguments. +/// /// The ref struct parameter type. public readonly struct RefStructArg where T : allows ref struct { From 11924204057c294164d9753be8f7ac6152435dfe Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:26:41 +0000 Subject: [PATCH 7/7] docs(mocks): document RefStructArg in argument matchers page --- .../mocking/argument-matchers.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/docs/test-authoring/mocking/argument-matchers.md b/docs/docs/test-authoring/mocking/argument-matchers.md index f41fc10af2..84c39b80b3 100644 --- a/docs/docs/test-authoring/mocking/argument-matchers.md +++ b/docs/docs/test-authoring/mocking/argument-matchers.md @@ -25,6 +25,7 @@ Argument matchers control which calls a setup or verification matches. The same | `Arg.IsIn(values)` | Value in a set | | `Arg.IsNotIn(values)` | Value not in a set | | `Arg.Not(inner)` | Negation of another matcher | +| `RefStructArg.Any` | Any value of a ref struct type (.NET 9+) | ## Basic Matchers @@ -186,6 +187,60 @@ await Assert.That(nameArg.Values).HasCount().EqualTo(3); Capture works in both setup and verification contexts. Store the `Arg` in a variable, then inspect `.Values` or `.Latest` after exercising the code. ::: +## Ref Struct Parameters + +Regular `Arg` matchers cannot be used with ref struct types like `ReadOnlySpan` or `Span` because ref structs cannot be generic type arguments. On **.NET 9+**, TUnit.Mocks provides `RefStructArg` which uses the `allows ref struct` anti-constraint to make these parameters visible in the setup and verification API. + +:::note .NET 9+ Only +`RefStructArg` requires .NET 9 or later. On older target frameworks, ref struct parameters are excluded from the setup/verify API and all calls match regardless of the ref struct argument value. +::: + +### Matching Any Value + +Currently, `RefStructArg.Any` is the only supported matcher — it matches any value passed for that parameter: + +```csharp +public interface IBufferProcessor +{ + void Process(ReadOnlySpan data); + int Parse(ReadOnlySpan text); +} + +var mock = Mock.Of(); + +// Setup — ref struct param is visible in the API +mock.Process(RefStructArg>.Any).Callback(() => Console.WriteLine("called")); +mock.Parse(RefStructArg>.Any).Returns(42); + +// Verification +mock.Process(RefStructArg>.Any).WasCalled(Times.Once); +``` + +### Mixed Parameters + +When a method has both regular and ref struct parameters, use `Arg` for the regular ones and `RefStructArg` for the ref struct ones. Argument matching works on the regular parameters while the ref struct parameter matches any value: + +```csharp +public interface IMixedProcessor +{ + int Compute(int id, ReadOnlySpan data); +} + +var mock = Mock.Of(); + +// Match on 'id', accept any span value +mock.Compute(1, RefStructArg>.Any).Returns(100); +mock.Compute(2, RefStructArg>.Any).Returns(200); + +var result = mock.Object.Compute(1, new byte[] { 0xFF }); // returns 100 +``` + +### Limitations + +- **Only `.Any` matching** — exact value and predicate matching are not supported because ref struct values cannot be stored on the heap +- **No argument capture** — `RefStructArg` does not support `.Values` or `.Latest` like `Arg` does +- **Not available in typed callbacks** — ref struct parameters are excluded from the typed `Callback`/`Returns`/`Throws` delegate overloads (use the `Action` overload instead) + ## Custom Matchers Implement `IArgumentMatcher` for reusable matching logic: