Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ void M()
return VerifyGeneratorOutput(source);
}

[Test]
public Task Interface_With_Params_Array_Parameter()
{
var source = """
using TUnit.Mocks;

public interface IParamsSink
{
int Sum(params int[] values);
string Render(params object[] args);
string Combine(string prefix, params string[] parts);
T First<T>(params T[] items);
}

public class TestUsage
{
void M()
{
var mock = Mock.Of<IParamsSink>();
}
}
""";

return VerifyGeneratorOutput(source);
}

[Test]
public Task Multi_Method_Interface()
{
Expand Down

Large diffs are not rendered by default.

217 changes: 217 additions & 0 deletions TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,13 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null,
IsValueType = p.Type.IsValueType,
IsRefStruct = p.Type.IsRefLikeType,
SpanElementType = GetSpanElementType(p.Type)
SpanElementType = GetSpanElementType(p.Type),
IsParams = p.IsParams,
// Only single-dimensional arrays get the params-expanded setup overload;
// params collections (C# 13) and params spans degrade to whole-value matching.
ParamsElementType = p.IsParams && p.Type is IArrayTypeSymbol paramsArray
? paramsArray.ElementType.GetFullyQualifiedNameWithNullability()
: null
}).ToImmutableArray()
),
TypeParameters = new EquatableArray<MockTypeParameterModel>(
Expand Down
17 changes: 16 additions & 1 deletion TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ internal sealed record MockParameterModel : IEquatable<MockParameterModel>
/// </summary>
public bool IsNonSpanRefStruct => IsRefStruct && SpanElementType is null;

/// <summary>True when the parameter is declared with the <c>params</c> modifier.</summary>
public bool IsParams { get; init; }

/// <summary>
/// For <c>params T[]</c> parameters, the fully qualified element type with nullability (e.g.
/// "global::System.Object"). Null for non-params parameters and for params collections that
/// are not single-dimensional arrays. Used to emit the params-expanded setup overload that
/// accepts per-element <c>Arg&lt;T&gt;</c> matchers.
/// </summary>
public string? ParamsElementType { get; init; }

public bool Equals(MockParameterModel? other)
{
if (other is null) return false;
Expand All @@ -35,7 +46,9 @@ public bool Equals(MockParameterModel? other)
&& Direction == other.Direction
&& IsValueType == other.IsValueType
&& IsRefStruct == other.IsRefStruct
&& SpanElementType == other.SpanElementType;
&& SpanElementType == other.SpanElementType
&& IsParams == other.IsParams
&& ParamsElementType == other.ParamsElementType;
}

public override int GetHashCode()
Expand All @@ -49,6 +62,8 @@ public override int GetHashCode()
hash = hash * 31 + IsValueType.GetHashCode();
hash = hash * 31 + IsRefStruct.GetHashCode();
hash = hash * 31 + (SpanElementType?.GetHashCode() ?? 0);
hash = hash * 31 + IsParams.GetHashCode();
hash = hash * 31 + (ParamsElementType?.GetHashCode() ?? 0);
return hash;
}
}
Expand Down
113 changes: 113 additions & 0 deletions TUnit.Mocks.Tests/KitchenSinkInterfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ public interface IKitchenSink : IEnumerable<string>, IAltNamed

// ── Params array ──
int Sum(params int[] values);
string Join(params string[] parts);
string Render(params object[] args);
T First<T>(params T[] items);

// ── Tuple return ──
(int Count, string Label) Describe();
Expand Down Expand Up @@ -400,6 +403,116 @@ public async Task Params_Array_Method_Configurable()
mock.Sum(Any()).WasCalled(Times.Exactly(2));
}

[Test]
public async Task Params_PerElement_Matchers()
{
var mock = IKitchenSink.Mock();
mock.Sum(Is(1), Is(2), Is(3)).Returns(6);

await Assert.That(mock.Object.Sum(1, 2, 3)).IsEqualTo(6);
await Assert.That(mock.Object.Sum(1, 2, 9)).IsEqualTo(0);
await Assert.That(mock.Object.Sum(1, 2)).IsEqualTo(0);
}

[Test]
public async Task Params_PerElement_Mixed_Any()
{
var mock = IKitchenSink.Mock();
mock.Sum(Is(1), Any<int>()).Returns(9);

await Assert.That(mock.Object.Sum(1, 50)).IsEqualTo(9);
await Assert.That(mock.Object.Sum(2, 50)).IsEqualTo(0);
}

