open atlas
↑ Back to track
NestJS, zero to senior NEST · 06 · 04

Integration testing against a real database

Mocks assert your assumptions; a real database asserts reality. Integration tests run code against a throwaway Postgres (Testcontainers) to catch constraints, cascades, and migration drift mocks can't — paid for with per-test rollback and seconds of runtime.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The unit suite was a wall of green. Every repository was mocked, every service test ran in single-digit milliseconds, coverage sat at 94%. Then a prod INSERT blew up on a UNIQUE constraint the mock had never enforced — the mock happily returned a saved user for an email that already existed, because a mock returns whatever you told it to. Worse, a ON DELETE CASCADE on a foreign key silently wiped a tree of child rows when an admin deleted one parent: no unit test exercised it, because the mock had no notion of foreign keys. Both bugs lived in the gap between what the code assumes the database does and what the database actually does — and that gap is invisible to a mock, by construction. Then they over-corrected: an integration suite with no isolation, all tests sharing one mutable database, went order-dependent and flaky enough to block CI for two days. This lesson is the middle path: test against a real Postgres, isolate every test, and keep it fast enough to live in CI.

Where the pyramid bends: what only a real database can catch

Ask yourself: what is your 94%-coverage mock suite actually testing? It’s testing that your code is consistent with your assumptions about the database — not that the database behaves that way. Here is the specific bug class that gap produces.

Unit tests (mocked dependencies, ~1ms each, many of them) verify your logic in isolation. Integration tests verify that your code plus a real collaborator — the database — actually work together. That distinction is the whole point: a mock returns what you programmed it to return, so it can only ever confirm the assumptions you already hold. The bugs that reach prod from the data layer are exactly the ones your assumptions missed:

  • Constraint violationsUNIQUE, NOT NULL, CHECK, foreign keys. A mocked repo will “save” a duplicate email all day; Postgres raises 23505.
  • Cascade rulesON DELETE CASCADE / SET NULL reshape rows you never explicitly touched. No mock models referential action.
  • Migration drift — the migration that didn’t run, the column that’s still varchar(50), the index that’s missing. Mocks have no schema to drift from.
  • Real ORM mappingJSONB columns, enum types, numeric precision, sequences, snake_case ↔ camelCase. The ORM’s actual SQL generation, not your mental model of it.
  • Transaction & isolation behaviour — what a rollback actually rolls back, what a lock actually blocks.

The cost is real: integration tests need a real Postgres and run in ~10–100ms+ (real IO) instead of ~1ms, and container startup costs ~1–3s once per suite. So you write far fewer of them than unit tests — the pyramid stays a pyramid. You’re not replacing unit tests; you’re adding a thin, high-value layer that asserts the things mocks can’t.

Why not a mocked repo, and why not SQLite

Two tempting shortcuts both fail the same way — by giving you green tests that lie:

// The mock asserts YOUR ASSUMPTION, not reality.
const repo = { save: jest.fn().mockResolvedValue({ id: 1, email: 'a@b.com' }) };
// This "passes" even if email a@b.com already exists in prod and would 23505.
// The mock cannot enforce UNIQUE, cannot cascade, cannot run a migration.

A mocked repository can’t catch a unique violation, can’t fire an FK cascade, can’t surface an N+1, and can’t notice a migration that never ran — because it is your assumption, encoded. SQLite-as-a-fake is the subtler trap: it’s a real database, so it feels honest, but it’s a different engine from prod. Its type affinity, JSONB handling, sequence semantics, and isolation behaviour all diverge from Postgres. You get tests that are green on SQLite and red in production — the worst possible outcome, because you trusted them. The senior stance is unambiguous: integration tests run on the same engine and major version as prod. If prod is Postgres 16, the test container is Postgres 16.

Testcontainers: a throwaway Postgres per test run

@testcontainers/postgresql spins up a real, disposable Postgres in Docker for the duration of the run and hands you a dynamic connection URL. You feed that URL into TypeOrmModule.forRoot in your testing module, run your real migrations against it (never synchronize: true — that skips the very migrations you need to test), execute the suite, and tear the container down. One beforeAll builds it all:

import { Test } from '@nestjs/testing';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { UsersModule } from '../src/users/users.module';

let container: StartedPostgreSqlContainer;
let dataSource: DataSource;

beforeAll(async () => {
  // ~1–3s once: a real Postgres on the SAME major version as prod
  container = await new PostgreSqlContainer('postgres:16').start();

  const moduleRef = await Test.createTestingModule({
    imports: [
      TypeOrmModule.forRoot({
        type: 'postgres',
        url: container.getConnectionUri(), // dynamic host/port — never hardcode 5432
        entities: [/* … */],
        synchronize: false,                // test the REAL migrations, not auto-sync
        migrationsRun: false,              // we run them explicitly below
      }),
      UsersModule,
    ],
  }).compile();

  dataSource = moduleRef.get<DataSource>(getDataSourceToken());
  await dataSource.runMigrations();        // exercise the same SQL prod will run
}, 60_000);                                // generous timeout: pulling the image is slow on a cold cache

