From 4460556549c49f6cbc3ea9098fe5b98bfc83f8c2 Mon Sep 17 00:00:00 2001 From: David Tchepak Date: Sun, 5 May 2024 14:17:12 +1000 Subject: [PATCH] Improve output for expected argument matchers - Add IDescribeSpecification to allow custom arg matchers to provide custom output for "expected to receive" entries. - Fallback to ToString when IDescribeSpecification not implemented. - Update code comment docs accordingly. Relates to #796. --- .../Core/Arguments/ArgumentMatcher.cs | 6 ++ .../Arguments/ArgumentSpecification.cs.orig | 88 +++++++++++++++++++ .../Core/Arguments/IArgumentMatcher.cs | 12 ++- src/NSubstitute/Core/CallSpecification.cs | 4 +- src/NSubstitute/Core/IDescribeNonMatches.cs | 7 +- .../Core/IDescribeSpecification.cs | 16 ++++ .../ArgumentMatching.cs | 36 +++++++- 7 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 src/NSubstitute/Core/Arguments/ArgumentSpecification.cs.orig create mode 100644 src/NSubstitute/Core/IDescribeSpecification.cs diff --git a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs index 2695a2cf..0fea577d 100644 --- a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs @@ -38,6 +38,9 @@ private class GenericToNonGenericMatcherProxy(IArgumentMatcher matcher) : protected readonly IArgumentMatcher _matcher = matcher; public bool IsSatisfiedBy(object? argument) => _matcher.IsSatisfiedBy((T?)argument!); + + public override string ToString() => + (_matcher as IDescribeSpecification)?.DescribeSpecification() ?? _matcher.ToString() ?? ""; } private class GenericToNonGenericMatcherProxyWithDescribe : GenericToNonGenericMatcherProxy, IDescribeNonMatches @@ -48,6 +51,9 @@ public GenericToNonGenericMatcherProxyWithDescribe(IArgumentMatcher matcher) } public string DescribeFor(object? argument) => ((IDescribeNonMatches)_matcher).DescribeFor(argument); + + public override string ToString() => + (_matcher as IDescribeSpecification)?.DescribeSpecification() ?? _matcher.ToString() ?? ""; } private class DefaultValueContainer diff --git a/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs.orig b/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs.orig new file mode 100644 index 00000000..51a8d728 --- /dev/null +++ b/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs.orig @@ -0,0 +1,88 @@ +namespace NSubstitute.Core.Arguments; + +public class ArgumentSpecification(Type forType, IArgumentMatcher matcher, Action action) : IArgumentSpecification +{ + private static readonly Action NoOpAction = _ => { }; + public Type ForType { get; } = forType; + public bool HasAction => action != NoOpAction; + + public ArgumentSpecification(Type forType, IArgumentMatcher matcher) : this(forType, matcher, NoOpAction) { } + + public bool IsSatisfiedBy(object? argument) + { + if (!IsCompatibleWith(argument)) + { + return false; + } + + try + { + return matcher.IsSatisfiedBy(argument); + } + catch + { + return false; + } + } + + public string DescribeNonMatch(object? argument) + { + if (!IsCompatibleWith(argument)) + { + return GetIncompatibleTypeMessage(argument); + } + + return matcher is IDescribeNonMatches describe + ? describe.DescribeFor(argument) + : string.Empty; + } + + public string FormatArgument(object? argument) + { + var isSatisfiedByArg = IsSatisfiedBy(argument); + + return matcher is IArgumentFormatter matcherFormatter + ? matcherFormatter.Format(argument, highlight: !isSatisfiedByArg) + : ArgumentFormatter.Default.Format(argument, highlight: !isSatisfiedByArg); + } + +<<<<<<< HEAD + public override string ToString() => matcher.ToString() ?? string.Empty; +======= + public override string ToString() => + _matcher is IDescribeSpecification describe + ? describe.DescribeSpecification() + : _matcher.ToString() ?? string.Empty; +>>>>>>> aa3cd12 (Apply review comments) + + public IArgumentSpecification CreateCopyMatchingAnyArgOfType(Type requiredType) + { + // Don't pass RunActionIfTypeIsCompatible method if no action is present. + // Otherwise, unnecessary closure will keep reference to this and will keep it alive. + return new ArgumentSpecification( + requiredType, + new AnyArgumentMatcher(requiredType), + action == NoOpAction ? NoOpAction : RunActionIfTypeIsCompatible); + } + + public void RunAction(object? argument) + { + action(argument); + } + + private void RunActionIfTypeIsCompatible(object? argument) + { + if (argument.IsCompatibleWith(ForType)) + { + action(argument); + } + } + + private bool IsCompatibleWith(object? argument) => argument.IsCompatibleWith(ForType); + + private string GetIncompatibleTypeMessage(object? argument) + { + var argumentType = argument == null ? typeof(object) : argument.GetType(); + return $"Expected an argument compatible with type '{ForType}'. Actual type was '{argumentType}'."; + } +} \ No newline at end of file diff --git a/src/NSubstitute/Core/Arguments/IArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/IArgumentMatcher.cs index b0000d89..e0a0f530 100644 --- a/src/NSubstitute/Core/Arguments/IArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/IArgumentMatcher.cs @@ -1,8 +1,10 @@ namespace NSubstitute.Core.Arguments; /// -/// Provides a specification for arguments for use with . -/// Can additionally implement to give descriptions when arguments do not match. +/// Provides a specification for arguments. +/// Can implement to give descriptions when arguments do not match. +/// Can implement to give descriptions of expected arguments (otherwise +/// `ToString()` will be used for descriptions). /// public interface IArgumentMatcher { @@ -14,8 +16,10 @@ public interface IArgumentMatcher } /// -/// Provides a specification for arguments for use with . -/// Can additionally implement to give descriptions when arguments do not match. +/// Provides a specification for arguments. +/// Can implement to give descriptions when arguments do not match. +/// Can implement to give descriptions of expected arguments (otherwise +/// `ToString()` will be used for descriptions). /// /// Matches arguments of type or compatible type. public interface IArgumentMatcher diff --git a/src/NSubstitute/Core/CallSpecification.cs b/src/NSubstitute/Core/CallSpecification.cs index dce5c7b9..d08b0232 100644 --- a/src/NSubstitute/Core/CallSpecification.cs +++ b/src/NSubstitute/Core/CallSpecification.cs @@ -121,7 +121,9 @@ public IEnumerable NonMatchingArguments(ICall call) public override string ToString() { - var argSpecsAsStrings = _argumentSpecifications.Select(x => x.ToString() ?? string.Empty).ToArray(); + var argSpecsAsStrings = _argumentSpecifications.Select(x => + (x as IDescribeSpecification)?.DescribeSpecification() ?? x.ToString() ?? string.Empty + ).ToArray(); return CallFormatter.Default.Format(GetMethodInfo(), argSpecsAsStrings); } diff --git a/src/NSubstitute/Core/IDescribeNonMatches.cs b/src/NSubstitute/Core/IDescribeNonMatches.cs index d8ba00aa..94814ce6 100644 --- a/src/NSubstitute/Core/IDescribeNonMatches.cs +++ b/src/NSubstitute/Core/IDescribeNonMatches.cs @@ -1,5 +1,10 @@ namespace NSubstitute.Core; +/// +/// A type that can describe how an argument does not match a required condition. +/// Use in conjunction with to provide information about +/// non-matches. +/// public interface IDescribeNonMatches { /// @@ -9,4 +14,4 @@ public interface IDescribeNonMatches /// /// Description of the non-match, or if no description can be provided. string DescribeFor(object? argument); -} \ No newline at end of file +} diff --git a/src/NSubstitute/Core/IDescribeSpecification.cs b/src/NSubstitute/Core/IDescribeSpecification.cs new file mode 100644 index 00000000..dfee9620 --- /dev/null +++ b/src/NSubstitute/Core/IDescribeSpecification.cs @@ -0,0 +1,16 @@ +namespace NSubstitute.Core; + +/// +/// A type that can describe the required conditions to meet a specification. +/// Use in conjunction with to provide information about +/// what it requires to match an argument. +/// +public interface IDescribeSpecification +{ + + /// + /// A concise description of the conditions required to match this specification. + /// + /// + string DescribeSpecification(); +} diff --git a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs index 8b131553..055e8c14 100644 --- a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs +++ b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs @@ -838,6 +838,7 @@ public interface IMyService { void MyMethod(IMyArgument argument); } + public interface IMyArgument { } public class SampleClass { } public class MyStringArgument : IMyArgument { } @@ -845,4 +846,37 @@ public class MyOtherStringArgument : IMyArgument { } public class MySampleClassArgument : IMyArgument { } public class MyOtherSampleClassArgument : IMyArgument { } public class MySampleDerivedClassArgument : MySampleClassArgument { } -} \ No newline at end of file + + [Test] + public void Should_use_ToString_to_describe_custom_arg_matcher_without_DescribesSpec() + { + var ex = Assert.Throws(() => + { + _something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomMatcher())); + }); + Assert.That(ex.Message, Contains.Substring("Add(23, Custom match)")); + } + + [Test] + public void Should_describe_spec_for_custom_arg_matcher_when_implemented() + { + var ex = Assert.Throws(() => + { + _something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomDescribeSpecMatcher())); + }); + Assert.That(ex.Message, Contains.Substring("Add(23, DescribeSpec)")); + } + + class CustomMatcher : IArgumentMatcher, IDescribeNonMatches, IArgumentMatcher + { + public string DescribeFor(object argument) => "failed"; + public bool IsSatisfiedBy(object argument) => false; + public bool IsSatisfiedBy(int argument) => false; + public override string ToString() => "Custom match"; + } + + class CustomDescribeSpecMatcher : CustomMatcher, IDescribeSpecification + { + public string DescribeSpecification() => "DescribeSpec"; + } +}