[Test]
public async Task Params_PerElement_Verification()
{
var mock = IKitchenSink.Mock();
mock.Object.Sum(1, 2);

mock.Sum(Is(1), Is(2)).WasCalled(Times.Once);
mock.Sum(Is(9), Is(9)).WasNeverCalled();
}

[Test]
public async Task Params_Empty_Setup_Matches_Empty_Call_Only()
{
var mock = IKitchenSink.Mock();
mock.Sum().Returns(42);

await Assert.That(mock.Object.Sum()).IsEqualTo(42);
await Assert.That(mock.Object.Sum(1)).IsEqualTo(0);
}

[Test]
public async Task Params_PerElement_Capture()
{
var mock = IKitchenSink.Mock();
var first = Any<int>();
mock.Sum(first, Any<int>()).Returns(0);

mock.Object.Sum(7, 9);

await Assert.That(first.Latest).IsEqualTo(7);
}

[Test]
public async Task Params_String_Elements_PerElement()
{
var mock = IKitchenSink.Mock();
mock.Join(Is("a"), Any<string>()).Returns("x");

await Assert.That(mock.Object.Join("a", "b")).IsEqualTo("x");
await Assert.That(mock.Object.Join("z", "b")).IsNotEqualTo("x");
}

[Test]
public async Task Params_Object_Elements_Raw_Values_And_Typed_Is()
{
var mock = IKitchenSink.Mock();
mock.Render(1, "two").Returns("matched");

await Assert.That(mock.Object.Render(1, "two")).IsEqualTo("matched");
await Assert.That(mock.Object.Render(2, "two")).IsNotEqualTo("matched");

var mock2 = IKitchenSink.Mock();
mock2.Render(Is<object>(1)).Returns("typed");

await Assert.That(mock2.Object.Render(1)).IsEqualTo("typed");
}

[Test]
public async Task Params_Object_Mistyped_Arg_Throws_Helpful_Error()
{
var mock = IKitchenSink.Mock();

// Is(1) is Arg<int>; converting it to the Arg<object> element slot would silently
// never match — the implicit conversion guard must throw instead.
var ex = Assert.Throws<ArgumentException>(() => mock.Render(Is(1)));

await Assert.That(ex.Message).Contains("Arg<Int32>");
}

[Test]
public async Task Params_Generic_PerElement()
{
var mock = IKitchenSink.Mock();
mock.First(Is(1), Any<int>()).Returns(5);

await Assert.That(mock.Object.First(1, 9)).IsEqualTo(5);
await Assert.That(mock.Object.First(2, 9)).IsEqualTo(0);
}

[Test]
public async Task Params_WholeArray_Matchers_Still_Work()
{
var mock = IKitchenSink.Mock();
mock.Sum(Is<int[]>(a => a is { Length: > 2 })).Returns(100);

await Assert.That(mock.Object.Sum(1, 2, 3)).IsEqualTo(100);
await Assert.That(mock.Object.Sum(1, 2)).IsEqualTo(0);
}

// ── Tuple return ──

[Test]
Expand Down
17 changes: 16 additions & 1 deletion TUnit.Mocks/Arguments/ArgOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,22 @@ public Arg(IArgumentMatcher matcher)

/// <summary>Implicitly converts a raw value to an <see cref="Arg{T}"/> using exact equality matching.</summary>
/// <param name="value">The value to match against.</param>
public static implicit operator Arg<T>(T value) => new(new ExactMatcher<T>(value));
public static implicit operator Arg<T>(T value)
{
// C# permits Arg<X> -> Arg<T> via boxing + this operator when X boxes to T (e.g. Is(1)
// passed to an Arg<object> slot). That would create an exact matcher on the boxed Arg
// struct itself, which can never match — fail fast with guidance instead.
if (value is not null && value.GetType() is { IsConstructedGenericType: true } valueType
&& valueType.GetGenericTypeDefinition() == typeof(Arg<>))
{
throw new ArgumentException(
$"An Arg<{valueType.GetGenericArguments()[0].Name}> matcher was passed where Arg<{typeof(T).Name}> was expected, " +
$"so it would be matched as a literal value and never succeed. " +
$"Use Arg.Is<{typeof(T).Name}>(value), Arg.Any<{typeof(T).Name}>(), or pass the raw value instead.");
}

return new(new ExactMatcher<T>(value));
}

