Test doubles and seams
Replace a slow or uncontrollable dependency in a test without hiding the behavior you meant to check.
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
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).
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.
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.
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.
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.