Skip to content

Real vs Fake Dependencies

The previous page let two services agree on a boundary without deploying both together — a consumer’s expectations, replayed against a provider, catching the seam disagreement early. That was a technique for one specific kind of collaborator: another service you talk to over the network. This page zooms out to the more general question that every integration test has to answer first: for each thing my system-under-test talks to — the database, the cache, the queue, the clock, the third-party API — do I use the real one, or a stand-in?

There is no single answer, and anyone who gives you one is selling something. What there is is a spectrum, a small number of named points on it, and a rule for choosing among them that holds up under pressure. That is what we build here.

Recall from the overview that a collaborator is anything your system-under-test (SUT) talks to. For each collaborator you get to dial how real it is, and that dial trades fidelity against speed and stability. The dial has four well-known detents, ordered here from most real to least:

HIGH FIDELITY HIGH SPEED
slow, flaky-prone fast, hermetic
│ │
▼ ▼
┌──────────────┐ ┌───────────────┐ ┌───────────────┐ ┌────────────┐
│ real, shared │ │ containerized │ │ in-memory │ │ mock / stub│
│ instance │ │ real (Testc.) │ │ fake │ │ │
└──────────────┘ └───────────────┘ └───────────────┘ └────────────┘
the actual the actual a hand-written a scripted
prod-like engine, but approximation answer, no
deployment spun up per run of the engine real engine
  • Real, shared instance — you point the test at an actual running Postgres/Redis/Kafka that lives somewhere (a shared dev database, a staging cluster). Highest fidelity to production, but tests share state, so they collide, and one flaky night in staging fails everybody’s build.
  • Containerized real instance (Testcontainers) — you spin up the actual engine in a throwaway Docker container, one per test run, seeded fresh. You are testing against the real Postgres query planner, the real Redis eviction, the real Kafka partition semantics — not an approximation. Isolated, reproducible, and only a few seconds slower to start.
  • In-memory fake — a lightweight working implementation you (or a library) wrote: SQLite standing in for Postgres, a HashMap standing in for Redis, an in-process channel standing in for Kafka, a controllable fake clock standing in for wall time. Fast and hermetic, but it is a different program with different bugs. It runs in your process with no I/O, so it is essentially free to spin up and tear down per test — the reason fakes populate the wide base of the pyramid rather than its tip.
  • Mock / stub — no engine at all. A mock is a scripted object that returns “when get("k") is called, return Some("v")” and optionally asserts how it was called. Fastest, most isolated, lowest fidelity: it tests your code against your idea of the dependency, which is exactly the idea that boundary bugs come from.

The names matter less than the axis. As you move left you buy fidelity — the test resembles production, so a pass means more. As you move right you buy speed and hermeticity — the test runs in milliseconds with no external moving parts, so you can run it on every keystroke. Every choice of collaborator is a bet about where on this line the risk you are retiring actually lives.

Make it concrete with one SUT. Suppose you are testing an order handler that (1) writes an order to Postgres, (2) caches the total in Redis, (3) stamps a created_at, and (4) calls a payment API. Four collaborators, and you get to set the dial independently for each:

Postgres ──► container (real engine) — you depend on its constraints & tx
Redis ──► in-memory fake — you only need "a" cache here
the clock ──► fake clock (fixed instant)— you want determinism, not wall time
payment API ──► recorded fake + nightly real smoke — external, rarely touched

Nothing forces all four to the same setting, and forcing them to would be a mistake: a test that containers everything is needlessly slow, and a test that fakes everything (including Postgres, whose constraints are the point) proves nothing about the write. Choosing per collaborator is the whole skill.

Fake vs mock vs stub — the words, made precise

Section titled “Fake vs mock vs stub — the words, made precise”

These three get used interchangeably in the wild; this book keeps them apart, following the classic taxonomy.

DoubleWhat it isExample
StubReturns canned answers to calls. No behaviour, no assertions.clock.now() always returns 2026-01-01T00:00:00Z.
MockA stub that also asserts it was called the expected way.Verify email.send() was called exactly once, with this address.
FakeA real, simplified working implementation.An in-memory key-value store that actually stores and retrieves.

The practical difference: a fake has behaviour and can be shared across many tests as a drop-in dependency; a mock encodes an expectation about an interaction and belongs to one test. Over-using mocks is how a suite ends up “testing the mock” — every test green, every assumption about the real dependency unverified.

Testcontainers: the real engine, disposable

Section titled “Testcontainers: the real engine, disposable”

