diff --git a/src/NSubstitute/Core/Arguments/ArgumentMatchInfo.cs b/src/NSubstitute/Core/Arguments/ArgumentMatchInfo.cs index 508e10d6..b7f96987 100644 --- a/src/NSubstitute/Core/Arguments/ArgumentMatchInfo.cs +++ b/src/NSubstitute/Core/Arguments/ArgumentMatchInfo.cs @@ -3,14 +3,15 @@ public class ArgumentMatchInfo(int index, object? argument, IArgumentSpecification specification) { private readonly object? _argument = argument; - private readonly IArgumentSpecification _specification = specification; public int Index { get; } = index; - public bool IsMatch => _specification.IsSatisfiedBy(_argument); + public bool IsMatch => Specification.IsSatisfiedBy(_argument); + + public IArgumentSpecification Specification { get; } = specification; public string DescribeNonMatch() { - var describeNonMatch = _specification.DescribeNonMatch(_argument); + var describeNonMatch = Specification.DescribeNonMatch(_argument); if (string.IsNullOrEmpty(describeNonMatch)) return string.Empty; var argIndexPrefix = "arg[" + Index + "]: "; return string.Format("{0}{1}", argIndexPrefix, describeNonMatch.Replace("\n", "\n".PadRight(argIndexPrefix.Length + 1))); @@ -20,7 +21,7 @@ public bool Equals(ArgumentMatchInfo? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return other.Index == Index && Equals(other._argument, _argument) && Equals(other._specification, _specification); + return other.Index == Index && Equals(other._argument, _argument) && Equals(other.Specification, Specification); } public override bool Equals(object? obj) @@ -37,7 +38,7 @@ public override int GetHashCode() { int result = Index; result = (result * 397) ^ (_argument != null ? _argument.GetHashCode() : 0); - result = (result * 397) ^ _specification.GetHashCode(); + result = (result * 397) ^ Specification.GetHashCode(); return result; } } diff --git a/src/NSubstitute/WillReceive.cs b/src/NSubstitute/WillReceive.cs new file mode 100644 index 00000000..7cbc118f --- /dev/null +++ b/src/NSubstitute/WillReceive.cs @@ -0,0 +1,13 @@ +using NSubstitute.Core; + +namespace NSubstitute; + +public static class WillReceive +{ + public static WillReceiveExpectation InOrder(Action calls) + { + return new WillReceiveExpectation( + callSpecificationFactory: SubstitutionContext.Current.CallSpecificationFactory, + buildExpectationsAction: calls); + } +} \ No newline at end of file diff --git a/src/NSubstitute/WillReceiveExpectation.cs b/src/NSubstitute/WillReceiveExpectation.cs new file mode 100644 index 00000000..8db25b43 --- /dev/null +++ b/src/NSubstitute/WillReceiveExpectation.cs @@ -0,0 +1,264 @@ +using System.Collections; +using System.Text; +using NSubstitute.Core; +using NSubstitute.Core.Arguments; +using NSubstitute.Core.SequenceChecking; +using NSubstitute.Exceptions; + +namespace NSubstitute; + +public sealed class WillReceiveExpectation : IQuery +{ + private const string _indent = " "; + private readonly List _expectedCallSpecAndTargets = []; + private readonly List _receivedCalls = []; + private readonly ICallSpecificationFactory _callSpecificationFactory; + private readonly InstanceTracker _instanceTracker = new(); + private readonly Action _buildExpectationsAction; + private bool _buildingExpectations; + + public WillReceiveExpectation(ICallSpecificationFactory callSpecificationFactory, Action buildExpectationsAction) + { + _callSpecificationFactory = callSpecificationFactory; + _buildExpectationsAction = buildExpectationsAction; + } + + public void WhileExecuting(Action action) + { + _buildingExpectations = true; + + SubstitutionContext.Current.ThreadContext.RunInQueryContext(_buildExpectationsAction, this); + + _buildingExpectations = false; + +#if NET6_0_OR_GREATER + _receivedCalls.EnsureCapacity(_expectedCallSpecAndTargets.Count); +#endif + + SubstitutionContext.Current.ThreadContext.RunInQueryContext(action, this); + + AssertReceivedCalls(); + } + + void IQuery.RegisterCall(ICall call) + { + if (call.GetMethodInfo().GetPropertyFromGetterCallOrNull() != null) + return; + + if (_buildingExpectations) + AddCallExpectation(call); + else + AddReceivedAssertionCall(call); + } + private void AddCallExpectation(ICall call) + { + var callSpecification = _callSpecificationFactory.CreateFrom(call, MatchArgs.AsSpecifiedInCall); + + _expectedCallSpecAndTargets.Add(new CallSpecAndTarget(callSpecification, call.Target())); + } + private void AddReceivedAssertionCall(ICall call) + { + var instanceNumber = _instanceTracker.InstanceNumber(call.Target()); + var expectedCallIndex = _receivedCalls.Count; + + if (expectedCallIndex >= _expectedCallSpecAndTargets.Count) + { + _receivedCalls.Add(new UnexpectedCallData(specification: null, call, instanceNumber)); + return; + } + + var specAndTarget = _expectedCallSpecAndTargets[expectedCallIndex]; + + var callData = !specAndTarget.CallSpecification.IsSatisfiedBy(call) + ? new UnexpectedCallData(specAndTarget.CallSpecification, call, instanceNumber) + : null; + + _receivedCalls.Add(callData); + } + + private void AssertReceivedCalls() + { + if (_receivedCalls.Any(x => x != null) || _receivedCalls.Count < _expectedCallSpecAndTargets.Count) + throw new CallSequenceNotFoundException(CreateExceptionMessage()); + } + + private string CreateExceptionMessage() + { + var builder = new StringBuilder(); + var includeInstanceNumber = HasMultipleCallsOnSameType(); + var multipleInstances = _instanceTracker.NumberOfInstances() > 1; + + builder.AppendLine(); + + var i = 0; + + for (; i < _receivedCalls.Count; i++) + { + var callData = _receivedCalls[i]; + + builder.Append("Call "); + builder.Append(i + 1); + builder.Append(": "); + + if (callData == null) + { + builder.AppendLine("Accepted!"); + } + else + { + var expectedCall = i < _expectedCallSpecAndTargets.Count + ? _expectedCallSpecAndTargets[i] + : null; + + AppendUnexpectedCallToExceptionMessage(builder, expectedCall, callData, multipleInstances, includeInstanceNumber); + } + } + + AppendNotReceivedCallsToExceptionMessage(builder, nextExpectedCallIndex: i); + + return builder.ToString(); + } + + private bool HasMultipleCallsOnSameType() + { + var lookup = new Dictionary(); + + foreach (var call in _receivedCalls) + { + if (call == null) + continue; + + if (lookup.TryGetValue(call.DeclaringType, out var instanceNumber)) + { + if (instanceNumber != call.InstanceNumber) + return true; + } + else + { + lookup.Add(call.DeclaringType, call.InstanceNumber); + } + } + + return false; + } + + private static void AppendUnexpectedCallToExceptionMessage(StringBuilder builder, + CallSpecAndTarget? expectedCall, + UnexpectedCallData unexpectedCallData, + bool multipleInstances, + bool includeInstanceNumber) + { + // Not matched or unexpected + if (expectedCall != null) + { + builder.AppendLine("Not matched!"); + builder.Append($"{_indent}Expected: "); + builder.AppendLine(expectedCall.CallSpecification.ToString()); + } + else + { + builder.AppendLine("Unexpected!"); + } + + builder.Append($"{_indent}But was: "); + + // Prepend instance number and type if multiple instances + if (multipleInstances) + { + if (includeInstanceNumber) + { + builder.Append(unexpectedCallData.InstanceNumber); + builder.Append('@'); + } + + builder.Append(unexpectedCallData.DeclaringType.GetNonMangledTypeName()); + builder.Append('.'); + } + + builder.AppendLine(unexpectedCallData.CallFormat); + + // Append non-matching arguments + foreach (var argumentFormat in unexpectedCallData.NonMatchingArgumentFormats) + { + builder.Append(_indent); + builder.Append(_indent); + builder.AppendLine(argumentFormat); + } + } + + private void AppendNotReceivedCallsToExceptionMessage(StringBuilder builder, int nextExpectedCallIndex) + { + for (; nextExpectedCallIndex < _expectedCallSpecAndTargets.Count; nextExpectedCallIndex++) + { + builder.AppendLine($"Call {nextExpectedCallIndex + 1}: Not received!"); + builder.Append($"{_indent}Expected: "); + builder.AppendLine(_expectedCallSpecAndTargets[nextExpectedCallIndex].CallSpecification.ToString()); + } + } + + private sealed class UnexpectedCallData + { + public Type DeclaringType { get; } + public string CallFormat { get; } + public IReadOnlyList NonMatchingArgumentFormats { get; } + public int InstanceNumber { get; } + + public UnexpectedCallData(ICallSpecification? specification, ICall call, int instanceNumber) + { + DeclaringType = call.GetMethodInfo().DeclaringType!; + + CallFormat = FormatCall(call); + + NonMatchingArgumentFormats = specification != null + ? FormatNonMatchingArguments(specification, call) + : []; + + InstanceNumber = instanceNumber; + } + + private static string FormatCall(ICall call) + { + // Based on SequenceFormatter, maybe we can refactor this? + + var methodInfo = call.GetMethodInfo(); + + var args = methodInfo.GetParameters() + .Zip(call.GetOriginalArguments(), (info, value) => (info, value)) + .SelectMany(x => + { + var (info, value) = x; + + return info.IsParams() + ? ((IEnumerable)value!).Cast() + : ToEnumerable(value); + + static IEnumerable ToEnumerable(T value) + { + yield return value; + } + }) + .Select(x => ArgumentFormatter.Default.Format(x, false)) + .ToArray(); + + return CallFormatter.Default.Format(methodInfo, args); + } + private static string[] FormatNonMatchingArguments(ICallSpecification specification, ICall call) + { + var nonMatchingArguments = specification.NonMatchingArguments(call).ToArray(); + var result = new string[nonMatchingArguments.Length]; + + for (var i = 0; i < nonMatchingArguments.Length; i++) + { + var nonMatchingArgument = nonMatchingArguments[i]; + var description = nonMatchingArgument.DescribeNonMatch(); + + if (string.IsNullOrWhiteSpace(description)) + description = $"arg[{nonMatchingArgument.Index}] not matched: {nonMatchingArgument.Specification}"; + + result[i] = description; + } + + return result; + } + } +} \ No newline at end of file diff --git a/tests/NSubstitute.Acceptance.Specs/WillReceiveWhileExecuting.cs b/tests/NSubstitute.Acceptance.Specs/WillReceiveWhileExecuting.cs new file mode 100644 index 00000000..30ab1071 --- /dev/null +++ b/tests/NSubstitute.Acceptance.Specs/WillReceiveWhileExecuting.cs @@ -0,0 +1,377 @@ +using NSubstitute.Exceptions; +using NUnit.Framework; + +namespace NSubstitute.Acceptance.Specs; + +[TestFixture] +public class WillReceiveWhileExecuting +{ + [Test] + public void Simple_Call_Sequence_Succeeds() + { + var sub = Substitute.For(); + + WillReceive + .InOrder(() => + { + sub.Method("first"); + sub.Method("second"); + }) + .WhileExecuting(() => + { + sub.Method("first"); + sub.Method("second"); + }); + } + + [Test] + public void Missing_Call_Throws_With_Descriptive_Message() + { + var sub = Substitute.For(); + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub.Method("first"); + sub.Method("second"); + }) + .WhileExecuting(() => + { + sub.Method("first"); + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring("Call 1: Accepted!")); + Assert.That(ex.Message, Contains.Substring("Call 2: Not received!")); + Assert.That(ex.Message, Contains.Substring($"Expected: {nameof(ITestInterface.Method)}(\"second\")")); + } + + [Test] + public void Extra_Unexpected_Call_Throws_With_Descriptive_Message() + { + var sub = Substitute.For(); + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub.Method("first"); + }) + .WhileExecuting(() => + { + sub.Method("first"); + sub.Method("extra"); + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring("Call 1: Accepted!")); + Assert.That(ex.Message, Contains.Substring("Call 2: Unexpected!")); + Assert.That(ex.Message, Contains.Substring($"But was: {nameof(ITestInterface.Method)}(\"extra\")")); + } + + [Test] + public void Wrong_Call_Order_Throws_With_Descriptive_Message() + { + var sub = Substitute.For(); + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub.Method("first"); + sub.Method("second"); + }) + .WhileExecuting(() => + { + sub.Method("second"); + sub.Method("first"); + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring("Call 1: Not matched!")); + Assert.That(ex.Message, Contains.Substring($"Expected: {nameof(ITestInterface.Method)}(\"first\")")); + Assert.That(ex.Message, Contains.Substring($"But was: {nameof(ITestInterface.Method)}(\"second\")")); + } + + [Test] + public void Property_Getters_Are_Ignored() + { + var sub = Substitute.For(); + + sub.Name = "name"; + + WillReceive + .InOrder(() => + { + _ = sub.Name; // getter should be ignored + sub.Method(1); + sub.Method(2); + }) + .WhileExecuting(() => + { + _ = sub.Name; // getter should be ignored + sub.Method(1); + sub.Method(2); + }); + } + + [Test] + public void Event_Subscriptions_Are_Handled() + { + var sub = Substitute.For(); + var handler = () => { }; + + WillReceive + .InOrder(() => + { + sub.OnEvent += handler; + sub.Method(1); + sub.OnEvent -= handler; + }) + .WhileExecuting(() => + { + sub.OnEvent += handler; + sub.Method(1); + sub.OnEvent -= handler; + }); + } + + [Test] + public void Event_Subscriptions_With_Wrong_Order_Are_Handled() + { + var sub = Substitute.For(); + var handler = () => { }; + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub.OnEvent += handler; + sub.Method(1); + sub.OnEvent -= handler; + }) + .WhileExecuting(() => + { + sub.OnEvent -= handler; + sub.Method(1); + sub.OnEvent += handler; + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring($"Expected: {nameof(ITestInterface.OnEvent)} += Action")); + Assert.That(ex.Message, Contains.Substring($"Expected: {nameof(ITestInterface.OnEvent)} -= Action")); + Assert.That(ex.Message, Contains.Substring($"But was: {nameof(ITestInterface.OnEvent)} += Action")); + Assert.That(ex.Message, Contains.Substring($"But was: {nameof(ITestInterface.OnEvent)} -= Action")); + } + + [Test] + public void Multiple_Substitutes_With_Correct_Order_Succeeds() + { + var sub1 = Substitute.For(); + var sub2 = Substitute.For(); + + WillReceive + .InOrder(() => + { + sub1.Method(1); + sub2.Method(2); + }) + .WhileExecuting(() => + { + sub1.Method(1); + sub2.Method(2); + }); + } + + [Test] + public void Multiple_Substitutes_With_Wrong_Order_Shows_Instance_Numbers() + { + var sub1 = Substitute.For(); + var sub2 = Substitute.For(); + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub1.Method(1); + sub2.Method(2); + }) + .WhileExecuting(() => + { + sub2.Method(2); + sub1.Method(1); + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring($"1@{nameof(WillReceiveWhileExecuting)}+{nameof(ITestInterface)}")); + Assert.That(ex.Message, Contains.Substring($"2@{nameof(WillReceiveWhileExecuting)}+{nameof(ITestInterface)}")); + } + + [Test] + public void Mutable_Object_State_Is_Captured_Correctly() + { + var sub = Substitute.For(); + var obj = new TestObject + { + Value = "initial" + }; + + WillReceive + .InOrder(() => + { + sub.ComplexMethod(Arg.Is(x => x.Value == "initial")); + sub.ComplexMethod(Arg.Is(x => x.Value == "modified")); + }) + .WhileExecuting(() => + { + sub.ComplexMethod(obj); + obj.Value = "modified"; + sub.ComplexMethod(obj); + obj.Value = "final"; // This change should not affect verification + }); + } + + [Test] + public void Collection_Modifications_Are_Captured() + { + var sub = Substitute.For(); + var obj = new TestObject + { + Numbers = new List { 1, 2 } + }; + + WillReceive + .InOrder(() => + { + sub.ComplexMethod(Arg.Is(x => x.Numbers.Count == 2)); + sub.ComplexMethod(Arg.Is(x => x.Numbers.Count == 3)); + }) + .WhileExecuting(() => + { + sub.ComplexMethod(obj); + obj.Numbers.Add(3); + sub.ComplexMethod(obj); + obj.Numbers.Add(4); // Should not affect verification + }); + } + + [Test] + public void Null_Arguments_Are_Handled() + { + var sub = Substitute.For(); + + WillReceive + .InOrder(() => + { + sub.ComplexMethod(null!); + sub.ComplexMethod(Arg.Any()); + }) + .WhileExecuting(() => + { + sub.ComplexMethod(null!); + sub.ComplexMethod(new TestObject()); + }); + } + + [Test] + public void Argument_Matchers_Are_Respected() + { + var sub = Substitute.For(); + + WillReceive + .InOrder(() => + { + sub.Method(Arg.Is(s => s.StartsWith("h"))); + sub.Method(Arg.Any()); + }) + .WhileExecuting(() => + { + sub.Method("hello"); + sub.Method("whatever"); + }); + } + + [Test] + public void Non_Matching_Argument_Shows_Detailed_Error() + { + var sub = Substitute.For(); + + var ex = Assert.Throws(() => + WillReceive + .InOrder(() => + { + sub.Method(Arg.Is(s => s.StartsWith("h"))); + }) + .WhileExecuting(() => + { + sub.Method("goodbye"); + })); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring("arg[0] not matched")); + } + + [Test] + public void Out_Parameters_Are_Handled_Correctly() + { + var lookup = Substitute.For(); + + lookup.TryGet("key", out _).Returns(x => { x[1] = "first"; return true; }); + lookup.TryGet("other", out _).Returns(x => { x[1] = "second"; return true; }); + + WillReceive + .InOrder(() => + { + lookup.TryGet("key", out _); + lookup.TryGet("other", out _); + }) + .WhileExecuting(() => + { + lookup.TryGet("key", out _); + lookup.TryGet("other", out _); + }); + } + + [Test] + public void Ref_Parameters_State_Is_Captured() + { + var lookup = Substitute.For(); + var refValue = 42; + + lookup.When(x => x.ModifyRef(ref refValue)).Do(x => x[0] = 100); + + WillReceive + .InOrder(() => + { + lookup.ModifyRef(ref refValue); + refValue = 100; + lookup.ModifyRef(ref refValue); + }) + .WhileExecuting(() => + { + refValue = 42; + lookup.ModifyRef(ref refValue); + refValue = 100; + lookup.ModifyRef(ref refValue); + refValue = 200; + }); + } + + public interface ITestInterface + { + event Action OnEvent; + void Method(string value); + void Method(int value); + string Name { get; set; } + void ComplexMethod(TestObject obj); + bool TryGet(string key, out string value); + void ModifyRef(ref int value); + } + + public class TestObject + { + public string Value { get; set; } + public List Numbers { get; set; } = []; + } +} \ No newline at end of file