Skip to content
Katabench
Try free
8 min read The Katabench team

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

IOrderRepository, IEmailSender
judges nothing behaves
  • 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

Mock: verify callsStub: assert outputFake: read state back refactor Cache the repeat lookup red, no bug stays green stays green refactor Save via a unit of work red, no bug stays green stays green bug Discount rounds the wrong way green, bug ships caught caught bug Confirmation email sent twice caught cannot see it caught

a mock lies on refactors, a stub is blind to side effects, a fake sees both

Verifying calls turns every refactor into a red build and lets a wrong answer ship; asserting on state read back through a fake catches the bugs and ignores the refactors.

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:

  1. A real object, when the collaborator is pure and cheap. Most domain objects qualify.
  2. A fake at the port, when the collaborator holds state: a repository, a clock, a queue.
  3. A stub, when you need one canned answer and no state: a rate, a feature flag.
  4. A spy with an interaction assertion, only where the call is the observable effect, pinning the fewest facts that matter (once, to this address).
  5. 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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.