← Code you can trust STAGE 03 · 30 MIN

Test doubles and seams

Replace a slow or uncontrollable dependency in a test without hiding the behavior you meant to check.

Open in the laboratory ↗

BY THE END, YOU CAN

  • Choose between a stub, a fake, a spy, and a mock for a dependency
  • Give code a seam so a test can supply its own collaborator
  • Recognize when a double lets a test pass while production fails
In a unit test, each collaborator of the worker is replaced by the double that fits what the test checks. A contract test keeps the fake faithful to the real repository.

Diagram description: Worker → Clock (now()); Worker → Job repository (get / save); Worker → Mailer (send()); Worker → Payments (charge()); Contract tests → Job repository (keeps it honest)

Why stand in for a collaborator

Relay's worker reads the clock, loads jobs from a database, and sends email through a provider.

In a unit test the real versions get in the way: the provider is slow, may cost money, sends real messages, and can fail for reasons unrelated to your code. A test double is any object that takes a real dependency's place during a test. The common kinds differ in what they do.

A dummy fills a parameter and is never used. A stub returns answers prepared for the test, such as a clock that always says 10:00. A fake really works but takes a shortcut that rules it out for production, such as a repository kept in a dictionary.

A spy behaves like a stub and also records how it was called. A mock is set up with the calls it expects and fails the test when they do not happen. Pick by what you want to check: the resulting state (a job marked done) or an interaction that is itself the behavior (one email sent).

Python
class RecordingMailer:
    def __init__(self):
        self.sent = []

    def send(self, to, subject):
        self.sent.append((to, subject))

Give the code a seam

A seam is a place where a test can change what the code uses without editing that code.

In Python the simplest seam is a parameter: pass the clock, the repository, and the mailer in, through the function or the constructor, instead of creating them inside. This is dependency injection, and it needs no framework. Code that calls time.time() or builds its own email client has no seam, so its tests turn to unittest.mock.patch, which swaps a name for the length of a test.

Patch the name where the code under test looks it up, not where it is defined, or the swap misses. Patching works, but it ties each test to how a module imports things. When you write the code yourself, a parameter is usually clearer: the test reads like a normal call, and the dependencies are visible in the signature.

Python
def test_finishing_a_job_notifies_its_owner():
    jobs = InMemoryJobs()
    jobs.add({"id": "j1", "owner": "ana@example.com", "state": "running"})
    mailer = RecordingMailer()
    worker = Worker(jobs, mailer, clock=lambda: 1_000)
    worker.finish("j1")
    assert jobs.get("j1")["state"] == "done"
    assert mailer.sent == [("ana@example.com", "Job j1 finished")]

When a double lies

A double encodes what you believe about the real dependency.

When the belief is wrong, the test passes and production fails. A fake repository that silently overwrites a duplicate id hides the unique-constraint error the real database raises. A plain Mock accepts any method name, so it keeps passing after the real client renames charge to create_charge.

Three habits help. Build mocks from the real object, with spec or create_autospec, so a missing attribute or a wrong signature fails the test. Run the same contract tests against the fake and the real implementation, so the fake stays faithful.

Wrap a third-party client in a small adapter you own and replace the adapter, not the library. Finally, avoid checking every internal call: a test that asserts which helpers ran breaks on a harmless refactor and still misses a wrong result.

Python
from unittest.mock import create_autospec

payments = create_autospec(PaymentClient, instance=True)
worker = Worker(jobs, mailer, clock=lambda: 1_000, payments=payments)
worker.finish("j1")
payments.charge.assert_called_once_with("ana@example.com", 500)

COMMON FAILURE MODE

Mocking everything the code touches. The test then repeats the implementation call by call, fails on every refactor, and passes even when the real collaborators would reject what the code sends.

TRY IT YOURSELF

List the collaborators of Relay's worker: the clock, the job repository, the mailer, and the payment client. For each one, choose the double you would use in a unit test and name one way it could differ from the real thing.

RETRIEVAL CHECK / APPLY THE IDEA

Make the call in the laboratory.

The two-question checkpoint, your notes, and your saved progress are in the laboratory. Free, with no account.

Open in the laboratory ↗

CONNECT IT TO RELAY

Pass the clock, the job repository, and the mailer into Relay's worker. Write an in-memory repository and a recording mailer, and run one set of contract tests against both the in-memory repository and the SQLite one.