The Testcontainers pattern (libraries exist for Java, Go, Rust, Python, JS, .NET, and more; the idea predates all of them) is the sweet spot most integration tests should aim for. Instead of approximating Postgres, you run Postgres — the exact version production runs — inside a Docker container that lives only for the test run.

test run starts
docker run postgres:16 ──► container gets an ephemeral port
│ │
▼ ▼
wait until it accepts run migrations, seed fixtures
connections (a READY signal, not sleep(5))
point the SUT's DATABASE_URL at that container
tests run against the real engine ──► container torn down

What this buys you that a fake cannot: you test against the actual query planner, the actual type coercions, the actual transaction isolation levels, the actual constraint and migration behaviour. A SELECT ... FOR UPDATE, a JSONB operator, a partial index, an ON CONFLICT upsert — these exist in real Postgres and do not exist, or behave differently, in SQLite. If your code depends on them, only the real engine can tell you the truth.

One detail is easy to get wrong: wait on a readiness signal, not a timer. A container’s process starts before the engine is accepting connections, so docker run then sleep(5) is both slower than it needs to be and flaky when the machine is loaded (five seconds isn’t enough under CI contention). Good Testcontainers setups poll the real readiness condition — “the port accepts a connection and a trivial SELECT 1 succeeds” — and proceed the instant it’s true. This is the same “wait on a condition, not the clock” discipline the flaky-tests cures page generalizes; it shows up here first because a real dependency is the first place a naive test reaches for sleep.

Under the hood — isolating tests that share one real engine

Section titled “Under the hood — isolating tests that share one real engine”

If you reuse a single container across a suite (you should), you now have the shared-state problem the real shared instance had — one test’s rows leak into the next. Three standard cures, cheapest first:

  1. Transaction rollback per test. Begin a transaction at the start of each test, run the test inside it, and roll back at the end. The database returns to its prior state with no truncation and no reconnection. Fast, but breaks if the code under test manages its own transactions or commits.
  2. Schema (or database) per test. Give each test its own Postgres schema or namespace, so they cannot see each other’s tables. More isolation, slightly more setup.
  3. Truncate-and-reseed between tests. Delete all rows and reload fixtures. Simplest to reason about, slowest of the three.

This is the same isolation discipline the rust/kvlite server test uses at the network layer: it binds 127.0.0.1:0 so the OS hands out a fresh, unused port per run, and spawns a fresh in-process database, so no two runs share TCP state or stored keys. Isolation is not a database-only concern — it applies to every real collaborator.

In-memory fakes: fast, hermetic, and quietly lying

Section titled “In-memory fakes: fast, hermetic, and quietly lying”

A fake is a working implementation, so it is far more honest than a mock — it actually stores and retrieves, actually enforces some invariants. It runs in-process with zero I/O, which makes it wonderful for the wide base of the pyramid. The kvlite unit tests lean on exactly this: an in-memory Database (a HashMap behind a lock) is the whole store, so tests exercise real get/set/expiry logic at memory speed, with no server and no socket.

The danger is subtle precisely because the fake works. It works almost like the real thing, and the gap is where bugs hide:

  • Dialect drift. SQLite accepts SQL that Postgres rejects, and vice versa. SQLite is dynamically typed by default, silently storing a string in an integer column; Postgres would raise. Your fake test is green; production throws a constraint error on the first real write.
  • Behaviour drift. A real Redis evicts keys under memory pressure and expires them on a background sweep; a HashMap “Redis” never evicts, so a bug that only appears under eviction is invisible. A real Kafka can deliver a message twice; an in-process channel delivers exactly once, hiding the missing idempotency you need in prod.
  • Concurrency drift. A real engine has its own locking and isolation semantics; a fake often serializes everything behind one mutex, so a race that a real transaction would expose can never occur in the fake.
What the fake proves: "my code is correct AGAINST MY MODEL
of the dependency."
What production needs: "my code is correct against the REAL
dependency's actual behaviour."
The bug lives in the gap between the model and the engine —
and the fake, by construction, cannot see into that gap.

None of this makes fakes bad. It makes them scoped. A fake is the right tool when the collaborator’s exact engine behaviour is not the thing under test — when you are testing your handler’s control flow and just need a store that behaves plausibly. It is the wrong tool the moment your correctness depends on a specific behaviour of the real engine.

The special case: third-party APIs you don’t control

Section titled “The special case: third-party APIs you don’t control”

A database or a queue is yours — you can run it in a container. A third-party API (a payment processor, a mapping service, an SMS gateway) is someone else’s, and it changes the trade-offs in two ways.