/// <summary>Implicitly converts a predicate to an <see cref="Arg{T}"/> using predicate matching.</summary>
/// <param name="predicate">The predicate that determines whether an argument matches.</param>
Expand Down
65 changes: 65 additions & 0 deletions TUnit.Mocks/Matchers/ParamsArrayMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.ComponentModel;
using TUnit.Mocks.Arguments;

namespace TUnit.Mocks.Matchers;

/// <summary>
/// Composite matcher for <c>params T[]</c> parameters: matches the packed argument array
/// element-by-element against the per-element matchers supplied at the setup call site.
/// Occupies a single top-level matcher slot so setup arity stays equal to the declared
/// parameter count. Public for generated code access. Not intended for direct use.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class ParamsArrayMatcher : IArgumentMatcher, ICapturingMatcher
{
private readonly IArgumentMatcher[] _elementMatchers;

/// <summary>Creates a matcher from per-element matchers. Public for generated code access.</summary>
public ParamsArrayMatcher(IArgumentMatcher[] elementMatchers)
{
_elementMatchers = elementMatchers ?? throw new ArgumentNullException(nameof(elementMatchers));
}

public bool Matches(object? value)
{
// The packed argument is the concrete array type (e.g. int[] for params int[]),
// not object?[] — match via System.Array so value-element params work.
if (value is not Array array || array.Length != _elementMatchers.Length)
{
return false;
}

for (var i = 0; i < _elementMatchers.Length; i++)
{
if (!_elementMatchers[i].Matches(array.GetValue(i)))
{
return false;
}
}

return true;
}

/// <summary>
/// Forwards deferred capture to each element matcher so per-element
/// <see cref="Arg{T}.Values"/>/<see cref="Arg{T}.Latest"/> still work.
/// <see cref="Setup.MethodSetup"/> only walks top-level matchers.
/// </summary>
void ICapturingMatcher.ApplyCapture(object? value)
{
if (value is not Array array || array.Length != _elementMatchers.Length)
{
return;
}

for (var i = 0; i < _elementMatchers.Length; i++)
{
if (_elementMatchers[i] is ICapturingMatcher capturing)
{
capturing.ApplyCapture(array.GetValue(i));
}
}
}

public string Describe() => $"[{string.Join(", ", _elementMatchers.Select(m => m.Describe()))}]";
}
38 changes: 38 additions & 0 deletions docs/docs/writing-tests/mocking/argument-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,44 @@ await Assert.That(nameArg.Values).Count().IsEqualTo(3);
Capture works in both setup and verification contexts. Store the `Arg<T>` in a variable, then inspect `.Values` or `.Latest` after exercising the code.
:::

## Params Array Parameters

Methods with a `params T[]` parameter support two matching styles: per-element and whole-array.

```csharp
public interface ICalculator
{
int Sum(params int[] values);
}
```

**Per-element** — pass one matcher per expected element. The setup matches only calls with exactly that many arguments, each satisfying its matcher:

```csharp
mock.Sum(Is(1), Is(2), Is(3)).Returns(6); // matches Sum(1, 2, 3) only
mock.Sum(Is(1), Any<int>()).Returns(9); // matches Sum(1, <anything>)
mock.Sum().Returns(0); // matches Sum() — zero arguments
mock.Sum(1, 2).Returns(3); // raw values work too (exact match)
```

**Whole-array** — pass a single matcher for the packed array:

```csharp
mock.Sum(Any()).Returns(100); // any number of arguments, including none
mock.Sum(Is<int[]>(a => a is { Length: > 2 })).Returns(7); // predicate over the whole array
```

Both styles work for verification as well:

```csharp
mock.Sum(Is(1), Is(2)).WasCalled(Times.Once);
mock.Sum(Any()).WasCalled(Times.Exactly(2));
```

:::tip params object[]
For `params object[]` parameters, use raw values (`mock.Log(1, "two")`) or a typed matcher (`Is<object>(1)`). A bare `Is(1)` creates an `Arg<int>`, which cannot stand in for an `Arg<object>` element — TUnit.Mocks throws a descriptive error at setup time if you try.
:::

## Ref Struct Parameters

Regular `Arg<T>` matchers cannot be used with ref struct types like `ReadOnlySpan<T>` or `Span<T>` because ref structs cannot be generic type arguments. On **.NET 9+**, TUnit.Mocks provides `RefStructArg<T>` which uses the `allows ref struct` anti-constraint to make these parameters visible in the setup and verification API.
Expand Down
Loading