Test Doubles
By the end of this lesson
Distinguish stubs, mocks, fakes and spies, and pick the right one.
A test double is any stand-in you put in place of a real dependency while testing.
The name comes from the film industry: a stunt double stands in for the actor when the real thing is impractical. The same logic applies here. OrderService needs an IOrderRepository, and the real one talks to a database. For a test about discount arithmetic, a database is slow, needs setup, and can fail for reasons that have nothing to do with the arithmetic.
The word "mock" is often used for all of these, which is why people end up with tests that do the wrong thing. The four kinds behave differently, and the difference determines whether your test survives a refactor.
The four kinds, by what they do:
- Stub
- Returns canned data. You configure it to answer a question a fixed way — Find always returns this one order — so the test can reach the code it cares about. A stub makes no claims about whether it was called.
- Fake
- A working, lightweight implementation. An in-memory repository backed by a dictionary genuinely stores and retrieves. It behaves like the real thing without the infrastructure, so tests can save something and then read it back.
- Spy
- Records what happened to it — which methods were called, with what arguments — and lets the test inspect that afterwards. It observes; it does not impose expectations.
- Mock
- Comes with expectations about how it will be used, and fails the test when they are not met. "Save must be called exactly once with this argument." The assertion is about the interaction, not the result.
There is a fifth name you will meet occasionally: a dummy. It is an object passed in only to satisfy a constructor, never called. If a test needs a logger it does not care about, the logger is a dummy.
The same dependency, four ways
public interface IOrderRepository
{
Order? Find(Guid id);
IReadOnlyList<Order> FindByEmployee(string employeeId);
void Save(Order order);
}- An interface is what makes substitution possible. OrderService is given an IOrderRepository and has no idea which implementation it received.
- In production it gets the one that talks to the database. In tests it gets whichever double suits the test.
- This is dependency injection doing the work. The testability is not a happy accident — it is the main practical reason to inject dependencies rather than construct them inside the class.
public class StubOrderRepository : IOrderRepository
{
private readonly Order? _order;
public StubOrderRepository(Order? order) => _order = order;
public Order? Find(Guid id) => _order;
public IReadOnlyList<Order> FindByEmployee(string employeeId) => Array.Empty<Order>();
public void Save(Order order) { }
}- Find returns the same order regardless of the id. That is deliberate — the test is not about lookup, so the stub does not pretend to look anything up.
- Save does nothing. A stub is allowed to ignore calls it is not there to support.
- Useful when the test needs the dependency to answer a question so the real logic can run. Not useful when the test needs to check what was stored.
public class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<Guid, Order> _orders = new();
public Order? Find(Guid id) =>
_orders.TryGetValue(id, out Order? order) ? order : null;
public IReadOnlyList<Order> FindByEmployee(string employeeId) =>
_orders.Values.Where(o => o.EmployeeId == employeeId).ToList();
public void Save(Order order) => _orders[order.Id] = order;
}- This one works. Save something, find it again, and you get it back — the same contract the database-backed implementation honours.
- Tests can therefore assert on outcomes: after calling Submit, the stored order has the submitted status. That assertion holds whatever the service does internally.
- One fake serves a whole test class, and often a whole test project. The cost is writing it once and keeping it honest as the interface grows.
public class SpyOrderRepository : IOrderRepository
{
public List<Order> SavedOrders { get; } = new();
public Order? Find(Guid id) => SavedOrders.FirstOrDefault(o => o.Id == id);
public IReadOnlyList<Order> FindByEmployee(string employeeId) => SavedOrders;
public void Save(Order order) => SavedOrders.Add(order);
}- The recording is the point: SavedOrders is a public list the test can examine after the act.
- A spy does not fail on its own. The test decides what matters — usually the content of what was saved rather than how many times Save ran.
- In practice a good fake often doubles as a spy, because being able to read the stored state is exactly what you need.
Where mocks earn their place, and where they do not
The difference that matters in daily work is what the assertion is about:
| Asserting on state (stub or fake) | Asserting on interaction (mock) | |
|---|---|---|
| The test checks | The outcome — what the system now holds or returns | The conversation — which methods were called, how often |
| After a refactor that preserves behaviour | Still passes | Often fails, because the conversation changed |
| What a failure tells you | The result is wrong | The implementation is different, which may or may not be a problem |
| Genuinely appropriate for | Most tests of business logic | Dependencies with no observable result — sending an email, publishing an event |
| Main risk | The fake drifts from the real implementation | Tests that break on every refactor and get deleted |
Summary
- A test double is any stand-in for a real dependency, and an interface is what makes substitution possible
- Stub returns canned data, fake is a working lightweight implementation, spy records calls, mock asserts on interactions
- Most tests should use a stub or a fake and assert on the outcome
- Mocks fit dependencies with no observable result, such as sending an email or publishing an event
- Fakes can drift from the real implementation, which is what integration tests are for
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Pick the double
For each of these, decide which kind of double you would use and why:
1. Testing that a total is calculated correctly for an order loaded from the repository.
2. Testing that submitting an order stores it with the submitted status.
3. Testing that submitting an order requests a confirmation email.
4. Testing that a constructor rejects a null repository.
Show solution
1. A stub. The test needs one order to exist so the calculation can run. Nothing about the repository's behaviour is under examination.
2. A fake. You need to store and then read back, and the assertion is about stored state. An in-memory repository gives you that without pretending.
3. A mock or a spy on the email service. There is no state to inspect, so the only observable outcome is the request itself. Assert on the recipient and content rather than on a call count.
4. A dummy — or in fact no double at all, since you pass null deliberately. The test is about the guard clause, and nothing gets called.
Try it yourself
Write the fake, then use it
Write InMemoryOrderRepository as shown, then use it to test that submitting an order stores it with the submitted status.
Then change OrderService so that Submit updates the order in place and calls Save once at the end, instead of saving twice. Run your test again.
Show solution
The test still passes. It asserted on the stored result, and the stored result did not change — only the route to it did.
That is the property you are buying. A mock verifying Save was called twice would have failed on a refactor that improved the code and changed nothing a user could observe.
Keep the fake in a shared test folder rather than nesting it inside one test class. It will be useful to every test that touches OrderService.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.