First, you usually cannot run the real one in a container, and even if a sandbox exists, hitting it in every test makes your suite depend on their uptime, their rate limits, and your network. That is a lot of borrowed flakiness for a boundary you touch rarely. So the default is to fake it — but fake it against a recorded view of its real responses, not a guessed one. Record real request/response pairs once (VCR-style cassettes, a recorded HTTP proxy) and replay them; you get a fast hermetic test that is still anchored to how the API actually answered.

Second, the real risk with a third-party API is not “does my happy-path call work” — it is their error and edge behaviour: the 429 rate-limit, the 503 during their outage, the malformed body, the field they renamed in a minor version. Those are exactly the cases a hand-written stub gets wrong, because you stub what you think they return. This is why a contract test (the previous page) or a periodic real smoke test against their sandbox earns its keep: it verifies the recorded fake still matches reality, so your fast replay-based tests stay honest.

every commit ──► fast tests replay RECORDED third-party responses (hermetic)
nightly / on ──► a few REAL calls to their sandbox (catches drift)
their release └─ if these diverge from the recording, re-record and fix

The rule: fake what’s boring, use the real thing for what you depend on

Section titled “The rule: fake what’s boring, use the real thing for what you depend on”

Here is the rule that survives contact with real projects:

Fake what you own and what is boring. Use the real thing (or a container) for anything whose specific behaviour you are actually depending on.

Unpack both halves.

  • “What you own and is boring.” A clock, a UUID generator, a config loader, a feature flag, an in-memory queue you wrote — these are simple, deterministic, and you control their semantics. A fake clock that returns a fixed instant is not lying about anything, because time has no interesting behaviour you depend on beyond “it advances.” Fake it; you gain determinism (no more sleep-based flakiness) for free.
  • “What you depend on.” If your query uses a Postgres JSONB operator, a window function, a FOR UPDATE lock, or a specific isolation level, then Postgres’s behaviour is your logic. Faking it with SQLite tests a different program. Use a container. Likewise, if you depend on Redis eviction, on Kafka’s at-least-once delivery, or on a third-party API’s exact error shapes, you are depending on their behaviour — approximate it and the test proves nothing about the risk you care about.

The rule is really a single question wearing two coats: does my code’s correctness ride on this collaborator’s own behaviour? If no, the collaborator is an implementation detail you can safely replace with the cheapest stand-in that keeps the test running. If yes, the collaborator is part of the thing you are actually claiming works, so faking it hollows out the claim. “Boring” and “depended-on” are just the two ends of that one question.

A quick decision aid:

Does the correctness of THIS test depend on the collaborator's
own, specific behaviour (dialect, eviction, ordering, isolation)?
┌── yes ──► use the REAL engine (containerized, disposable)
└── no ──► is the collaborator slow, non-deterministic,
or external (network, wall clock, paid API)?
┌── yes ──► FAKE it (in-memory / fake clock)
└── no ──► use the real one; it's cheap

How this choice ripples through the rest of the Part

Section titled “How this choice ripples through the rest of the Part”

The collaborator you pick is not a local decision; it sets up everything the next three pages deal with. Three consequences follow directly, and each is a topic in its own right further along in this Part.

  • Speed and CI cost. Every real dependency you add multiplies runtime and consumes CI minutes (which are billed). A suite that starts twelve containers is slower and more expensive than one that fakes eleven of them and containers the one that matters. The pyramid’s economics from the overview are, in practice, mostly this choice repeated hundreds of times.
  • The flakiness surface. This is the big one. Every real collaborator is a new source of non-determinism — a container that starts slowly, a network blip, a shared port, a clock that ticks mid-assertion. The flaky-tests pages that follow are, to a large degree, a catalogue of failure modes that only exist because a real dependency is in the loop. Choosing a fake where a fake is legitimate is not just a speed optimization — it removes an entire class of flakiness before it can be introduced.
  • What the end-to-end tests uniquely add. Because you fake aggressively lower down, the thin E2E cap is the only place the whole system runs with everything real at once. That is what makes it uniquely valuable and uniquely expensive — it is the far-left end of this page’s spectrum, applied to every collaborator simultaneously.
  • Reproducibility across machines. A fake behaves identically on your laptop and in CI; a shared real instance does not, because its state depends on who touched it last. Containerized real instances split the difference — real engine, but reset per run — which is why “container + isolate per test” is the pattern that keeps a real dependency reproducible instead of turning it into a source of “works on my machine.”

