Mocking Libraries in Depth: NSubstitute, Moq and FakeItEasy
NSubstitute, Moq and FakeItEasy share the same Castle DynamicProxy engine, so they can only fake interfaces and virtual members, and the differences between them are mostly syntax and failure messages. The same payment scenarios in all three: argument matching and capture, sequential returns, async exceptions, call count and order, strict mode, the NS1000 analyzer warning and Moq's 2023 SponsorLink incident.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2, NSubstitute 6.2.0, NSubstitute.Analyzers.CSharp 1.0.17, Moq 4.21.0 and FakeItEasy 9.0.1.
Predict First
ShippingRateClient, which gets the shipping fee from an external service, is a concrete class wrapping an HTTP (HyperText Transfer Protocol) API (Application Programming Interface) we do not own. A developer decides to fake it directly with NSubstitute:
// A concrete class wrapping an HTTP client we do not own
public class ShippingRateClient(HttpClient http)
{
public async Task<decimal> GetRateAsync(string postcode, CancellationToken ct)
{
string body = await http.GetStringAsync($"rates/{postcode}", ct);
return decimal.Parse(body, System.Globalization.CultureInfo.InvariantCulture);
}
}
[Fact]
public async Task Shipping_rate_for_istanbul_is_4_99()
{
ShippingRateClient client = Substitute.For<ShippingRateClient>(new HttpClient());
client.GetRateAsync("34000", Arg.Any<CancellationToken>()).Returns(4.99m);
decimal rate = await client.GetRateAsync("34000", TestContext.Current.CancellationToken);
Assert.Equal(4.99m, rate);
}
Does the code compile? If it does, does the test pass, or does a real HTTP request go out?
The Answer: A Method That Cannot Be Faked
It compiles, but NSubstitute.Analyzers gives two warnings:
ConcreteMockTests.cs(12,9): warning NS1000: Member GetRateAsync can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.
ConcreteMockTests.cs(12,38): warning NS1004: Argument matcher used with a non-virtual member of a class.
And the test fails:
NSubstitute.Exceptions.CouldNotSetReturnDueToNoLastCallException : Could not find a call to return from.
Make sure you called Returns() after calling your substitute (for example: mySub.SomeMethod().Returns(value)),
and that you are not configuring other substitutes within Returns() (for example, avoid this: mySub.SomeMethod().Returns(ConfigOtherSub())).
If you substituted for a class rather than an interface, check that the call to your substitute was on a virtual/abstract member.
Return values cannot be configured for non-virtual/non-abstract members.
NSubstitute, Moq and FakeItEasy use the same technique: all three generate a subclass or an interface implementation at run time with Castle.Core's DynamicProxy and intercept calls in the members they override. Because GetRateAsync is not virtual, it cannot be overridden; the call on the setup line went to the real method instead of the proxy, and Returns found no intercepted call. The bad scenario is this: had the method had a side effect (a real request, writing a file), it would have run during setup.
The same limit applies to static methods, sealed classes, extension methods and non-virtual members. Read it as a design signal rather than a shortcoming of the library: if you want to fake ShippingRateClient, what you need is a seam. We cover seams in post 09, and where the right seam is for an HTTP client you do not own in post 08. In the rest of this post we compare the three libraries with the same scenarios through interfaces.
The Code Under Test and Five Scenarios with NSubstitute
The payment flow: call the payment provider, retry once on a timeout, send a receipt if approved:
public sealed class CheckoutService(IPaymentGateway payments, IReceiptSender receipts)
{
public async Task<CheckoutResult> PayAsync(
Guid orderId, decimal amount, string email, string cardToken, CancellationToken ct)
{
PaymentRequest request = new(orderId, amount, "EUR", cardToken);
PaymentResult result;
try
{
result = await payments.ChargeAsync(request, ct);
}
catch (TimeoutException)
{
// Retry once when the payment provider times out
result = await payments.ChargeAsync(request, ct);
}
if (!result.Approved)
{
return CheckoutResult.Declined;
}
await receipts.SendAsync(email, result.TransactionId!, amount, ct);
return CheckoutResult.Paid;
}
}
The tests with NSubstitute:
public sealed class NSubstituteCheckoutTests
{
private readonly IPaymentGateway payments = Substitute.For<IPaymentGateway>();
private readonly IReceiptSender receipts = Substitute.For<IReceiptSender>();
private readonly Guid orderId = Guid.NewGuid();
[Fact]
public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
{
PaymentRequest? captured = null;
payments.ChargeAsync(Arg.Do<PaymentRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(new PaymentResult(true, "tx-1", null));
CheckoutService sut = new(payments, receipts);
await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), captured);
await receipts.Received(1).SendAsync("ada@example.com", "tx-1", 96m, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Timeout_is_retried_once()
{
payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
.Returns(
_ => throw new TimeoutException(),
_ => new PaymentResult(true, "tx-2", null));
CheckoutService sut = new(payments, receipts);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Paid, result);
await payments.Received(2).ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Declined_payment_sends_no_receipt()
{
payments.ChargeAsync(Arg.Is<PaymentRequest>(r => r.Amount > 0), Arg.Any<CancellationToken>())
.Returns(new PaymentResult(false, null, "insufficient_funds"));
CheckoutService sut = new(payments, receipts);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Declined, result);
await receipts.DidNotReceive().SendAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<decimal>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Receipt_is_sent_after_the_charge()
{
payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
.Returns(new PaymentResult(true, "tx-3", null));
CheckoutService sut = new(payments, receipts);
await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Received.InOrder(() =>
{
payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>());
receipts.SendAsync("ada@example.com", "tx-3", 96m, Arg.Any<CancellationToken>());
});
}
[Fact]
public async Task Gateway_exception_other_than_timeout_propagates()
{
payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("503 Service Unavailable"));
CheckoutService sut = new(payments, receipts);
await Assert.ThrowsAsync<HttpRequestException>(() => sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken));
}
}
A few details. Argument matching: Arg.Any<T>() accepts any value, Arg.Is<T>(predicate) accepts the ones that satisfy the condition. Argument capture: Arg.Do<T> runs an action when the call arrives; because the captured PaymentRequest is a record, we could compare four fields at once with a single Assert.Equal. Sequential returns: Returns takes several values or functions; the first call gets the first, the second call the second. Async exceptions: ThrowsAsync from NSubstitute.ExceptionExtensions returns a faulted Task. Nothing extra is needed for async methods: Returns(value) wraps the value in a completed Task.
The Same Scenarios with Moq and FakeItEasy
In Moq everything is set up through Mock<T> with lambdas and used through .Object:
[Fact]
public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
{
PaymentRequest? captured = null;
payments.Setup(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.Callback<PaymentRequest, CancellationToken>((r, _) => captured = r)
.ReturnsAsync(new PaymentResult(true, "tx-1", null));
CheckoutService sut = new(payments.Object, receipts.Object);
await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), captured);
receipts.Verify(r => r.SendAsync("ada@example.com", "tx-1", 96m, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Timeout_is_retried_once()
{
payments.SetupSequence(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TimeoutException())
.ReturnsAsync(new PaymentResult(true, "tx-2", null));
CheckoutService sut = new(payments.Object, receipts.Object);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Paid, result);
payments.Verify(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task Declined_payment_sends_no_receipt()
{
payments.Setup(p => p.ChargeAsync(It.Is<PaymentRequest>(r => r.Amount > 0), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult(false, null, "insufficient_funds"));
CheckoutService sut = new(payments.Object, receipts.Object);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Declined, result);
receipts.VerifyNoOtherCalls();
}
In FakeItEasy the entry point is A.CallTo; the argument matchers are A<T>._ and A<T>.That.Matches(...). Order verification is chained with .Then(...):
[Fact]
public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
{
A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
.Returns(new PaymentResult(true, "tx-1", null));
CheckoutService sut = new(payments, receipts);
await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
A.CallTo(() => payments.ChargeAsync(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), A<CancellationToken>._))
.MustHaveHappenedOnceExactly()
.Then(A.CallTo(() => receipts.SendAsync("ada@example.com", "tx-1", 96m, A<CancellationToken>._))
.MustHaveHappenedOnceExactly());
}
[Fact]
public async Task Timeout_is_retried_once()
{
A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
.Throws(new TimeoutException()).Once()
.Then.Returns(new PaymentResult(true, "tx-2", null));
CheckoutService sut = new(payments, receipts);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Paid, result);
A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
.MustHaveHappenedTwiceExactly();
}
[Fact]
public async Task Declined_payment_sends_no_receipt()
{
A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>.That.Matches(r => r.Amount > 0), A<CancellationToken>._))
.Returns(new PaymentResult(false, null, "insufficient_funds"));
CheckoutService sut = new(payments, receipts);
CheckoutResult result = await sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);
Assert.Equal(CheckoutResult.Declined, result);
A.CallTo(receipts).MustNotHaveHappened();
}
Together the three test classes have 12 tests, all green. In FakeItEasy's first test, instead of capturing the payment request we verified it directly with record equality: the record passed as the argument is compared with the incoming record through Equals. This works in NSubstitute and Moq too, and in most cases it is more readable than capturing arguments.
Call Count, Order and Failure Messages
When choosing a mocking library, the thing looked at least but read most is the message it gives when a verification fails. The output in the three libraries of the same verification expecting the receipt to be sent with 95 EUR (96 is actually sent):
NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
SendAsync("ada@example.com", "tx-1", 95, any CancellationToken)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
SendAsync("ada@example.com", "tx-1", *96*, System.Threading.CancellationToken)
Moq.MockException :
Expected invocation on the mock once, but was 0 times: x => x.SendAsync("ada@example.com", "tx-1", 95, It.IsAny<CancellationToken>())
Performed invocations:
Mock<IReceiptSender:1> (x):
IReceiptSender.SendAsync("ada@example.com", "tx-1", 96, CancellationToken)
FakeItEasy.ExpectationException :
Assertion failed for the following call:
Bookstore.Checkout.IReceiptSender.SendAsync(email: "ada@example.com", transactionId: "tx-1", amount: 95, ct: <Ignored>)
Expected to find it once exactly but didn't find it among the calls:
1: Bookstore.Checkout.IReceiptSender.SendAsync(
email: "ada@example.com",
transactionId: "tx-1",
amount: 96,
ct: System.Threading.CancellationToken)
All three show the call that actually happened. NSubstitute marks the differing argument with asterisks; FakeItEasy writes the parameter names. For order verification there is Received.InOrder in NSubstitute and .Then(...) in FakeItEasy; Moq has MockSequence, but because it is designed to be used with strict mocks it is seen less often in practice. Use order verification where it really is a business rule: "first the money is taken, then the receipt is sent" is a business rule; "first the customer is queried, then the stock" is usually an implementation detail.
Strict and Loose Behavior
By default all three libraries are loose: an unconfigured call returns a default value (null, 0, a completed Task; NSubstitute and FakeItEasy can also automatically create a new fake for methods that return interfaces). Moq and FakeItEasy support a strict mode, in which every unconfigured call throws an exception:
[Fact]
public async Task Strict_mock_rejects_unconfigured_call()
{
Mock<IPaymentGateway> strict = new(MockBehavior.Strict);
CheckoutService sut = new(strict.Object, receipts.Object);
await Assert.ThrowsAsync<MockException>(() => sut.PayAsync(
orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken));
}
The exception message: IPaymentGateway.ChargeAsync(PaymentRequest { ... Amount = 96, Currency = EUR, CardToken = tok_visa }, CancellationToken) invocation failed with mock behavior Strict. All invocations on the mock must have a corresponding setup. NSubstitute has no strict mode; that is the library's choice.
Strict mocks look attractive ("nothing unexpected may happen"), but they amplify the problem of post 01: the test turns the whole conversation with the collaborator into a contract, and every new query added to the service breaks all the tests. Keep the loose default; where you really mean "this call must never happen", say it explicitly with DidNotReceive, Times.Never or MustNotHaveHappened.
The State of the Libraries and the SponsorLink Lesson
The basic API, license (BSD (Berkeley Software Distribution) or MIT (Massachusetts Institute of Technology) license) and NuGet target frameworks of the three libraries:
| Topic | NSubstitute 6.2.0 | Moq 4.21.0 | FakeItEasy 9.0.1 |
|---|---|---|---|
| Creation | Substitute.For<T>() | new Mock<T>() + .Object | A.Fake<T>() |
| Setup | x.Method(...).Returns(...) | Setup(x => ...).ReturnsAsync(...) | A.CallTo(() => ...).Returns(...) |
| Verification | Received(n) | Verify(..., Times...) | MustHaveHappened...() |
| Strict mode | None | MockBehavior.Strict | o => o.Strict() |
| License | BSD-3-Clause | BSD-3-Clause | MIT |
| Targets (NuGet) | net8.0, netstandard2.0 | net462, netstandard2.0/2.1, net6.0 | net462, net8.0, net10.0 |
NSubstitute 6.0 shipped in July 2026 and narrowed its targets to net8.0 and netstandard2.0. Moq 4.21.0 arrived in September 2026; the previous release was 4.20.72 in 2024. FakeItEasy 9.0 added the net10.0 target in January 2026.
Moq's history contains an incident every .NET team should remember. 4.20.0, released in August 2023, added the closed-source SponsorLink to the package. During the build, SponsorLink took a hash of the developer's git email address and checked their sponsorship status. After the community's privacy backlash it was removed in 4.20.2 on 9 August 2023; the release note gives breaking restore on macOS and privacy concerns about hashing email addresses as the reasons. The lesson is not specific to Moq: test dependencies also run code on your build machine and in CI (Continuous Integration). Instead of auto-merging dependency updates, read the release notes, pin the versions of critical packages, and take updates that change licensing or behavior deliberately. The FluentAssertions license change in post 03 is another face of the same lesson.
Which one to choose? All three are mature and use the same engine; the choice is largely a matter of syntax preference. In this series we use NSubstitute: its syntax is the shortest, and its analyzer package shows the mistake from the start of this post at compile time. If your existing project uses Moq, rewriting tests just to switch libraries is rarely worth it.
In Production
Install the analyzer. NSubstitute.Analyzers catches non-virtual members, argument matchers used in the wrong place, and calls forgotten after Received at compile time. Turning the warnings into errors with TreatWarningsAsErrors means the test in Predict First could never be written.
Keep setup next to the test. A twenty-line shared setup in every test class hides which test relies on which behavior. Set up the return value the test cares about inside the test; only the creation of the double should be shared.
Use mocks only at the system boundary. The payment gateway and receipt sending are at the boundary; PriceCalculator or Order are not. Which dependencies should be mocked is the topic of the next post.
Common Pitfalls
Mocking a concrete class. The example in Predict First. Making the methods virtual silences the warning, but the class's constructor still runs, and every non-virtual member keeps its real behavior. An interface or an adapter is more robust.
A long setup chain in every test. If a test has five Setup and four Verify calls, it describes the behavior of the mocks rather than the service. This usually means either the service has too many collaborators or the test verifies implementation details.
Scattering libraries across the test code. Using three libraries in one project (as in this post) only makes sense for a comparison. In a real project it means every developer learning three syntaxes, plus name clashes like Times in the same file; when I put using directives for Moq and FakeItEasy in the same file I got the build error 'Times' is an ambiguous reference between 'FakeItEasy.Times' and 'Moq.Times'.
A synchronous exception through Returns on an async method. The error of a method that returns Task normally arrives as a faulted Task. An exception thrown synchronously in a test can behave differently from errors caught before an await. Using ThrowsAsync for async methods is closer to production behavior.
A Small Experiment
★☆☆ Extract an IShippingRates interface for ShippingRateClient, add the shipping fee to CheckoutService, and rewrite the Predict First test against the interface. Did NS1000 (NSubstitute.Analyzers' warning for members that cannot be intercepted) go away?
★★☆ Extend Timeout_is_retried_once: what should the service do on two consecutive timeouts? Set up two consecutive TimeoutExceptions with one of the three libraries and verify that the service lets the exception out.
★★★ Rewrite Moq's Declined_payment_sends_no_receipt test with MockBehavior.Strict. How many setups did you have to add to make it green? Then add a log line to CheckoutService; what happened to the strict test?
In the next post we turn the question around: when should you not use a mock? Output-, state- and communication-based test styles, the system boundary, an HttpMessageHandler stub for HttpClient, and the "don't mock what you don't own" rule.