Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,7 @@ docs/_site/*

# Ignore Ionide files (https://ionide.io/)
.ionide

# Ignore mergetool temp files
*.orig

2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
### 6.x (work in progress)

* [NEW] `ArgMatchers.Matching` predicate matcher as an alternative to `Is(Expression<Predicate<T>>`. (.NET6 and above.)
* [UPDATE] Improved support for custom argument matchers. `Arg.Is` now accepts arg matchers.
* [UPDATE][BREAKING] Update target frameworks: .NET8, .NET Standard 2.0
* [UPDATE] Drop EOL .NET 6/7 platforms from testing matrix
* [UPDATE] Update github actions steps versions
Expand Down
65 changes: 65 additions & 0 deletions docs/help/argument-matchers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ formatter.DidNotReceive().Format(Arg.Any<int>());
```

## Conditionally matching an argument

An argument of type `T` can be conditionally matched using `Arg.Is<T>(Predicate<T> condition)`.

```csharp
Expand Down Expand Up @@ -94,6 +95,27 @@ Assert.That(formatter.Format("not matched, too long"), Is.Not.EqualTo("matched")
Assert.That(formatter.Format(null), Is.Not.EqualTo("matched"));
```

_[Since v6.0; .NET6 and above]_ An argument of type `T` can also be conditionally matched using `ArgMatchers.Matching`.

```csharp
#if NET6_0_OR_GREATER

// With `using static NSubstitute.ArgMatchers`
calculator.Add(1, -10);

//Received call with first arg 1 and second arg less than 0:
calculator.Received().Add(1, Arg.Is(Matching<int>(x => x < 0)));
//Received call with first arg 1 and second arg of -2, -5, or -10:
calculator
.Received()
.Add(1, Arg.Is(Matching<int>(x => new[] {-2,-5,-10}.Contains(x))));
//Did not receive call with first arg greater than 10:
calculator.DidNotReceive().Add(Arg.Is(Matching<int>(x => x > 10)), -10);

#endif
```


## Matching a specific argument
An argument of type `T` can be matched using `Arg.Is<T>(T value)`.

Expand Down Expand Up @@ -127,6 +149,49 @@ Assert.That(memoryValue, Is.EqualTo(42));

See [Setting out and ref args](/help/setting-out-and-ref-arguments/) for more information on working with `out` and `ref`.

## Custom argument matchers

_[Since v6.0]_

Custom argument matching logic can be provided by implementing the `IArgumentMatcher<T>` interface in the `NSubstitute.Core.Arguments` namespace. Ideally custom matchers should also implement `NSubstitute.Core.IDescribeSpecification`, which explains what conditions an argument needs to meet to match the required condition, and `NSubstitute.Core.IDescribeNonMatches`, which provides an explanation about why a specific argument does not match.

Custom argument matchers can be used via `Arg.Is(IArgumentMatcher<T>)`.

For example:

```csharp
class GreaterThanMatcher<T>(T value) :
IDescribeNonMatches, IDescribeSpecification, IArgumentMatcher<T>
where T : IComparable<T> {

public string DescribeFor(object argument) => $"{argument} ≯ {value}";
public string DescribeSpecification() => $">{value}";
public bool IsSatisfiedBy(T argument) => argument.CompareTo(value) > 0;
}

public static IArgumentMatcher<T> GreaterThan<T>(T value) where T : IComparable<T> =>
new GreaterThanMatcher<T>(value);

[Test]
public void AddGreaterThan() {
calculator.Add(1, 20);
calculator.Received().Add(1, Arg.Is(GreaterThan(10)));
}
```

If the `GreaterThan` matcher fails, we get a message like:

```
NSubstitute.Exceptions.ReceivedCallsException : Expected to receive a call matching:
Add(1, >10)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
Add(1, *2*)
arg[1]: 2 ≯ 10
```

The `Add(1, >10)` part of the message uses `IDescribeSpecification`, while the `arg[1]: 2 ≯ 10` line is build from `IDescribeNonMatchers`.

## How NOT to use argument matchers

Occasionally argument matchers get used in ways that cause unexpected results for people. Here are the most common ones.
Expand Down
12 changes: 12 additions & 0 deletions src/NSubstitute/Arg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ public static ref T Is<T>(Expression<Predicate<object?>> predicate) where T : An
return ref ArgumentMatcher.Enqueue<T>(new ExpressionArgumentMatcher<object>(predicate));
}

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static ref T Is<T>(IArgumentMatcher matcher) =>
ref ArgumentMatcher.Enqueue<T>(matcher);

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static ref T Is<T>(IArgumentMatcher<T> matcher) =>
ref ArgumentMatcher.Enqueue(matcher);

/// <summary>
/// Invoke any <see cref="Action"/> argument whenever a matching call is made to the substitute.
/// </summary>
Expand Down
38 changes: 38 additions & 0 deletions src/NSubstitute/ArgMatchers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using NSubstitute.Core.Arguments;

// Disable nullability for client API, so it does not affect clients.
#nullable disable annotations

namespace NSubstitute;

/// <summary>
/// Argument matchers for use with <see cref="Arg.Is{T}(IArgumentMatcher{T})"/>.
/// </summary>
public static class ArgMatchers
{
public static IArgumentMatcher<T> EqualTo<T>(T value) => new TypedEqualsArgumentMatcher<T>(value);

public static IArgumentMatcher Any<T>() => new AnyArgumentMatcher(typeof(T));


#if NET6_0_OR_GREATER
/// <summary>
/// Match argument that satisfies <paramref name="predicate"/>.
/// If the <paramref name="predicate"/> throws an exception for an argument it will be treated as non-matching.
/// </summary>
public static IArgumentMatcher<T> Matching<T>(
Predicate<T> predicate,
[System.Runtime.CompilerServices.CallerArgumentExpression("predicate")]
string predicateDescription = ""
) =>
new PredicateArgumentMatcher<T>(predicate, predicateDescription);

// See https://github.com/nsubstitute/NSubstitute/issues/822
private class PredicateArgumentMatcher<T>(Predicate<T> predicate, string predicateDescription) : IArgumentMatcher<T>
{
public bool IsSatisfiedBy(T argument) => predicate(argument!);

public override string ToString() => predicateDescription;
}
#endif
}
13 changes: 12 additions & 1 deletion src/NSubstitute/Compatibility/Arg.Compat.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
using NSubstitute.Core.Arguments;

// Disable nullability for client API, so it does not affect clients.
#nullable disable annotations
Expand Down Expand Up @@ -48,6 +49,16 @@ public static class Compat
/// </summary>
public static AnyType Is<T>(Expression<Predicate<object>> predicate) where T : AnyType => Arg.Is<T>(predicate);

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static T Is<T>(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue<T>(matcher);

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static T Is<T>(IArgumentMatcher<T> matcher) => ArgumentMatcher.Enqueue(matcher);

/// <summary>
/// Invoke any <see cref="Action"/> argument whenever a matching call is made to the substitute.
/// This is provided for compatibility with older compilers --
Expand Down Expand Up @@ -95,7 +106,7 @@ public static class Compat
/// Capture any argument compatible with type <typeparamref name="T"/> and use it to call the <paramref name="useArgument"/> function
/// whenever a matching call is made to the substitute.
/// This is provided for compatibility with older compilers --
/// if possible use <see cref="Arg.Do{T}" /> instead.
/// if possible use <see cref="Arg.Do{T}(System.Action{T})" /> instead.
/// </summary>
public static T Do<T>(Action<T> useArgument) => Arg.Do<T>(useArgument);

Expand Down
11 changes: 11 additions & 0 deletions src/NSubstitute/Compatibility/CompatArg.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
using NSubstitute.Core.Arguments;

// Disable nullability for client API, so it does not affect clients.
#nullable disable annotations
Expand Down Expand Up @@ -57,6 +58,16 @@ private CompatArg() { }
/// </summary>
public Arg.AnyType Is<T>(Expression<Predicate<object>> predicate) where T : Arg.AnyType => Arg.Is<T>(predicate);

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static T Is<T>(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue<T>(matcher);

/// <summary>
/// Match argument that satisfies <paramref name="matcher"/>.
/// </summary>
public static T Is<T>(IArgumentMatcher<T> matcher) => ArgumentMatcher.Enqueue(matcher);

/// <summary>
/// Invoke any <see cref="Action"/> argument whenever a matching call is made to the substitute.
/// This is provided for compatibility with older compilers --
Expand Down
10 changes: 8 additions & 2 deletions src/NSubstitute/Core/Arguments/AnyArgumentMatcher.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
namespace NSubstitute.Core.Arguments;

public class AnyArgumentMatcher(Type typeArgMustBeCompatibleWith) : IArgumentMatcher
public class AnyArgumentMatcher(Type typeArgMustBeCompatibleWith)
: IArgumentMatcher, IDescribeSpecification, IDescribeNonMatches
{
public override string ToString() => "any " + typeArgMustBeCompatibleWith.GetNonMangledTypeName();

public bool IsSatisfiedBy(object? argument) => argument.IsCompatibleWith(typeArgMustBeCompatibleWith);
}

public string DescribeFor(object? argument) =>
argument?.GetType().GetNonMangledTypeName() ?? "<null>" + " is not a " + typeArgMustBeCompatibleWith.GetNonMangledTypeName();

public string DescribeSpecification() => ToString();
}
6 changes: 6 additions & 0 deletions src/NSubstitute/Core/Arguments/ArgumentMatcher.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using NSubstitute.Exceptions;

namespace NSubstitute.Core.Arguments;
Expand Down Expand Up @@ -53,6 +54,11 @@ public GenericToNonGenericMatcherProxyWithDescribe(IArgumentMatcher<T> matcher)
}

public string DescribeFor(object? argument) => ((IDescribeNonMatches)_matcher).DescribeFor(argument);

public override string ToString() =>
_matcher is IDescribeSpecification describe
? describe.DescribeSpecification() ?? string.Empty
: _matcher.ToString() ?? string.Empty;
}

private class DefaultValueContainer<T>
Expand Down
7 changes: 7 additions & 0 deletions src/NSubstitute/Core/Arguments/EqualsArgumentMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@ public class EqualsArgumentMatcher(object? value) : IArgumentMatcher
public override string ToString() => ArgumentFormatter.Default.Format(value, false);

public bool IsSatisfiedBy(object? argument) => EqualityComparer<object>.Default.Equals(value, argument);
}

public class TypedEqualsArgumentMatcher<T>(T? value) : IArgumentMatcher<T>
{
public override string ToString() => ArgumentFormatter.Default.Format(value, false);

public bool IsSatisfiedBy(T? argument) => EqualityComparer<T>.Default.Equals(argument, value);
}
64 changes: 58 additions & 6 deletions tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using NSubstitute.Exceptions;
using NSubstitute.Extensions;
using NUnit.Framework;
using static NSubstitute.Acceptance.Specs.Extensions;
using static NSubstitute.ArgMatchers;

namespace NSubstitute.Acceptance.Specs;

Expand All @@ -12,6 +14,12 @@ public class ArgumentMatching
{
private ISomething _something;

[SetUp]
public void SetUp()
{
_something = Substitute.For<ISomething>();
}

[Test]
public void Return_result_for_any_argument()
{
Expand Down Expand Up @@ -866,12 +874,6 @@ public void Does_support_out_method_with_base_override()
Assert.That(outArg, Is.EqualTo(4));
}

[SetUp]
public void SetUp()
{
_something = Substitute.For<ISomething>();
}

public interface IMyService
{
void MyMethod<T>(IMyArgument<T> argument);
Expand Down Expand Up @@ -919,6 +921,21 @@ public void Should_use_empty_string_for_null_describe_spec_for_custom_arg_matche
Assert.That(ex.Message, Contains.Substring("Add(23, )"));
}

[Test]
public void Custom_arg_matcher_support()
{
_something.Add(1, 2);

_something.Received().Add(1, Arg.Is(GreaterThan(0)));

var exception = Assert.Throws<ReceivedCallsException>(() =>
_something.Received().Add(1, Arg.Is(GreaterThan(3))));

Assert.That(exception.Message, Contains.Substring("Add(1, >3)"));
Assert.That(exception.Message, Contains.Substring("Add(1, *2*)"));
Assert.That(exception.Message, Contains.Substring("arg[1]: 2 \u226f 3"));
}

class CustomMatcher : IArgumentMatcher, IDescribeNonMatches, IArgumentMatcher<int>
{
public string DescribeFor(object argument) => "failed";
Expand Down Expand Up @@ -956,4 +973,39 @@ public override int MethodWithOutParameter(int arg1, out int arg2)
return 2;
}
}

#if NET6_0_OR_GREATER
/// <summary>
/// See https://github.com/nsubstitute/NSubstitute/issues/822.
/// </summary>
[Test]
public void Predicate_match()
{
_something.Say("hello");

_something.Received().Say(Arg.Is(Matching<string>(x => x?.Length > 0)));

var exception = Assert.Throws<ReceivedCallsException>(() =>
_something.Received().Say(Arg.Is(Matching<string>(x => x?.Length > 10))));
Assert.That(exception.Message, Contains.Substring("Say(x => x?.Length > 10)"));
Assert.That(exception.Message, Contains.Substring("Say(*\"hello\"*)"));
}
#endif
}

static class Extensions
{
public static IArgumentMatcher<T> GreaterThan<T>(T value) where T : IComparable<T> =>
new GreaterThanMatcher<T>(value);

private class GreaterThanMatcher<T>(T value) :
IDescribeNonMatches, IDescribeSpecification, IArgumentMatcher<T>
where T : IComparable<T>
{
public string DescribeFor(object argument) => $"{argument} ≯ {value}";

public string DescribeSpecification() => $">{value}";

public bool IsSatisfiedBy(T argument) => argument.CompareTo(value) > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ private static string GenerateTestClassContent(string testsClassName, Additional
$$"""
using NUnit.Framework;
using System.ComponentModel;
using NSubstitute.Core;
using NSubstitute.Core.Arguments;
using NSubstitute.Extensions;
using NSubstitute.ExceptionExtensions;
using static NSubstitute.ArgMatchers;

namespace NSubstitute.Documentation.Tests.Generated;

Expand Down
Loading