The through-line: choosing collaborators is choosing where each test sits in the fidelity/speed/stability triangle from the overview — one boundary at a time, with eyes open about what each choice hides and what it costs.

A useful way to sanity-check any integration test you write: for every faked collaborator, ask “if this fake and the real engine disagreed, would this test still pass?” If the honest answer is yes for a collaborator whose behaviour you actually depend on, the test is buying you false confidence — it will stay green through exactly the failure it was supposed to catch. Move that one collaborator to a container, leave the rest faked, and re-ask. When every faked collaborator is one you genuinely don’t depend on, the test sits at the right point on the spectrum: as fast and hermetic as it can be without lying about the risk it exists to retire.

  • Why does this spectrum exist? Because “use the real dependency” and “fake everything” are both wrong as blanket rules — the first is too slow and flaky to run often, the second tests your assumptions instead of reality. The spectrum exists so you can pick a different fidelity level per collaborator, per test.
  • What problem does it solve? It lets a test run fast and hermetically for the collaborators whose behaviour you do not depend on, while still testing against the real engine for the ones whose behaviour is your logic — buying confidence where it matters without paying for it everywhere.
  • What are the trade-offs? Fidelity versus speed and stability, per collaborator. Move toward real (containers) and a pass means more but the suite runs slower and gains new flakiness surfaces; move toward fakes and it runs in milliseconds but can pass while production would fail.
  • When should I avoid the real thing? When the collaborator is slow, non-deterministic, external, paid, or simply not the thing under test — a wall clock, a UUID source, a third-party API, an in-house queue you own. There, a fake is more powerful, because you can drive it into states the real one won’t reach on demand.
  • What breaks if I remove the ability to fake? Your suite becomes all-real: slow, expensive, and saturated with flakiness, so it runs rarely and gets ignored — and you lose the ability to reproduce rare-but-critical states (leap seconds, eviction, duplicate delivery) that real dependencies won’t perform on cue.
  1. Name the four points on the fidelity/speed spectrum of test doubles, in order, and say what moving from one end to the other buys and costs.
  2. Distinguish a stub, a mock, and a fake. Which one is a real working implementation, and which one is most likely to lead to “testing the mock”?
  3. What does a containerized real Postgres (Testcontainers) prove that an in-memory SQLite fake cannot? Give two concrete engine behaviours.
  4. State the rule for choosing a real dependency versus a fake, and apply it to (a) a wall clock and (b) a query that uses a Postgres FOR UPDATE lock.
  5. Explain the link between “add another real dependency” and the flakiness pages later in this Part. Why is choosing a legitimate fake also a flakiness decision?
Show answers
  1. From most real to least: real shared instance → containerized real instance (Testcontainers) → in-memory fake → mock/stub. Moving toward the real end buys fidelity (a pass resembles production, so it means more) at the cost of speed and stability (slower, more sources of flakiness). Moving toward the fake end buys speed and hermeticity (millisecond, no external moving parts, runnable on every save) at the cost of fidelity (the test can pass while production would fail).
  2. A stub returns canned answers with no assertions. A mock is a stub that also asserts it was called the expected way (an interaction expectation). A fake is a real, simplified working implementation. The fake is the working implementation. Over-relying on mocks leads to “testing the mock” — every test green while every assumption about the real dependency stays unverified.
  3. Testcontainers runs the actual Postgres engine, so it proves behaviour tied to that engine that SQLite lacks or differs on. Two of: real JSONB/window-function/ON CONFLICT semantics; real type coercion and constraint enforcement (SQLite is dynamically typed and silently accepts mismatches); real transaction isolation levels; real SELECT ... FOR UPDATE locking; real migration behaviour.
  4. Rule: fake what you own and is boring; use the real thing (or a container) for anything whose specific behaviour you actually depend on. (a) A wall clock is boring and non-deterministic and you don’t depend on its internals — fake it with a controllable clock (and you gain the ability to test leap seconds/DST). (b) A query using FOR UPDATE depends on Postgres’s real locking/isolation semantics — that behaviour is your logic — so use the real engine in a container; SQLite would test a different program.
  5. Every real dependency you add is a new source of non-determinism — slow container start, network blips, shared ports, clocks ticking mid-assertion — which is precisely the raw material of the later flaky-tests pages. So choosing a legitimate fake (for a collaborator whose behaviour you don’t depend on) doesn’t just speed the test up; it removes an entire class of flakiness before it can ever be introduced, making the fake-vs-real choice a stability decision as much as a fidelity one.