Mocks vs stubs vs fakes: choosing a test double in C#
Mocks vs stubs vs fakes in C#: what each test double can detect, why interaction-heavy mocks go red on every refactor, and when a hand-written fake wins.
The refactor was supposed to be safe. OrderService.CancelAsync loaded the order twice, once for
a permission check and once to change it, so someone made it load once and pass the instance
along. No behavior changed. Forty-one tests went red. Not one of them had found a bug; every one
had pinned FindAsync to exactly two calls, and the build now says the code is broken on the day
the code is finally right. That is the afternoon a team decides its suite is a cost center, and
the decision that caused it was made months earlier, in a test helper, when someone chose a mock
where a stub or a fake belonged.
The choice of test double is not a style preference. It decides which failures a test can see and which non-failures it will report anyway.
Five doubles, three jobs
Gerard Meszaros named the taxonomy in xUnit Test Patterns, and Martin Fowler's Mocks Aren't Stubs made it common vocabulary. The five names matter less than the three jobs behind them.
A dummy fills a parameter and is never used. A stub supplies canned answers so the code
under test can run: FindAsync returns this order, GetRateAsync returns 0.2. A spy is a
stub that also records what was called, so the test can inspect the record afterwards. A mock
takes expectations up front and fails the test when the conversation deviates. A fake is a
working implementation with a shortcut that makes it unfit for production: an in-memory
repository, a clock you can set, a message bus that keeps messages in a list.
Three jobs, then. Stubs supply state. Spies and mocks verify interactions. Fakes behave. Most mocking libraries can play all three roles from one object, which is exactly how a stub setup turns into an interaction assertion without anyone deciding it should.
What a double supplies, and what it judges
The double decides what the test can see
- Dummy supplies nothing, fills a parameter
test asserts on nothing
- Stub supplies canned answers
test asserts on the returned state
- Spy supplies canned answers plus a call record
test asserts on the record, after the act
- Mock supplies expectations set before the act
test asserts on the conversation
- Fake supplies a working implementation
test asserts on the outcome, read back
stubs supply state, spies and mocks verify calls, fakes behave
Four changes, three test styles
two refactors that break nothing, two bugs that must be caught
a mock lies on refactors, a stub is blind to side effects, a fake sees both
Stubs answer, mocks judge
The mechanism is easiest to see with a hand-rolled spy, no library involved:
public sealed class SpyOrderRepository : IOrderRepository
{
public List<string> Calls { get; } = new();
public Order? Stored { get; set; }
public Task<Order?> FindAsync(int id, CancellationToken ct)
{
Calls.Add($"Find:{id}");
return Task.FromResult(Stored);
}
public Task SaveAsync(Order order, CancellationToken ct)
{
Calls.Add($"Save:{order.Id}");
Stored = order;
return Task.CompletedTask;
}
}
[Fact]
public async Task Cancel_finds_then_saves()
{
var repo = new SpyOrderRepository { Stored = Order.Paid(id: 42) };
var service = new OrderService(repo);
await service.CancelAsync(42, CancellationToken.None);
Assert.Equal(new[] { "Find:42", "Save:42" }, repo.Calls);
}
The assertion is about the conversation. Cache the lookup, save through a unit of work that commits later, split the write into an audit row plus the order: each of these preserves the promise (a paid order becomes a cancelled order) and turns this test red. The test is a transcript of today's call graph, and any refactor that edits the transcript fails it.
Now the same spy, asserting state instead:
Assert.Equal(OrderStatus.Cancelled, repo.Stored!.Status);
Assert.Equal(42, repo.Stored.Id);
Whatever route the service took to change the order, this test checks the destination, and it goes red for exactly one reason: the order did not end up cancelled. The best-practices rule that a test should fail for one reason is really a rule about which double you assert through.
A test that verifies interactions has decided the implementation is the behavior. That is the right decision at a port where the call is the effect, and the wrong one everywhere else.
When the interaction is the behavior
Some effects leave no state you can read back. Completing an order sends a confirmation email; the email is the outcome, and the only way to observe it is to watch the call. That is where a spy earns its place, and where "exactly once" is a real requirement, because a redelivered message that sends twice is a bug your customers will screenshot.
public sealed class SpyEmailSender : IEmailSender
{
public List<Email> Sent { get; } = new();
public Task SendAsync(Email email, CancellationToken ct)
{
Sent.Add(email);
return Task.CompletedTask;
}
}
[Fact]
public async Task Completing_an_order_twice_emails_the_customer_once()
{
var emails = new SpyEmailSender();
var service = new OrderService(new InMemoryOrderRepository(), emails);
await service.CompleteAsync(42, CancellationToken.None);
await service.CompleteAsync(42, CancellationToken.None); // redelivered
var email = Assert.Single(emails.Sent);
Assert.Equal("[email protected]", email.To);
}
What gets verified is one observable side effect at one port, not the order of internal calls. A
useful filter before writing any interaction assertion: if this call did not happen, would anyone
outside the process notice? For SendAsync, yes. For FindAsync, no; only the returned order
matters.
A fake is a small working implementation
The repository in that last test is a fake, and fakes are the double most teams underuse. A dictionary and twenty lines give you a repository with real semantics: save then find returns the order, find on an unknown id returns null, a second save overwrites.
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<int, Order> _orders = new();
public Task<Order?> FindAsync(int id, CancellationToken ct)
=> Task.FromResult(_orders.GetValueOrDefault(id));
public Task SaveAsync(Order order, CancellationToken ct)
{
_orders[order.Id] = order;
return Task.CompletedTask;
}
}
Because it behaves, tests written against it read like the feature: arrange a saved order, act
through the service, assert on what a later FindAsync returns. There is no per-test setup of
canned returns, so a refactor that changes which methods the service calls changes nothing in
the tests. The same idea gives you a fake clock. .NET's
TimeProvider is the seam,
and FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package lets a test set
the time, advance it by 31 days, and assert that an invoice became overdue, with no
Thread.Sleep and no midnight surprises.
Fakes carry one obligation the other doubles do not: they must agree with the real adapter. A fake that returns null where PostgreSQL would throw on a unique violation is quietly rewriting the contract, and every test on top of it passes against your imagination of the database. The fix is a contract test, one abstract test class run against both implementations:
public abstract class OrderRepositoryContract
{
protected abstract IOrderRepository Create();
[Fact]
public async Task Save_then_find_returns_the_order() { /* ... */ }
[Fact]
public async Task Find_unknown_id_returns_null() { /* ... */ }
}
public sealed class InMemoryOrderRepositoryTests : OrderRepositoryContract { /* ... */ }
public sealed class PostgresOrderRepositoryTests : OrderRepositoryContract { /* ... */ }
The in-memory run takes microseconds and gates every commit; the real-database run proves the fake is telling the truth. This is the payoff of a hexagonal design: the port is narrow enough that a fake is cheap and its contract test is short.
Fakes must not ship
A fake is production-shaped code, which is exactly why it must never be reachable from
production. The failure looks innocent. FakePaymentGateway lives next to CheckoutService "so
they stay together", then someone adds a parameterless constructor that defaults to
new FakePaymentGateway(), or a registration that was meant to be temporary:
// "until the sandbox credentials arrive"
builder.Services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
Every caller happens to pass the real gateway today, so the tests are green, and the fallback is
a live path: forget one argument anywhere and checkout starts approving charges against a double
that moves no money. Put fakes in a test or *.Testing assembly the production project cannot
reference, give services one constructor with every dependency required, and treat any default
that constructs a double as a bug. Dependency inversion puts
the port in the core; the adapters, fake and real alike, stay outside it.
Choosing, in order
Start with the least judgmental double the test can get away with:
- A real object, when the collaborator is pure and cheap. Most domain objects qualify.
- A fake at the port, when the collaborator holds state: a repository, a clock, a queue.
- A stub, when you need one canned answer and no state: a rate, a feature flag.
- A spy with an interaction assertion, only where the call is the observable effect, pinning the fewest facts that matter (once, to this address).
- A strict mock with ordered expectations, almost never.
Descend the list only when the level above cannot express the test.
Where the grader disagrees with green
Interaction-only tests have a specific blind spot: the code can compute the wrong answer while
making all the right calls, and the suite stays green. Katabench's
Test Writing exercises grade a suite by how many planted-bug
mutants it catches, the mutation testing loop applied as a
score. A suite that verifies FindAsync ran twice catches none of a discount that rounds the
wrong way; a suite that reads the order's state back catches it. On the design side, the
Architecture kata Fakes Don't Ship hands you a PaymentStub
that has drifted into the core assembly behind a fallback constructor, and the design rules stay
red until the double is out of the production project and the real gateway is impossible to
forget. Both are the same lesson from two directions: choose the double that can see the failure
you care about, and keep it where only tests can reach it.