afterAll(async () => {
  await dataSource?.destroy();
  await container?.stop();                  // dispose the throwaway DB
});

Running runMigrations() rather than synchronize: true is the load-bearing detail: it means a migration that drops a column, adds a constraint, or backfills data is actually executed in the test, so migration drift surfaces here instead of in prod.

Per-test isolation: the technique that decides whether your suite is trustworthy

Tests must not see each other’s data. The flakiness in the Hook came from shared mutable state — one test inserted a row, another counted rows and got a number that depended on test order. Three strategies, in order of preference:

// (a) PREFERRED — wrap each test in a transaction and ROLLBACK after.
//     Fastest: nothing is ever committed, so there is nothing to clean up.
let qr;
beforeEach(async () => {
  qr = dataSource.createQueryRunner();
  await qr.connect();
  await qr.startTransaction();             // BEGIN
  // hand qr.manager to the code under test so its writes live in THIS tx
});
afterEach(async () => {
  await qr.rollbackTransaction();          // ROLLBACK — every write vanishes
  await qr.release();
});
// (b) ROBUST — TRUNCATE every table between tests. Survives code that commits
//     its own transactions, but slower (real DELETEs + index churn).
afterEach(async () => {
  const tables = dataSource.entityMetadatas.map((m) => `"${m.tableName}"`).join(', ');
  await dataSource.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE;`);
});

// (c) PARALLEL — a fresh schema (or database) per test FILE, so workers never
//     share state. Most isolation, most setup; reach for it when you parallelize.

Strategy (a) is fastest because a rolled-back transaction commits nothing — there’s no cleanup to do. But it has a sharp edge: it breaks the moment the code under test manages its own transaction and commits. If your service opens its own dataSource.transaction(...) and commits inside it, that commit escapes your outer test transaction (or the nested savepoint releases), and your ROLLBACK no longer undoes it. For that code, fall back to (b) TRUNCATE. The trap to internalize: shared mutable DB state + a parallel test runner = order-dependent flakiness. Isolate per test, or per worker — never let two tests touch the same committed rows.

Why this works

Why is a real-DB integration test worth its slowness over a mocked repo? Because the bugs that reach prod from the data layer — constraint violations, cascade deletes, migration drift, ORM-mapping mistakes, isolation and locking surprises — are exactly the class a mock cannot model. A mock returns what you told it to, so it asserts your assumption; the real database asserts reality, including the parts of reality you got wrong. The unique violation, the cascade that wiped child rows, the migration that never ran — none of them are expressible against a jest.fn(). You pay seconds of runtime and a Docker container to buy coverage of the one class of bug that mocks are structurally blind to. That’s not slow testing being tolerated; that’s the only place these defects can be caught before prod.

Pick the best fit

You must test repository and query code with real confidence — catching constraint violations, cascades, and migration drift before prod. How do you set up the test database?

Quiz

Your service is fully unit-tested with a mocked repository and 94% coverage, yet a prod INSERT fails on a UNIQUE constraint and an admin delete silently cascades away child rows. What class of bug did the mocked unit tests structurally fail to catch?

Quiz

For per-test isolation you wrap each test in a transaction and ROLLBACK in afterEach. It's the fastest option — but when does this strategy silently break?

Recall before you leave
  1. 01
    What class of bug does an integration test against a real database catch that a mocked-repo unit test structurally cannot, and why?
  2. 02
    Walk through a Testcontainers integration setup with per-test isolation: container, migrations, testing module, and the isolation strategy — including when the fast one breaks.
Recap

Integration tests occupy the bend in the test pyramid: unit tests use mocks to assert your logic in isolation (~1ms, many), while integration tests run your code against a real collaborator — the database — to assert reality (~10–100ms, fewer). A mock returns what you programmed it to, so it confirms your assumptions and is structurally blind to the data-layer bug class: UNIQUE/FK/CHECK constraint violations, ON DELETE CASCADE rules, migration drift, real ORM/SQL mapping (JSONB, enums, sequences), and transaction/isolation behaviour. That blindness is how a 94%-coverage mocked suite let a UNIQUE violation and a silent cascade-delete reach prod. Don’t fix it with SQLite — a different engine gives green tests that lie; run on the same engine and major version as prod. Testcontainers (@testcontainers/postgresql) starts a throwaway Postgres in Docker once per suite (~1–3s), hands you a dynamic connection URL you point TypeOrmModule.forRoot at, and you run the real migrations (runMigrations, never synchronize:true) so drift fails in the test. Isolate every test or the suite goes order-dependent and flaky: prefer wrapping each test in a transaction and rolling back in afterEach (fastest — nothing commits, nothing to clean), fall back to TRUNCATE between tests when the code under test commits its own transaction (which escapes the outer rollback), and use a fresh schema per file when you parallelize. The discipline: real engine, real migrations, per-test isolation — paid for in seconds, returned as the one bug class mocks can never see. Now when you see a prod UNIQUE violation on a route that was “100% covered”, you’ll know where to add the integration test — and what isolation strategy to pick.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.