Integration testing
covers Testcontainers lifecycle and Ryuk · container reuse · fixtures, factories, builders · database isolation strategies · stubbing external APIs · fast parallel CI suites
What an integration test checks
Unit tests prove your logic in isolation. An integration test proves the wiring: the SQL your ORM emits, the schema mapping, a transaction boundary, the serialization your HTTP client does before it hits the wire. These only misbehave when real components talk to each other, so the honest way to test them is against the real thing, not a stand-in.
Real thing has a specific meaning here. Use the actual database engine you ship (Postgres, not SQLite pretending to be Postgres) and the real third-party client (the real Stripe SDK), but intercept the third party's network responses so you are not calling their servers in every run. Fowler splits integration tests into narrow (you exercise the code that talks to one dependency) and broad (live versions of everything, more like a system or end-to-end test). His narrow tests use test doubles; a single real dependency in a container is the same narrow scope, still short of a broad live-all-services run. This module lives in the narrow lane.
Spinning up a real dependency with Testcontainers
Testcontainers is a library that starts a throwaway Docker container for a test run and gives you its address. You get a real Postgres for the duration of the suite, then it is torn down.
import { PostgreSqlContainer } from "@testcontainers/postgresql";
const pg = await new PostgreSqlContainer("postgres:16").start();
const url = pg.getConnectionUri(); // host + MAPPED port, not 5432
// run migrations against url, point your app at url, run tests
await pg.stop();
Two things trip people up. First, the port. Testcontainers publishes the container's 5432 to a random free port on the host so parallel runs and any local Postgres never collide, so you must read getMappedPort(5432) or the connection URI. Hardcoding localhost:5432 works on your laptop and breaks in CI.
Second, started is not ready. start() resolves when the container process launches, but Postgres needs a moment before it accepts connections. Testcontainers waits for a readiness signal for you (for Postgres, a log/port check), and for a plain GenericContainer you pick the signal with a wait strategy such as Wait.forListeningPorts(), Wait.forLogMessage(/ready to accept/), or Wait.forHttp("/health", 8080). Skip this and your first test races the database and flakes.
Keeping tests from stepping on each other
The fastest way to a flaky, un-parallelizable suite is shared mutable data. If every test imports the same "standard user" from a fixtures file and then edits it, test order starts to matter and two tests running at once corrupt each other.
Wrong "one shared set of fixtures every test reuses keeps setup DRY." It couples the tests to each other. Change the standard user for one test and you break three others, and you can never run them in parallel.
Right give each test its own fresh, uniquely-keyed data. That is what factories and builders are for (next section). Then reset the database between tests so nothing leaks. The cheap resets are per-worker isolation or a rolled-back transaction; the senior notes cover the tradeoffs.
Fixtures, factories, and builders
These three are ways to get an object into your test, from most rigid to most flexible.
A fixture is a canned object you reuse. Fowler's Object Mother is the tidy version: factory methods like aStandardCustomer() that return a familiar, pre-built object. It reads well and it is shared vocabulary, but it couples many tests to that exact data, so as tests need variations you accrete method after method.
A factory is a function that builds a fresh object each call, ideally with a unique key so two tests never collide:
let n = 0;
const makeUser = (over = {}) => ({ email: `u${n++}@test.dev`, plan: "free", ...over });
A builder (Nat Pryce's Test Data Builder, the answer to Object Mother's rigidity) starts from sensible defaults and lets each test override only the field it cares about, keeping the arrange step readable:
const user = aUser().onPlan("pro").build(); // everything else is a default
Reach for a builder when objects are complex and each test tweaks a different corner; a plain factory is enough when the shape is small.
Do not mock the third party's SDK
backend-testing covers why you don't mock what you don't own; here is the concrete seam. Let the real client run and intercept it at the HTTP boundary. In Node, MSW's setupServer patches the http/https modules so a real outgoing request is caught and answered by your handler; the real Stripe client did its real work up to the socket. WireMock does the same from the outside, as a fake HTTP server you point the client at. Either way you are stubbing the network, not the library. Calling a third party's live sandbox in the normal suite is the other trap: it is slow and it flakes on their network, so keep it out of the fast lane.
Testcontainers lifecycle, and who cleans up
The container's life is: pull the image, create and start it, wait for a readiness signal, hand you the mapped port, run your tests, stop and remove it. The interesting part is the last step, because a crashed test process cannot run its own teardown. That is what Ryuk is for.
Ryuk (the resource reaper, image testcontainers/ryuk) is a small sidecar container Testcontainers starts alongside your test run, with the Docker socket mounted. Every container, network, and volume Testcontainers creates gets tagged with a session label, and the client opens a TCP connection to Ryuk (default port 8080) carrying that label, its "death note". When the connection drops, whether the suite finished cleanly or the JVM/Node process was killed, Ryuk waits out a reconnection timeout and then deletes everything carrying that session's labels. So containers get reaped even if your process is kill -9'd mid-test, which is why you rarely see leaked containers.
TESTCONTAINERS_RYUK_DISABLED=true turns the reaper off entirely (some CI environments that forbid privileged sidecars or auto-clean the Docker host do this on purpose), and RYUK_CONTAINER_IMAGE / TESTCONTAINERS_RYUK_PRIVILEGED pin the image and privilege level. Disabling Ryuk means cleanup falls back to the client's own shutdown hooks, so a hard-killed run leaks containers until something else prunes them.
Container reuse, and why it is a local-only trick
Starting a fresh Postgres per suite costs seconds; over a tight edit-run loop that adds up. Reuse keeps one warm container across runs.
It is opt-in on two sides. The container calls .withReuse(true), AND the machine enables it via TESTCONTAINERS_REUSE_ENABLE=true or testcontainers.reuse.enable=true in ~/.testcontainers.properties (a classpath properties file does not count, per the docs). On start(), Testcontainers hashes the container's configuration and looks for a running container already labeled with that hash; a match is reused instead of a new start. Change any config that feeds the hash and you get a new container.
The catch that matters for design questions: reused containers are excluded from Ryuk's session cleanup by design, so they are not stopped when the suite ends. The docs state reuse is not suited for CI, and the reason is structural. A CI runner is ephemeral, so the "warm" container dies with the workspace and there is nothing to reuse across jobs; worse, if a runner did persist you would inherit a container holding last run's committed rows. Reuse is a local developer speedup. In CI you want a clean container and you get parallelism elsewhere.
Isolation strategy is a parallelism decision
How you reset the database between tests decides whether you can run them in parallel at all. Three common strategies, with the tradeoff that bites:
| Strategy | Speed | Parallelism | Where it breaks |
|---|---|---|---|
| Transaction rollback | Fastest reset | Good, one shared instance | Code that commits or opens its own connection |
| TRUNCATE / DELETE between tests | Slow (FK checks, cascades) | Poor on shared tables | Needs per-worker DB to parallelize safely |
| Database or schema per worker | Migration cost per DB | Best | Container/migration overhead |
Rollback wraps each test in a transaction that never commits and is rolled back in teardown. It is faster than TRUNCATE and it parallelizes because the shared instance never accepts writes. Its hard limit: the test and the code under test must share the one connection. If your handler opens its own pool it will not see the test's uncommitted rows, and any COMMIT it issues is outside the rollback, so both the isolation and the assertion quietly break. Rollback also cannot test code whose behavior depends on its own transaction boundaries.
TRUNCATE leaves committed state and pays for foreign-key checks and cascades, so it is the slow option, and because the data is really there, two workers on the same tables corrupt each other. Database-or-schema-per-worker is the strongest isolation and the clean road to parallelism: hand each Jest/JUnit worker its own database (or its own schema in one Postgres), migrate once, and let workers run flat out. You pay in migration time per database, which reuse of a template database or a schema clone can cut.
Practical default for a parallel suite: schema-per-worker for the general case, rollback for the read-heavy handlers where you control the connection.
Stubbing the third party at the HTTP boundary
The rule from test-doubles and Google's "don't mock what you don't own" has a concrete shape here: intercept the external API at the network layer, below your client, so the real client still runs.
Two tools, same seam. WireMock runs as a real HTTP server (standalone or in-process) that you point the client at; you register stubs with stubFor(get(urlEqualTo("/v1/charges")).willReturn(...)), and a priority system (1 highest, default 5) lets a catch-all sit under specific stubs. MSW's setupServer patches Node's http/https modules in-process, so you do not redirect the client anywhere; a real outgoing request is intercepted and answered by a handler, with server.listen() / server.resetHandlers() / server.close() bracketing the suite and onUnhandledRequest: "error" catching calls you forgot to stub.
Wrong "mocking the SDK object and stubbing at the HTTP boundary are the same test, just different syntax." They are not. jest.mock("stripe") skips the client entirely, so serialization, auth headers, idempotency keys, and retry-on-5xx never execute and the test cannot catch a bug in any of them. Boundary stubbing exercises all of that and only fakes the socket, so the test survives an SDK refactor and can assert on the actual bytes your client sends.
Keep the real third-party sandbox out of this suite. A live sandbox call is a broad test in Fowler's sense: it flakes on their network and rate limits, so run it on a separate slow lane or fold that confidence into a contract test (owned by the contract-testing module), not the fast integration run.
Keeping the suite fast and green in CI
The levers, in rough order of payoff. Parallelize, which per-worker isolation unlocks; every serial --runInBand suite is leaving wall-clock on the table. Start containers once per worker, not per test, and share the schema via migrations run at boot. Stub all external HTTP so no test depends on someone else's uptime. Make data unique per test (factories/builders) so nothing is contended and order cannot matter.
Wrong "the suite is flaky, so add automatic retries and move on." backend-testing covers why a blanket retry only buries a real race; the integration-suite fix is the levers above, applied to the specific flake: per-worker isolation, boundary stubbing, and unique data per test, plus waiting on readiness instead of sleeping. Retries are for genuinely non-deterministic infrastructure, applied narrowly, never the flake strategy for the whole suite.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
gapmap pro
You've read the Integration testing notes. An interviewer will ask you to prove them.
Know you're ready. Don't hope.
The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:
Mock interviews on your topics
An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.
Voice test interviews
The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.
A plan to your date
Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.
A mentor inside every lesson
Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.
Pro $20/mo · Pro+ $50/mo · every study note on the site stays free