The NSubstitute Guide: Returns, Argument Matchers, When..Do, Received, Raise, ForPartsOf and the Analyzer
In NSubstitute, Returns binds to the last intercepted call; substitute a class and configure a non-virtual method, and the virtual member it calls internally gets configured instead, so the test fails with a wrong number rather than an exception. Fixed, sequential and computed Returns, out parameters, When..Do, Received with its real failure message, Raise.EventWith, recursive mocks, ForPartsOf with Configure() and the analyzer rules.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3, NSubstitute 6.2.0 and NSubstitute.Analyzers.CSharp 1.0.17.
Predict First
Post 07 compared three mocking libraries on the same scenarios and the series chose NSubstitute. This post does not repeat the comparison; it goes through NSubstitute's everyday API one call at a time, using the Payments and Notifications modules of Bookstore, the online bookstore used as the shared example across this site's series. A question first. The class that calculates the payment provider's fee is a concrete class with no interface:
public class PaymentFeeCalculator
{
public decimal Fee(decimal amount, string provider) =>
Math.Round(amount * ProviderFeeRate(provider), 2, MidpointRounding.AwayFromZero);
// Real rates come from the provider contract table
public virtual decimal ProviderFeeRate(string provider) => provider switch
{
"card-gateway" => 0.025m,
"bank-transfer" => 0.0m,
_ => throw new KeyNotFoundException($"No fee contract for provider '{provider}'."),
};
}
A test assumes the fee for a new wallet provider is 3 lira:
[Fact]
public void Wallet_fee_is_three_lira()
{
PaymentFeeCalculator calculator = Substitute.For<PaymentFeeCalculator>();
calculator.Fee(200m, "wallet").Returns(3m);
decimal fee = calculator.Fee(200m, "wallet");
Assert.Equal(3m, fee);
}
Fee is not virtual. In post 07 we saw NSubstitute throw "Could not find a call to return from" when Returns was used on a non-virtual method. Does the same happen here?
The Answer: Returns Binds to the Last Intercepted Call
This time there is no exception. The test fails and the message is surprising:
Assert.Equal() Failure: Values differ
Expected: 3
Actual: 600
Let us go through it step by step. calculator.Fee(200m, "wallet") is non-virtual, so it ran its real code without hitting the proxy. The real Fee called the virtual ProviderFeeRate("wallet") internally; that call hit the proxy and, being unconfigured, returned 0. Returns(3m) binds to whatever the last intercepted call was: ProviderFeeRate("wallet"). The rate is now 3, that is 300%, and Fee returns 200 × 3 = 600. No exception, just a wrongly configured test and a wrong number. NSubstitute.Analyzers flags the line at build time:
Probe.cs(9,9): warning NS1000: Member Fee can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.
If you want to keep a class's real behavior and change only a part of it, the right tool is Substitute.ForPartsOf<T>(). There is a trap here too: with a partial substitute, the call on the setup line runs the real method. When I wrote calculator.ProviderFeeRate("wallet").Returns(0.015m), the test failed on the setup line with the KeyNotFoundException : No fee contract for provider 'wallet'. the real code throws for a provider without a contract. The fix is Configure() from the NSubstitute.Extensions namespace: the call that follows it is recorded only as configuration, without reaching the real code.
[Fact]
public void Fee_uses_the_rate_of_a_new_provider()
{
PaymentFeeCalculator calculator = Substitute.ForPartsOf<PaymentFeeCalculator>();
calculator.Configure().ProviderFeeRate("wallet").Returns(0.015m);
Assert.Equal(3.00m, calculator.Fee(200m, "wallet"));
Assert.Equal(5.00m, calculator.Fee(200m, "card-gateway"));
}
Fee and the "card-gateway" rate are real; only the "wallet" rate is faked. Think of ForPartsOf not as a design tool for new code but as a temporary seam, a point where you can change behavior for a test without editing the code (post 09), in old code where you cannot extract an interface.
Substitute.For and Returns: Fixed, Sequential and Computed Values
The payment flow: a fraud check, choosing a gateway by currency, one retry on timeout, an audit entry, and an e-mail to the customer when the payment is approved:
public sealed class PaymentService(
IPaymentGatewayFactory gateways, IFraudCheck fraud, IPaymentAuditLog audit, INotificationSender notifications)
{
public const int FraudThreshold = 80;
public async Task<PaymentOutcome> PayAsync(
Guid orderId, decimal amount, string currency, string email, string cardToken, CancellationToken ct)
{
if (fraud.TryScore(email, amount, out int score) && score >= FraudThreshold)
{
audit.Write($"blocked {orderId} score={score}");
return PaymentOutcome.Blocked;
}
IPaymentGateway gateway = gateways.For(currency);
ChargeRequest request = new(orderId, amount, currency, cardToken);
ChargeResult result;
try
{
result = await gateway.ChargeAsync(request, ct);
}
catch (TimeoutException)
{
audit.Write($"timeout {orderId} via {gateway.ProviderName}, retrying");
result = await gateway.ChargeAsync(request, ct);
}
if (!result.Approved)
{
audit.Write($"declined {orderId} {result.DeclineCode}");
return PaymentOutcome.Declined;
}
audit.Write($"charged {orderId} {result.TransactionId}");
string body = string.Create(CultureInfo.InvariantCulture, $"We received {amount:0.00} {currency} for your order.");
await notifications.SendAsync(new EmailMessage(email, "Payment received", body), ct);
return PaymentOutcome.Paid;
}
}
The test class creates the substitutes as fields; since xUnit creates a new instance for every test (post 05), each test starts with clean substitutes:
public sealed class PaymentServiceTests
{
private static readonly ChargeResult Approved = new(true, "tx-1001", null);
private readonly IPaymentGatewayFactory gateways = Substitute.For<IPaymentGatewayFactory>();
private readonly IPaymentGateway gateway = Substitute.For<IPaymentGateway>();
private readonly IFraudCheck fraud = Substitute.For<IFraudCheck>();
private readonly IPaymentAuditLog audit = Substitute.For<IPaymentAuditLog>();
private readonly INotificationSender notifications = Substitute.For<INotificationSender>();
private readonly PaymentService sut;
private readonly Guid orderId = Guid.Parse("8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11");
public PaymentServiceTests()
{
gateways.For("TRY").Returns(gateway);
gateway.ProviderName.Returns("card-gateway");
sut = new PaymentService(gateways, fraud, audit, notifications);
}
private Task<PaymentOutcome> Pay(decimal amount) =>
sut.PayAsync(orderId, amount, "TRY", "ada@example.com", "card-token-test", TestContext.Current.CancellationToken);
[Fact]
public async Task Gateway_timeout_is_retried_once()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromException<ChargeResult>(new TimeoutException()),
Task.FromResult(Approved));
PaymentOutcome outcome = await Pay(349.90m);
Assert.Equal(PaymentOutcome.Paid, outcome);
await gateway.Received(2).ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>());
audit.Received(1).Write("timeout 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 via card-gateway, retrying");
}
[Fact]
public async Task Amount_above_the_card_limit_is_declined()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.Returns(call => call.Arg<ChargeRequest>().Amount > 5_000m
? new ChargeResult(false, null, "limit_exceeded")
: Approved);
Assert.Equal(PaymentOutcome.Declined, await Pay(7_500m));
Assert.Equal(PaymentOutcome.Paid, await Pay(349.90m));
audit.Received(1).Write(Arg.Is<string>(e => e.EndsWith("limit_exceeded")));
}
}
Returns comes in three forms. A fixed value: gateway.ProviderName.Returns("card-gateway"); properties are configured just like methods. Sequential values: Returns(first, second, ...) returns the next one on each call, and once the list runs out the last value repeats. On an async method these can be Tasks: the first test returns a faulted Task (a timeout) on the first call and an approval on the second. The result is the same as the lambda form in post 07, but here the exception is not thrown synchronously; it arrives inside the Task, as it would in production. A computed value: Returns(call => ...) runs on every call and reads the arguments through call.Arg<T>(); the second test sets up a gateway that answers differently by amount. ReturnsForAnyArgs makes the same setup without looking at the arguments at all.
For exceptions, the NSubstitute.ExceptionExtensions namespace has Throws and ThrowsAsync:
[Fact]
public async Task Gateway_error_other_than_timeout_is_not_retried()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("503 Service Unavailable"));
await Assert.ThrowsAsync<HttpRequestException>(() => Pay(349.90m));
await gateway.Received(1).ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>());
}
Argument Matchers and out Parameters
Arg.Any<T>() accepts any value, Arg.Is<T>(predicate) a value that satisfies the condition, and Arg.Is(value) an equal value; writing the argument directly is the same as Arg.Is(value). Arg.Do<T>(action) captures the argument (post 07). 6.1.0 added a matcher that checks reference equality (Arg.Same), for asking "is it this instance?" on a type that overrides Equals. In 6.0, the Arg.Compat API written for pre-C# 7 compilers became obsolete.
The fraud check returns a score through an out parameter. To fill an out parameter in the setup, you assign to call[index]:
[Fact]
public async Task High_fraud_score_blocks_the_payment_before_charging()
{
fraud.TryScore("ada@example.com", Arg.Any<decimal>(), out Arg.Any<int>())
.Returns(call =>
{
call[2] = 91;
return true;
});
PaymentOutcome outcome = await Pay(349.90m);
Assert.Equal(PaymentOutcome.Blocked, outcome);
gateways.DidNotReceiveWithAnyArgs().For(default!);
audit.Received().Write("blocked 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 score=91");
}
out Arg.Any<int>() means "whatever comes into this parameter". The index starts at zero and counts out/ref parameters too: email is 0, amount is 1, score is 2. ref parameters work the same way, changed with call[i] = .... This expression depends on parameter order; if the method signature changes, the compiler does not warn you and the test writes to the wrong index at run time. That is why it matters to keep the callback short.
Void Methods: When..Do
Returns is for calls that return a value. To add behavior to a void method or to observe its calls, use When(...).Do(...):
[Fact]
public async Task Audit_entries_follow_the_payment_flow()
{
List<string> entries = [];
audit.When(a => a.Write(Arg.Any<string>())).Do(call => entries.Add(call.Arg<string>()));
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<ChargeResult>(new TimeoutException()), Task.FromResult(Approved));
await Pay(349.90m);
Assert.Equal(
[
"timeout 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 via card-gateway, retrying",
"charged 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 tx-1001",
],
entries);
}
[Fact]
public async Task Failing_audit_log_stops_before_the_receipt()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);
audit.When(a => a.Write(Arg.Is<string>(e => e.StartsWith("charged"))))
.Do(_ => throw new IOException("Audit store unavailable."));
await Assert.ThrowsAsync<IOException>(() => Pay(349.90m));
await notifications.DidNotReceive().SendAsync(Arg.Any<EmailMessage>(), Arg.Any<CancellationToken>());
}
The first test collects the audit entries in order; you could do the same with audit.Write(Arg.Do<string>(entries.Add)). The second test makes a void method fail only for a specific argument: if the "charged" entry cannot be written, no e-mail goes to the customer. This raises a design question as well: when the audit entry cannot be written, the payment has been taken but the customer has not been told. The test's job is to make this behavior visible; the owner of the Payments module decides what the right behavior is.
Received, DidNotReceive and Call Counts
[Fact]
public async Task Approved_payment_sends_one_receipt_to_the_customer()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);
PaymentOutcome outcome = await Pay(349.90m);
Assert.Equal(PaymentOutcome.Paid, outcome);
await notifications.Received(1).SendAsync(
Arg.Is<EmailMessage>(m => m.To == "ada@example.com" && m.Body.Contains("349.90 TRY")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Each_payment_sends_its_own_receipt()
{
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);
await Pay(349.90m);
notifications.ClearReceivedCalls();
await Pay(120.00m);
await notifications.Received(1).SendAsync(
Arg.Is<EmailMessage>(m => m.Body.Contains("120.00 TRY")), Arg.Any<CancellationToken>());
}
Received() expects at least one call, Received(n) exactly n, and DidNotReceive() none. ReceivedWithAnyArgs/DidNotReceiveWithAnyArgs ignore the arguments; in the fraud test, gateways.DidNotReceiveWithAnyArgs().For(default!) says "no gateway was requested". On async methods you do not have to await the Task that Received returns, but doing so helps readability and keeps analyzers quiet. ClearReceivedCalls() deletes the call records so far and keeps the configuration; the second test verifies the second payment without seeing the first payment's e-mail. For call order there is Received.InOrder (post 07).
When a verification fails, NSubstitute lists the calls it received and marks the mismatching argument with asterisks. If we expect the e-mail to go to Alan:
NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
SendAsync(m => (m.To == "alan@example.com"), any CancellationToken)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
SendAsync(*EmailMessage { To = ada@example.com, Subject = Payment received, Body = We received 349.90 TRY for your order. }*, System.Threading.CancellationToken)
The benefit of records shows here too: EmailMessage's ToString prints every field and the message says exactly what was sent.
A small interaction with xUnit: on lines such as DidNotReceiveWithAnyArgs().SendAsync(default!, default), where I passed default for the CancellationToken parameter, the xUnit analyzer raised xUnit1051 ("should use TestContext.Current.CancellationToken"); the arguments are ignored in that call, but the analyzer does not know that. Writing it with Arg.Any<CancellationToken>() keeps both tools happy.
Events, Properties and Recursive Mocks
The gateway raises an event when a payment settles, that is, when the provider's webhook arrives, and SettlementTracker collects these. To raise the event through the substitute, use Raise:
[Fact]
public void Settlements_raised_by_the_gateway_are_summed()
{
IPaymentGateway gateway = Substitute.For<IPaymentGateway>();
using SettlementTracker tracker = new(gateway);
gateway.Settled += Raise.EventWith(new PaymentSettledEventArgs("tx-1001", 349.90m));
gateway.Settled += Raise.EventWith(gateway, new PaymentSettledEventArgs("tx-1002", 120.00m));
Assert.Equal(["tx-1001", "tx-1002"], tracker.SettledTransactions);
Assert.Equal(469.90m, tracker.SettledTotal);
}
The += syntax looks odd but is deliberate: in C# you can only touch an event from outside with +=/-=, and NSubstitute interprets this expression as "raise the event". Raise.EventWith(args) uses the substitute itself as the sender, Raise.EventWith(sender, args) uses the sender you pass; for delegate types other than EventHandler<T> there is Raise.Event<TDelegate>(...).
Unconfigured members return a default value, but what "default" means depends on the type. A string property returns an empty string, numbers return 0, and a method that returns an interface automatically returns a new substitute, a recursive mock. A test that relies on this:
[Fact]
public async Task Unconfigured_factory_returns_a_recursive_substitute()
{
IPaymentGatewayFactory gateways = Substitute.For<IPaymentGatewayFactory>();
gateways.For("TRY").ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.Returns(new ChargeResult(true, "tx-2001", null));
PaymentService sut = new(
gateways, Substitute.For<IFraudCheck>(), Substitute.For<IPaymentAuditLog>(), Substitute.For<INotificationSender>());
PaymentOutcome outcome = await sut.PayAsync(
Guid.NewGuid(), 120m, "TRY", "grace@example.com", "card-token-test", TestContext.Current.CancellationToken);
Assert.Equal(PaymentOutcome.Paid, outcome);
Assert.Same(gateways.For("TRY"), gateways.For("TRY"));
Assert.NotSame(gateways.For("TRY"), gateways.For("EUR"));
}
gateways.For("TRY") returns the same automatic substitute every time and a different one for a different argument, so you can configure the ChargeAsync at the end of the chain directly. Handy, but there is a limit: ChargeResult is a record, a sealed class, so its automatic value is null. In a test where I never configured ChargeAsync, the Task completed but its result was null and the service failed at the result.Approved line with System.NullReferenceException. Do not assume an unconfigured collaborator will quietly return something "reasonable"; when a test fails, an unconfigured call is the first place to look.
NSubstitute.Analyzers
Most NSubstitute mistakes do not show up at run time but in a wrongly configured, green test. The analyzer package catches them at build time. The rules I saw on the broken code I wrote for this post:
warning NS1000: Member Fee can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.
warning NS1004: Argument matcher used with a non-virtual member of a class.
warning NS2000: Can only substitute for parts of classes, not interfaces or delegates. Use Substitute.For<IPaymentAuditLog>() instead of Substitute.ForPartsOf<IPaymentAuditLog>() here.
warning NS2002: The number of arguments passed to NSubstitute.Substitute.For<Bookstore.Payments.PaymentFeeCalculator> do not match the number of constructor arguments for Bookstore.Payments.PaymentFeeCalculator. Check the constructors for Bookstore.Payments.PaymentFeeCalculator and make sure you have passed the required number of arguments.
warning NS5000: Unused received check. To fix, make sure there is a call after "Received". Correct: "sub.Received().SomeCall();". Incorrect: "sub.Received();"
NS5000 is especially valuable: the line audit.Received(); does nothing at run time and the test stays green; you think you wrote a verification but you did not. Add the package to the test project with PrivateAssets="all" and turn these rules into errors with WarningsAsErrors. The analyzer does not see everything: for Substitute.For<PaymentService>() on a sealed class I only got NS2002 about the number of constructor arguments, and no warning about it being sealed. Version 1.0.17 dates from February 2024, so the analyzer knows nothing of the library's 6.x changes; the core rules still apply.
In Production
Keep setup to what the test cares about. The only configuration that holds for every test in the constructor is gateways.For("TRY").Returns(gateway) and ProviderName; each test sets up the payment result itself. Adding a Returns(Approved) to the shared setup forces whoever writes the "declined payment" test to override it, and it is not obvious to the reader which of the two configurations wins (the last one).
Make analyzer warnings errors and check them in CI. The three most dangerous mistakes in this post, the misbound Returns from Predict First, the empty Received behind NS5000 and an unconfigured call returning null, do not turn the test red; the analyzer catches the first two, and only a careful code review catches the third.
Pin the version. NSubstitute 6.0.0 (July 2026) narrowed its targets to net8.0 and netstandard2.0 and enabled nullability in the public API; 6.1.0 reverted the nullability change because it caused problems in user code, and 6.2.0 (11 August 2026) brought better generic call matching and support for C# 13 params collections. A change that came and went within two months is a good example of why test dependencies should not be updated automatically either. The licence is BSD-3-Clause.
Common Pitfalls
Using an argument matcher outside a substitute call. Matchers enter a queue inside NSubstitute the moment they are called and bind to the next substitute call. Storing a matcher in a variable and using it later binds it to the wrong place if other calls come in between:
EmailMessage toAda = Arg.Is<EmailMessage>(m => m.To == "ada@example.com");
gateways.For("TRY").Returns(gateway);
With this code the test failed with RedundantArgumentMatcherException : Some argument specifications (e.g. Arg.Is, Arg.Any) were left over after the last call. The message is long, but it ends with a useful diagnosis ("Remaining (non-bound) argument specifications: m => (m.To == "ada@example.com")") and also says the cause might be in a previously executed test. Always write the matcher directly in the call inside Returns, Received or When.
Substituting a class. The Predict First example: the non-virtual method's real code runs, the virtual calls inside it are intercepted, and Returns binds to a member you did not expect. Use an interface or ForPartsOf with Configure(), and never ignore the analyzer's NS1000 warning.
Miscounting the out parameter index. call[2] depends on parameter order; when a new parameter is added to TryScore, the test still compiles but writes the score to the wrong place. Review such callbacks when signatures change.
A Small Experiment
★☆☆ Fix Wallet_fee_is_three_lira with ForPartsOf and Configure(). Remove Configure() and run it: on which line, with which exception, does it fail?
★★☆ Add a branch to PaymentService that sends the customer a "payment declined" e-mail when the payment is declined. In the test, verify the e-mail's content with Received(1) and that no approval e-mail went out with DidNotReceive.
★★★ Add a rule to SettlementTracker that ignores a second event with the same TransactionId, and test it with Raise.EventWith. When you raise the same event three times, is the total correct?
In the next post we cover the Moq counterpart of the same API family through legacy Ordering tests: Setup, SetupSequence, It matchers, Callback, Verify, strict mocks, Protected() and Mock.Of, with FakeItEasy equivalents alongside.