diff --git a/.gitignore b/.gitignore index ef3a9cc5..6ef776ae 100644 --- a/.gitignore +++ b/.gitignore @@ -308,3 +308,7 @@ docs/_site/* # Ignore Ionide files (https://ionide.io/) .ionide + +# Ignore mergetool temp files +*.orig + diff --git a/CHANGELOG.md b/CHANGELOG.md index 563ed626..5bae2778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### 6.x (work in progress) +* [NEW] `ArgMatchers.Matching` predicate matcher as an alternative to `Is(Expression>`. (.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 diff --git a/docs/help/argument-matchers/index.md b/docs/help/argument-matchers/index.md index 224cbaa6..f9124af2 100644 --- a/docs/help/argument-matchers/index.md +++ b/docs/help/argument-matchers/index.md @@ -67,6 +67,7 @@ formatter.DidNotReceive().Format(Arg.Any()); ``` ## Conditionally matching an argument + An argument of type `T` can be conditionally matched using `Arg.Is(Predicate condition)`. ```csharp @@ -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(x => x < 0))); +//Received call with first arg 1 and second arg of -2, -5, or -10: +calculator + .Received() + .Add(1, Arg.Is(Matching(x => new[] {-2,-5,-10}.Contains(x)))); +//Did not receive call with first arg greater than 10: +calculator.DidNotReceive().Add(Arg.Is(Matching(x => x > 10)), -10); + +#endif +``` + + ## Matching a specific argument An argument of type `T` can be matched using `Arg.Is(T value)`. @@ -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` 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)`. + +For example: + +```csharp +class GreaterThanMatcher(T value) : + IDescribeNonMatches, IDescribeSpecification, IArgumentMatcher + where T : IComparable { + + public string DescribeFor(object argument) => $"{argument} ≯ {value}"; + public string DescribeSpecification() => $">{value}"; + public bool IsSatisfiedBy(T argument) => argument.CompareTo(value) > 0; +} + +public static IArgumentMatcher GreaterThan(T value) where T : IComparable => + new GreaterThanMatcher(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. diff --git a/src/NSubstitute/Arg.cs b/src/NSubstitute/Arg.cs index 83f36933..ec6c7c9a 100644 --- a/src/NSubstitute/Arg.cs +++ b/src/NSubstitute/Arg.cs @@ -53,6 +53,18 @@ public static ref T Is(Expression> predicate) where T : An return ref ArgumentMatcher.Enqueue(new ExpressionArgumentMatcher(predicate)); } + /// + /// Match argument that satisfies . + /// + public static ref T Is(IArgumentMatcher matcher) => + ref ArgumentMatcher.Enqueue(matcher); + + /// + /// Match argument that satisfies . + /// + public static ref T Is(IArgumentMatcher matcher) => + ref ArgumentMatcher.Enqueue(matcher); + /// /// Invoke any argument whenever a matching call is made to the substitute. /// diff --git a/src/NSubstitute/ArgMatchers.cs b/src/NSubstitute/ArgMatchers.cs new file mode 100644 index 00000000..6a9dade5 --- /dev/null +++ b/src/NSubstitute/ArgMatchers.cs @@ -0,0 +1,38 @@ +using NSubstitute.Core.Arguments; + +// Disable nullability for client API, so it does not affect clients. +#nullable disable annotations + +namespace NSubstitute; + +/// +/// Argument matchers for use with . +/// +public static class ArgMatchers +{ + public static IArgumentMatcher EqualTo(T value) => new TypedEqualsArgumentMatcher(value); + + public static IArgumentMatcher Any() => new AnyArgumentMatcher(typeof(T)); + + +#if NET6_0_OR_GREATER + /// + /// Match argument that satisfies . + /// If the throws an exception for an argument it will be treated as non-matching. + /// + public static IArgumentMatcher Matching( + Predicate predicate, + [System.Runtime.CompilerServices.CallerArgumentExpression("predicate")] + string predicateDescription = "" + ) => + new PredicateArgumentMatcher(predicate, predicateDescription); + + // See https://github.com/nsubstitute/NSubstitute/issues/822 + private class PredicateArgumentMatcher(Predicate predicate, string predicateDescription) : IArgumentMatcher + { + public bool IsSatisfiedBy(T argument) => predicate(argument!); + + public override string ToString() => predicateDescription; + } +#endif +} \ No newline at end of file diff --git a/src/NSubstitute/Compatibility/Arg.Compat.cs b/src/NSubstitute/Compatibility/Arg.Compat.cs index 949b0697..632df202 100644 --- a/src/NSubstitute/Compatibility/Arg.Compat.cs +++ b/src/NSubstitute/Compatibility/Arg.Compat.cs @@ -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 @@ -48,6 +49,16 @@ public static class Compat /// public static AnyType Is(Expression> predicate) where T : AnyType => Arg.Is(predicate); + /// + /// Match argument that satisfies . + /// + public static T Is(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue(matcher); + + /// + /// Match argument that satisfies . + /// + public static T Is(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue(matcher); + /// /// Invoke any argument whenever a matching call is made to the substitute. /// This is provided for compatibility with older compilers -- @@ -95,7 +106,7 @@ public static class Compat /// Capture any argument compatible with type and use it to call the function /// whenever a matching call is made to the substitute. /// This is provided for compatibility with older compilers -- - /// if possible use instead. + /// if possible use instead. /// public static T Do(Action useArgument) => Arg.Do(useArgument); diff --git a/src/NSubstitute/Compatibility/CompatArg.cs b/src/NSubstitute/Compatibility/CompatArg.cs index 0d10b70b..4644f0ec 100644 --- a/src/NSubstitute/Compatibility/CompatArg.cs +++ b/src/NSubstitute/Compatibility/CompatArg.cs @@ -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 @@ -57,6 +58,16 @@ private CompatArg() { } /// public Arg.AnyType Is(Expression> predicate) where T : Arg.AnyType => Arg.Is(predicate); + /// + /// Match argument that satisfies . + /// + public static T Is(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue(matcher); + + /// + /// Match argument that satisfies . + /// + public static T Is(IArgumentMatcher matcher) => ArgumentMatcher.Enqueue(matcher); + /// /// Invoke any argument whenever a matching call is made to the substitute. /// This is provided for compatibility with older compilers -- diff --git a/src/NSubstitute/Core/Arguments/AnyArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/AnyArgumentMatcher.cs index 82b083d0..f23d09dd 100644 --- a/src/NSubstitute/Core/Arguments/AnyArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/AnyArgumentMatcher.cs @@ -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); -} \ No newline at end of file + + public string DescribeFor(object? argument) => + argument?.GetType().GetNonMangledTypeName() ?? "" + " is not a " + typeArgMustBeCompatibleWith.GetNonMangledTypeName(); + + public string DescribeSpecification() => ToString(); +} diff --git a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs index 1b62dea7..55d8eb0a 100644 --- a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using NSubstitute.Exceptions; namespace NSubstitute.Core.Arguments; @@ -53,6 +54,11 @@ public GenericToNonGenericMatcherProxyWithDescribe(IArgumentMatcher 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 diff --git a/src/NSubstitute/Core/Arguments/EqualsArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/EqualsArgumentMatcher.cs index ef6ed4a5..2cdf6ff9 100644 --- a/src/NSubstitute/Core/Arguments/EqualsArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/EqualsArgumentMatcher.cs @@ -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.Default.Equals(value, argument); +} + +public class TypedEqualsArgumentMatcher(T? value) : IArgumentMatcher +{ + public override string ToString() => ArgumentFormatter.Default.Format(value, false); + + public bool IsSatisfiedBy(T? argument) => EqualityComparer.Default.Equals(argument, value); } \ No newline at end of file diff --git a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs index b43ef848..9712836b 100644 --- a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs +++ b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs @@ -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; @@ -12,6 +14,12 @@ public class ArgumentMatching { private ISomething _something; + [SetUp] + public void SetUp() + { + _something = Substitute.For(); + } + [Test] public void Return_result_for_any_argument() { @@ -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(); - } - public interface IMyService { void MyMethod(IMyArgument argument); @@ -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(() => + _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 { public string DescribeFor(object argument) => "failed"; @@ -956,4 +973,39 @@ public override int MethodWithOutParameter(int arg1, out int arg2) return 2; } } + +#if NET6_0_OR_GREATER + /// + /// See https://github.com/nsubstitute/NSubstitute/issues/822. + /// + [Test] + public void Predicate_match() + { + _something.Say("hello"); + + _something.Received().Say(Arg.Is(Matching(x => x?.Length > 0))); + + var exception = Assert.Throws(() => + _something.Received().Say(Arg.Is(Matching(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 GreaterThan(T value) where T : IComparable => + new GreaterThanMatcher(value); + + private class GreaterThanMatcher(T value) : + IDescribeNonMatches, IDescribeSpecification, IArgumentMatcher + where T : IComparable + { + public string DescribeFor(object argument) => $"{argument} ≯ {value}"; + + public string DescribeSpecification() => $">{value}"; + + public bool IsSatisfiedBy(T argument) => argument.CompareTo(value) > 0; + } +} \ No newline at end of file diff --git a/tests/NSubstitute.Documentation.Tests.Generator/DocumentationTestsGenerator.cs b/tests/NSubstitute.Documentation.Tests.Generator/DocumentationTestsGenerator.cs index 18c665e3..f3530c7a 100644 --- a/tests/NSubstitute.Documentation.Tests.Generator/DocumentationTestsGenerator.cs +++ b/tests/NSubstitute.Documentation.Tests.Generator/DocumentationTestsGenerator.cs @@ -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;