Skip to content

Commit

Permalink
Improve output for expected argument matchers
Browse files Browse the repository at this point in the history
- 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 nsubstitute#796.
  • Loading branch information
dtchepak committed Nov 10, 2024
1 parent ab73157 commit 4460556
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 7 deletions.
6 changes: 6 additions & 0 deletions src/NSubstitute/Core/Arguments/ArgumentMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ private class GenericToNonGenericMatcherProxy<T>(IArgumentMatcher<T> matcher) :
protected readonly IArgumentMatcher<T> _matcher = matcher;

public bool IsSatisfiedBy(object? argument) => _matcher.IsSatisfiedBy((T?)argument!);

public override string ToString() =>
(_matcher as IDescribeSpecification)?.DescribeSpecification() ?? _matcher.ToString() ?? "";
}

private class GenericToNonGenericMatcherProxyWithDescribe<T> : GenericToNonGenericMatcherProxy<T>, IDescribeNonMatches
Expand All @@ -48,6 +51,9 @@ public GenericToNonGenericMatcherProxyWithDescribe(IArgumentMatcher<T> matcher)
}

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

public override string ToString() =>
(_matcher as IDescribeSpecification)?.DescribeSpecification() ?? _matcher.ToString() ?? "";
}

private class DefaultValueContainer<T>
Expand Down
88 changes: 88 additions & 0 deletions src/NSubstitute/Core/Arguments/ArgumentSpecification.cs.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace NSubstitute.Core.Arguments;

public class ArgumentSpecification(Type forType, IArgumentMatcher matcher, Action<object?> action) : IArgumentSpecification
{
private static readonly Action<object?> 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}'.";
}
}
12 changes: 8 additions & 4 deletions src/NSubstitute/Core/Arguments/IArgumentMatcher.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
namespace NSubstitute.Core.Arguments;

/// <summary>
/// Provides a specification for arguments for use with <see ctype="Arg.Matches (IArgumentMatcher)" />.
/// Can additionally implement <see cref="IDescribeNonMatches" /> to give descriptions when arguments do not match.
/// Provides a specification for arguments.
/// Can implement <see cref="IDescribeNonMatches" /> to give descriptions when arguments do not match.
/// Can implement <see cref="IDescribeSpecification"/> to give descriptions of expected arguments (otherwise
/// `ToString()` will be used for descriptions).
/// </summary>
public interface IArgumentMatcher
{
Expand All @@ -14,8 +16,10 @@ public interface IArgumentMatcher
}

/// <summary>
/// Provides a specification for arguments for use with <see ctype="Arg.Matches &lt; T &gt;(IArgumentMatcher)" />.
/// Can additionally implement <see ctype="IDescribeNonMatches" /> to give descriptions when arguments do not match.
/// Provides a specification for arguments.
/// Can implement <see cref="IDescribeNonMatches" /> to give descriptions when arguments do not match.
/// Can implement <see cref="IDescribeSpecification"/> to give descriptions of expected arguments (otherwise
/// `ToString()` will be used for descriptions).
/// </summary>
/// <typeparam name="T">Matches arguments of type <typeparamref name="T"/> or compatible type.</typeparam>
public interface IArgumentMatcher<T>
Expand Down
4 changes: 3 additions & 1 deletion src/NSubstitute/Core/CallSpecification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ public IEnumerable<ArgumentMatchInfo> 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);
}

Expand Down
7 changes: 6 additions & 1 deletion src/NSubstitute/Core/IDescribeNonMatches.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
namespace NSubstitute.Core;

/// <summary>
/// A type that can describe how an argument does not match a required condition.
/// Use in conjunction with <see cref="NSubstitute.Core.Arguments.IArgumentMatcher"/> to provide information about
/// non-matches.
/// </summary>
public interface IDescribeNonMatches
{
/// <summary>
Expand All @@ -9,4 +14,4 @@ public interface IDescribeNonMatches
/// <param name="argument"></param>
/// <returns>Description of the non-match, or <see cref="string.Empty" /> if no description can be provided.</returns>
string DescribeFor(object? argument);
}
}
16 changes: 16 additions & 0 deletions src/NSubstitute/Core/IDescribeSpecification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace NSubstitute.Core;

/// <summary>
/// A type that can describe the required conditions to meet a specification.
/// Use in conjunction with <see cref="NSubstitute.Core.Arguments.IArgumentMatcher"/> to provide information about
/// what it requires to match an argument.
/// </summary>
public interface IDescribeSpecification
{

/// <summary>
/// A concise description of the conditions required to match this specification.
/// </summary>
/// <returns></returns>
string DescribeSpecification();
}
36 changes: 35 additions & 1 deletion tests/NSubstitute.Acceptance.Specs/ArgumentMatching.cs
Original file line number Diff line number Diff line change
Expand Up @@ -838,11 +838,45 @@ public interface IMyService
{
void MyMethod<T>(IMyArgument<T> argument);
}

public interface IMyArgument<T> { }
public class SampleClass { }
public class MyStringArgument : IMyArgument<string> { }
public class MyOtherStringArgument : IMyArgument<string> { }
public class MySampleClassArgument : IMyArgument<SampleClass> { }
public class MyOtherSampleClassArgument : IMyArgument<SampleClass> { }
public class MySampleDerivedClassArgument : MySampleClassArgument { }
}

[Test]
public void Should_use_ToString_to_describe_custom_arg_matcher_without_DescribesSpec()
{
var ex = Assert.Throws<ReceivedCallsException>(() =>
{
_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<ReceivedCallsException>(() =>
{
_something.Received().Add(23, ArgumentMatcher.Enqueue(new CustomDescribeSpecMatcher()));
});
Assert.That(ex.Message, Contains.Substring("Add(23, DescribeSpec)"));
}

class CustomMatcher : IArgumentMatcher, IDescribeNonMatches, IArgumentMatcher<int>
{
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";
}
}

0 comments on commit 4460556

Please sign in to comment.