Test doubles are useful when a test needs control over a dependency that would otherwise be slow, unpredictable, expensive, or difficult to observe. They can also make a test suite fragile when every internal interaction is replaced and asserted.
The goal is not to avoid test doubles. It is to use the least powerful double that gives the test the control or evidence it needs.
Start with the reason for replacing a dependency
Before introducing a double, identify what makes the real dependency unsuitable for this test.
Common reasons include:
- the dependency talks to an external system;
- its result must be deterministic for a particular scenario;
- invoking it would make a small test unnecessarily slow;
- the test needs to observe an important outgoing interaction;
- constructing the real dependency requires unrelated infrastructure.
If none of these apply, using the real implementation may produce a simpler and more trustworthy test.
A double should remove a testing obstacle, not automatically replace every collaborator.
Use a stub when the test only needs an answer
A stub returns controlled data to the code under test.
Suppose a pricing service needs an exchange rate:
exchange rate stub:
USD -> EUR = 0.90The test can then verify the pricing result without depending on a live rate provider.
The important point is that the test cares about the resulting behavior, not whether the pricing service called a particular method exactly once. Adding interaction assertions would introduce constraints the requirement does not need.
Prefer a stub when controlled input from a dependency is sufficient.
Use a fake when realistic behavior matters
A fake is a working but simplified implementation of an interface. An in-memory repository is a common example.
A fake can be valuable when many tests need richer behavior than a collection of fixed responses can provide:
fake repository:
save(record)
find(id)
delete(id)Unlike a stub, the fake maintains meaningful state and follows part of the real dependency’s contract.
Fakes need discipline. If production uses a database with uniqueness constraints, transaction boundaries, or query semantics that the in-memory fake ignores, tests may pass against behavior that production does not provide.
Keep important contract assumptions shared and verify the real adapter separately with integration tests.
Use a spy when observation is enough
A spy records what happened so the test can inspect it afterward.
For example, a notification port might record sent messages:
send(message)
recorded messages:
[message]After executing the operation, the test can assert that the expected notification was emitted.
This is often easier to understand than configuring a sequence of interaction expectations before the behavior runs. The test reads as arrange, act, then inspect the observable effect.
A spy is particularly useful for boundaries where the outgoing action itself is part of the required behavior.
Use mocks for meaningful interaction contracts
A mock is typically configured with expectations about calls and verifies whether those expectations were satisfied.
Mocks are appropriate when an interaction is itself an important contract. Examples include ensuring a transaction is committed only after successful work or confirming that a sensitive operation is sent to an audit boundary.
They become harmful when tests assert incidental call structure:
expect helper A once
expect helper B twice
expect helper C before helper DIf users of the code cannot observe those details and correctness does not depend on them, the assertions mainly preserve the current implementation. A harmless refactoring can then break many tests without changing behavior.
Mock boundaries, not arbitrary internal structure.
Prefer observable outcomes inside a module
Tests within a module are usually more resilient when they verify state changes, returned values, emitted domain events, or other stable outcomes.
Imagine an order operation that internally validates inventory, calculates a total, and builds a receipt. Tests do not usually benefit from mocking those internal steps individually. Doing so turns the test into a duplicate description of the implementation.
Instead, supply the order input and verify the meaningful result.
This leaves engineers free to extract functions, combine steps, change algorithms, or reorganise private collaborators without rewriting tests that still describe valid behavior.
Treat difficult mocking as design feedback
A class that requires ten mocks before it can be tested is often telling you something about its design.
Possible causes include:
- too many responsibilities have accumulated in one component;
- dependencies are exposed at a lower level than the component actually needs;
- domain logic is mixed with I/O orchestration;
- boundaries are unclear;
- construction performs work instead of merely establishing valid state.
Adding a mocking framework can make the immediate test possible, but it does not remove the underlying coupling.
Consider whether a smaller interface, a clearer module boundary, or separation between pure decisions and side effects would make both production code and tests easier to reason about.
Do not fake what you need to learn about
A double cannot prove that the real dependency behaves the same way.
If correctness depends on SQL transaction semantics, HTTP serialization, filesystem permissions, framework routing, or a third-party protocol, some tests must exercise the real boundary or a sufficiently representative environment.
A useful test strategy often combines layers:
- focused tests use stubs or fakes to exercise decisions quickly;
- adapter tests verify real integration behavior;
- a smaller number of broader tests verify that important pieces work together.
The layers answer different questions. More mocking in the focused layer does not eliminate the need for integration evidence.
Keep doubles small and explicit
A test double should expose only behavior needed by the tests that use it. Large reusable doubles tend to grow into alternative implementations of production systems.
When a double gains complex branching, persistence rules, timing behavior, or extensive configuration, ask whether the tests are trying to simulate too much.
Small doubles are easier to understand and less likely to encode accidental assumptions.
Name them according to their role when that improves clarity:
FixedExchangeRateStub
InMemoryOrderRepository
RecordingEmailSenderThe name tells a reader what control or observation the test receives.
Choose based on the evidence the test needs
A practical decision sequence is:
- Use the real dependency when it is fast, deterministic, and appropriate for the test scope.
- Use a stub when only controlled responses are required.
- Use a fake when tests benefit from a lightweight implementation with meaningful behavior.
- Use a spy when the test needs to inspect an outgoing effect after execution.
- Use a mock when a specific interaction is an important part of the contract.
- Add integration coverage wherever a double cannot establish confidence in real boundary behavior.
These categories are less important than the design principle behind them: introduce only the control and observation the test actually needs.
Tests should protect behavior, not freeze code
A good test suite gives engineers confidence to change implementation while preserving intended behavior. Test doubles support that goal when they isolate unstable boundaries and make important scenarios deterministic.
They work against it when they reproduce the internal call graph in expectations.
Use doubles deliberately, keep them near meaningful boundaries, and let unnecessary mocking pressure reveal places where the production design may be too tightly coupled. The result is not only a more maintainable test suite, but often a clearer software design as well.