From e4bdaed4519b29c7ee36407224cfee715269d302 Mon Sep 17 00:00:00 2001 From: David Tchepak Date: Sun, 5 May 2024 14:17:12 +1000 Subject: [PATCH 1/2] 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. Closes #796. --- .../Core/Arguments/ArgumentMatcher.cs | 7 ++++ .../Core/Arguments/IArgumentMatcher.cs | 12 ++++--- src/NSubstitute/Core/CallSpecification.cs | 4 ++- src/NSubstitute/Core/IDescribeNonMatches.cs | 7 +++- .../Core/IDescribeSpecification.cs | 16 +++++++++ .../ArgumentMatching.cs | 35 ++++++++++++++++++- 6 files changed, 74 insertions(+), 7 deletions(-) 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 f4d4bbee..c82e52b8 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; @@ -43,6 +44,9 @@ public GenericToNonGenericMatcherProxy(IArgumentMatcher 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 @@ -53,6 +57,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/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 0dfa04cf..7f1f94d9 100644 --- a/src/NSubstitute/Core/CallSpecification.cs +++ b/src/NSubstitute/Core/CallSpecification.cs @@ -123,7 +123,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 63468c3d..3940a81b 100644 --- a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs +++ b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs @@ -745,4 +745,37 @@ public void SetUp() { _something = Substitute.For(); } -} \ 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"; + } +} From aa3cd123654034bd8a889eb658e0605deeb503db Mon Sep 17 00:00:00 2001 From: David Tchepak Date: Sun, 5 May 2024 22:33:47 +1000 Subject: [PATCH 2/2] Apply review comments - use string.Empty for null value from IDescribeSpecification, rather than falling back to ToString(). This supports the contract that IDescribeSpecification will be used if implemented. Replacing null string.Empty with matches the documented bheaviour of IDescribeNonMatches. - updated IDescribeSpecification code docs. - removed GenericToNonGenericMatcherProxyWithDescribe `ToString` as it can use the GenericToNonGenericMatcherProxy superclass implementation. - update ArgumentSpecification to also support IDescribeSpecification for its matcher. - Replace linq with Array.ConvertAll rather than requiring an extra ToArray conversion. --- .../Core/Arguments/ArgumentMatcher.cs | 7 +++---- .../Core/Arguments/ArgumentSpecification.cs | 5 ++++- src/NSubstitute/Core/CallSpecification.cs | 8 +++++--- src/NSubstitute/Core/IDescribeSpecification.cs | 6 +++--- .../ArgumentMatching.cs | 16 +++++++++++++--- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs index c82e52b8..ac2315d3 100644 --- a/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs +++ b/src/NSubstitute/Core/Arguments/ArgumentMatcher.cs @@ -46,7 +46,9 @@ public GenericToNonGenericMatcherProxy(IArgumentMatcher matcher) public bool IsSatisfiedBy(object? argument) => _matcher.IsSatisfiedBy((T?)argument!); public override string ToString() => - (_matcher as IDescribeSpecification)?.DescribeSpecification() ?? _matcher.ToString() ?? ""; + _matcher is IDescribeSpecification describe + ? describe.DescribeSpecification() ?? string.Empty + : _matcher.ToString() ?? string.Empty; } private class GenericToNonGenericMatcherProxyWithDescribe : GenericToNonGenericMatcherProxy, IDescribeNonMatches @@ -57,9 +59,6 @@ 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 b/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs index 67147f0b..1c14274a 100644 --- a/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs +++ b/src/NSubstitute/Core/Arguments/ArgumentSpecification.cs @@ -56,7 +56,10 @@ public string FormatArgument(object? argument) : ArgumentFormatter.Default.Format(argument, highlight: !isSatisfiedByArg); } - public override string ToString() => _matcher.ToString() ?? string.Empty; + public override string ToString() => + _matcher is IDescribeSpecification describe + ? describe.DescribeSpecification() + : _matcher.ToString() ?? string.Empty; public IArgumentSpecification CreateCopyMatchingAnyArgOfType(Type requiredType) { diff --git a/src/NSubstitute/Core/CallSpecification.cs b/src/NSubstitute/Core/CallSpecification.cs index 7f1f94d9..50cea538 100644 --- a/src/NSubstitute/Core/CallSpecification.cs +++ b/src/NSubstitute/Core/CallSpecification.cs @@ -123,9 +123,11 @@ public IEnumerable NonMatchingArguments(ICall call) public override string ToString() { - var argSpecsAsStrings = _argumentSpecifications.Select(x => - (x as IDescribeSpecification)?.DescribeSpecification() ?? x.ToString() ?? string.Empty - ).ToArray(); + var argSpecsAsStrings = Array.ConvertAll(_argumentSpecifications, x => + x is IDescribeSpecification describe + ? describe.DescribeSpecification() ?? string.Empty + : x.ToString() ?? string.Empty + ); return CallFormatter.Default.Format(GetMethodInfo(), argSpecsAsStrings); } diff --git a/src/NSubstitute/Core/IDescribeSpecification.cs b/src/NSubstitute/Core/IDescribeSpecification.cs index dfee9620..b6d30765 100644 --- a/src/NSubstitute/Core/IDescribeSpecification.cs +++ b/src/NSubstitute/Core/IDescribeSpecification.cs @@ -7,10 +7,10 @@ namespace NSubstitute.Core; /// public interface IDescribeSpecification { - /// - /// A concise description of the conditions required to match this specification. + /// A concise description of the conditions required to match this specification, or + /// if a detailed description can not be provided. /// - /// + /// Description of the specification, or if no description can be provided. string DescribeSpecification(); } diff --git a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs index 3940a81b..78ad814e 100644 --- a/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs +++ b/tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs @@ -761,11 +761,21 @@ public void Should_describe_spec_for_custom_arg_matcher_when_implemented() { var ex = Assert.Throws(() => { - _something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomDescribeSpecMatcher())); + _something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomDescribeSpecMatcher("DescribeSpec"))); }); Assert.That(ex.Message, Contains.Substring("Add(23, DescribeSpec)")); } + [Test] + public void Should_use_empty_string_for_null_describe_spec_for_custom_arg_matcher_when_implemented() + { + var ex = Assert.Throws(() => + { + _something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomDescribeSpecMatcher(null))); + }); + Assert.That(ex.Message, Contains.Substring("Add(23, )")); + } + class CustomMatcher : IArgumentMatcher, IDescribeNonMatches, IArgumentMatcher { public string DescribeFor(object argument) => "failed"; @@ -774,8 +784,8 @@ class CustomMatcher : IArgumentMatcher, IDescribeNonMatches, IArgumentMatcher "Custom match"; } - class CustomDescribeSpecMatcher : CustomMatcher, IDescribeSpecification + class CustomDescribeSpecMatcher(string description) : CustomMatcher, IDescribeSpecification { - public string DescribeSpecification() => "DescribeSpec"; + public string DescribeSpecification() => description; } }