Mocks that pass while production breaks
A mock is an assertion about how a dependency behaves, written by someone who wasn't sure. It says: when I call this, I get that. Your tests then verify your code against that assertion. If the assertion is wrong, you have a green suite that proves your code correctly handles a system that doesn't exist.
That's not an argument against mocking — mocks are essential for speed and for simulating failures you can't easily produce. It's an argument for knowing which of your mocks are load-bearing.
The in-memory database
The most consequential example, because it's the most widely adopted. Test against SQLite or H2 instead of Postgres for speed, and inherit a long list of behavioural differences that only appear in production.
- Type strictness. SQLite will happily store the string 'banana' in an integer column. Postgres raises an error. A whole category of type bug is invisible in one and fatal in the other.
- Concurrency. SQLite serialises writes; Postgres has MVCC with row-level locking. Every deadlock, every lost update, every serialisation failure — untestable on SQLite because the concurrency model that produces them isn't there.
- SQL dialect. Window functions, `RETURNING`, JSONB operators, `ON CONFLICT`, arrays, full-text search. Either unsupported or subtly different.
- Constraint timing. Deferred constraints, `ON DELETE` cascade ordering, and when exactly a unique violation surfaces all differ.
- Query planning. There is no such thing as testing an index's effectiveness against a different engine with a different planner.
The trade used to be defensible because running a real Postgres per test was genuinely slow. That's the part that changed: a restored database snapshot starts in well under a second, which removes the speed argument that justified the fidelity loss.
// Fast AND real: fork a prepared database per test instead of substituting
// a different engine for it.
beforeEach(async () => {
db = await Sandbox.fork(preparedSnapshot); // sub-second
process.env.DATABASE_URL = db.connectionUrl;
});
afterEach(() => db.delete()); // destroyed, not reset — no cleanup logicThird-party APIs
Different situation, different answer. You can't call Stripe's live API in CI, and you shouldn't want to. Here mocking is correct — but the failure mode is the same and it's worth naming.
Your mock returns the response you saw in the documentation. The real API returns that response plus fourteen fields you ignored, sometimes returns a different shape for edge cases, occasionally rate-limits you, and changes over time without telling your test suite.
Three things narrow that gap, in increasing order of effort:
- Record real responses and replay them. Tools that capture actual HTTP traffic and replay it in tests give you mocks that were, at least once, true. Re-record periodically so drift is visible as a diff.
- Use the provider's official test environment or emulator. Stripe's test mode, LocalStack for AWS, the vendor's sandbox — these are maintained by the people who know the real behaviour, which is a much better source of truth than your memory of the docs.
- Run a contract test against the real API on a schedule, outside your main suite. Nightly, hitting a test account, asserting the shapes your code expects. It won't block a PR, but it tells you the day an upstream change breaks an assumption — which is otherwise something you learn from a customer.
Your own services
For services you control, mocking has an additional cost that's easy to miss: mocks don't get updated when the real thing changes. Team A alters a response shape; Team B's tests keep passing against a mock encoding the old shape, right up until the deploy.
Two credible approaches. Consumer-driven contract testing, where the consumer publishes what it expects and the provider verifies it in their CI — this catches drift at the right moment, in the provider's pipeline, before it ships. Or, for smaller systems, just run the real thing: if a service and its dependency can both boot into an ephemeral environment in seconds, the mock's speed advantage largely disappears and you get to test the actual wire format.
// Bring up the real dependency graph, not stand-ins for it
const env = await Sandbox.fork(stackSnapshot); // api + worker + postgres + redis
await env.exec("./scripts/wait-for-ready.sh");
const res = await fetch(`${env.url}/v1/orders`, { method: "POST", body });
expect(res.status).toBe(201);
// Serialisation, auth, middleware, transactions — all genuinely exercisedWhat should always be mocked
The argument runs in both directions. Some things must be substituted, and the reasons are worth stating so the rule isn't applied by feel.
- Time. Never let a test depend on the real clock. Inject it, freeze it, control it — otherwise you get a suite that fails at midnight, on the 29th of February, or across a daylight saving boundary.
- Randomness. Same reasoning. Seed it so failures reproduce.
- Failure modes you can't produce on demand — timeouts, connection resets, partial writes, a disk filling. These are exactly what mocks are for, and they're the ones most teams never test.
- Anything expensive or irreversible. Sending real emails, charging real cards, calling a paid API in a loop.
- Anything slow with no fidelity benefit. A three-second `sleep` in a retry path should be injectable.
Note the shape of that list: mock what you can't control or don't want to happen, use the real thing for what you're actually testing. The common failure is inverting it — mocking the database, which is the thing under test, while using the real clock, which isn't.
A rule that holds up
The most useful heuristic I've found: mock at the boundary of what you own, use the real thing inside it.
Your database is inside the boundary — it's part of your system, its behaviour is part of your behaviour, and a query is not an implementation detail. Stripe is outside — you don't control it, you can't run it, so you test against their emulator and verify the contract on a schedule.
The corollary that matters: as running real dependencies gets cheaper, the boundary should move outward. A lot of mocking practice was calcified around infrastructure costs from a decade ago, when starting a database per test really was prohibitive. If a real Postgres with realistic data now costs you a second and no cleanup code, the reason to substitute something else is mostly habit — and habit is a poor reason to test against a system you don't run.
Frequently asked questions
Is it bad to test against SQLite when production uses Postgres?
It costs you a specific and important set of coverage. SQLite has dynamic typing, so it accepts values Postgres rejects; it serialises writes, so deadlocks, lost updates and serialisation failures are structurally untestable; and it differs on window functions, RETURNING, JSONB, ON CONFLICT, arrays and constraint timing. You also cannot test index effectiveness against a different query planner. The trade was defensible when running a real Postgres per test was slow — a restored database snapshot now starts in well under a second, which removes the reason.
What should I always mock in tests?
Time and randomness, so tests do not fail at midnight or on a leap day and failures reproduce. Failure modes you cannot produce on demand — timeouts, connection resets, partial writes, a full disk — which are exactly what mocks are for and what most suites never exercise. Anything expensive or irreversible, like sending real emails or charging real cards. And anything slow with no fidelity benefit, such as a sleep in a retry path. The common mistake is inverting this: mocking the database, which is under test, while using the real clock, which is not.
How do I test against a third-party API I cannot call in CI?
In increasing order of effort: record real HTTP responses and replay them, so your mocks were at least true once and re-recording surfaces drift as a diff; use the provider's official test mode or emulator, which is maintained by people who know the real behaviour better than your reading of the docs; and run a contract test against the real API on a nightly schedule outside your main suite. That last one is the highest value and the least commonly done — it converts 'the API changed and we found out in production' into a failed scheduled job.
Should I mock other services my team owns?
Prefer not to, because mocks of internal services do not get updated when the real service changes — Team A alters a response shape and Team B's tests keep passing against a mock encoding the old one until the deploy. Consumer-driven contract testing catches this at the right moment, in the provider's pipeline before it ships. For smaller systems, running the real dependency graph in an ephemeral environment is often simpler than maintaining contracts, and it exercises serialisation, auth and middleware that a mock skips entirely.
What is a good rule for deciding what to mock?
Mock at the boundary of what you own, and use the real thing inside it. Your database is inside the boundary — it is part of your system and a query is not an implementation detail. A payment provider is outside — you do not control it and cannot run it, so use their emulator and verify the contract on a schedule. The corollary is that as running real dependencies gets cheaper, the boundary should move outward; much current mocking practice calcified around infrastructure costs that no longer apply.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.