← All posts
7 min read

Test Doubles and Mocks: Controlled Boundaries

#technology#test-doubles#mocks#software-testing
📑 On this page

A test sometimes needs a dependency to behave in a way that is slow, expensive, dangerous, or difficult to trigger.

Charging a real card to test decline handling is unnecessary. Waiting for a real clock to reach midnight is impractical. Depending on an unstable network makes local feedback unreliable.

A test double replaces a dependency with controlled behavior when that control improves the evidence the test provides.

Replacement is useful at chosen boundaries, but replacing everything can create a fictional system that passes tests and fails in reality.

A concrete example: a declined payment

An order service calls a payment gateway.

A payment fake is configured to return a decline for a test card. The test verifies that the service:

  • returns a useful failure,
  • does not mark the order paid,
  • does not send a receipt,
  • and records the attempt safely.

The test exercises application behavior without contacting a real processor.

The broad term: test double

Test double is the general category for something used in place of a real collaborator.

Common forms are:

  • dummy,
  • stub,
  • fake,
  • spy,
  • and mock.

People use these words inconsistently across tools, so the behavior of the double matters more than winning terminology arguments.

Dummy

A dummy value satisfies an interface but is not used by the behavior under test.

For example, a constructor requires an analytics client, but the tested calculation never calls it. A dummy allows construction while making no meaningful contribution.

If the test unexpectedly uses the dummy, failing clearly is often better than silently returning plausible values.

Stub

A stub returns predetermined responses.

Examples:

  • a weather client returns a fixed forecast,
  • a repository returns one known user,
  • or a clock returns a fixed instant.

The test uses the response to drive a scenario and usually asserts the unit's resulting behavior.

Fake

A fake provides a working but simplified implementation.

Examples include:

  • an in-memory repository,
  • a local email collector,
  • or a payment simulator with defined outcomes.

Fakes can support many tests naturally, but differences from the real implementation must be understood. An in-memory database may not reproduce transactions or query semantics.

Spy

A spy records how it was used.

A test can inspect whether:

  • an email was requested,
  • an event was published,
  • or a cleanup operation occurred.

Spies are useful when an interaction is an observable outcome. Asserting every internal call order makes tests brittle.

Mock

In the stricter meaning, a mock is configured with expected interactions and verifies that they occurred.

For example:

expect paymentGateway.charge(orderTotal) exactly once

Mocking frameworks may use "mock" as the name for all generated doubles, which is why precise conversation should describe what the test controls and asserts.

State versus interaction

State-based testing asks, "What result or state exists after the action?"

Interaction-based testing asks, "How did this object communicate with collaborators?"

Prefer state when it represents the public outcome. Use interactions when communication itself matters, such as publishing a required domain event.

An internal helper call is rarely a durable contract.

Why use a double?

Good reasons include:

  • trigger rare failures deterministically,
  • avoid destructive side effects,
  • remove external cost,
  • control time or randomness,
  • make focused tests fast,
  • and verify an outbound boundary.

"Because every dependency should be mocked" is not a good reason.

Mocking at architectural boundaries

Doubles are often most stable at boundaries the application already owns:

  • payment gateway interface,
  • email sender,
  • clock,
  • repository,
  • message publisher,
  • or external API client.

Mocking deep framework internals or private helpers binds tests to implementation and can make upgrades painful.

Designing explicit boundaries improves both replaceability and test clarity.

Do not mock what you do not own carelessly

Third-party APIs can change, and a hand-written mock may reflect what the team assumes rather than what the provider actually does.

A safer design wraps the external API behind an owned interface, tests application logic against that interface, and separately integration-tests the wrapper against a realistic server or sandbox.

This keeps external complexity at one boundary.

Contract fidelity

A double should model the behaviors relevant to the test, including important failures.

A fake repository that accepts duplicate keys while production rejects them can hide defects. A fake payment client that never times out cannot test recovery.

The more a fake is reused as a substitute for reality, the more its contract needs verification.

Over-mocking

A heavily mocked test may configure:

  • every method call,
  • exact ordering,
  • internal object creation,
  • and private collaboration.

The test then repeats the implementation in a second language. It passes when the code follows the script, not necessarily when useful behavior is correct.

Refactoring breaks both code and test even though the public result is unchanged.

False confidence

Suppose a mocked HTTP client is configured to return the exact response shape the application expects.

The test cannot discover that the real service uses another field name. Only a contract or integration test can provide that evidence.

Every double removes part of reality. A test strategy must deliberately restore confidence at broader levels.

Failure injection

Doubles are excellent for controlled failure:

  • timeout,
  • connection reset,
  • rate limit,
  • malformed response,
  • duplicate message,
  • expired credential,
  • and partial success.

These scenarios are often hard to reproduce reliably with live systems and are essential for testing recovery logic.

Verifying calls without overspecifying

If an order must request one payment, assert the meaningful facts:

  • correct amount,
  • correct currency,
  • stable idempotency key,
  • and no second charge.

Avoid asserting irrelevant details such as the order of unrelated logging calls. The test should protect the contract, not freeze every line.

In-memory repositories

An in-memory repository can make business tests fast.

It should not be treated as proof of:

  • SQL correctness,
  • transaction isolation,
  • database constraints,
  • or persistence behavior.

Use it to test service logic, then add integration tests against the real storage boundary.

Clocks are valuable doubles

Time influences expiration, scheduling, billing, and retention.

An injected clock lets tests represent:

  • before a deadline,
  • exactly at it,
  • after it,
  • daylight-saving transitions,
  • and long elapsed periods.

Changing the system clock globally can interfere with parallel tests; a local clock abstraction is safer.

Maintenance cost

Test doubles are code. They need updates when contracts change.

Shared fakes can reduce repetition, but a huge universal fake becomes another complex system. Keep doubles small, explicit, and owned by the tests that benefit from them.

Delete a double when the boundary no longer exists.

A practical decision checklist

Before replacing a dependency, ask:

  1. Which risk does the replacement help test?
  2. Could the real component run cheaply and reliably instead?
  3. Does the double model the important contract?
  4. Am I asserting public behavior or internal choreography?
  5. Which integration test covers the reality removed?
  6. Will failure output explain the scenario?

The goal is controlled evidence, not maximum isolation.

Knowledge check

  1. What does the general term test double include?
  2. How does a stub differ from a spy?
  3. When is interaction-based testing appropriate?
  4. Why can mocking a third-party response create false confidence?
  5. What makes time a good candidate for an injected dependency?

The one idea to remember

Use test doubles when controlling a boundary makes an important scenario faster, safer, or deterministic. Assert meaningful outcomes, preserve contract fidelity, and use integration tests to cover the reality each double removes.