What Testcontainers gives a NestJS test suite, and the bugs mocks miss

Testcontainers starts a real, disposable PostgreSQL in Docker from your test code, hands the suite its connection details, and removes the container when the run ends. Your NestJS integration and e2e tests then use the same database engine, SQL dialect and DDL rules as production, with no shared database to maintain.

The reason to use it is the set of bugs that substitutes cannot detect. A mocked repository never executes SQL, so it returns whatever the test told it to return. SQLite and in-memory databases do execute SQL, but in a different dialect, so a query can pass in the test and fail in production, or pass for the wrong reason.

Bug class Mocked repository SQLite / in-memory Real Postgres container
Unique-index and FK violations Never raised Raised, with different error codes Raised as in production
Transactions, isolation, SELECT ... FOR UPDATE Not modelled Different locking model Real behavior
JSONB (@>, ?) and array operators Not executed Missing or different Supported
Native enums Not checked Not available Enforced
timestamp with time zone Not checked Stored as text or number Real conversion
LIKE case sensitivity Not executed Case-insensitive for ASCII Case-sensitive
Migration DDL (e.g. CREATE INDEX CONCURRENTLY inside a transaction) Never run Fails or behaves differently Fails as it would on deploy

Backend and platform engineers get the most from this approach when their services depend on Postgres-specific features, hand-written SQL, row locking or a migration history that has to apply cleanly.

How the pieces fit: container, migrations, Nest app and Jest workers

Testcontainers asks the local Docker daemon to start a throwaway Postgres container. It waits until the database accepts connections and maps the container's port to a random free port on the host. Using a random port means two suites, or two CI jobs on the same runner, never compete for 5432. When the test process exits, Testcontainers tears the container down. A companion reaper container also removes leftovers if the process crashes.

Once the database is up, apply the same migrations you ship to production, through the same tool and in the same order. Do not let the ORM synchronize the schema from your entity definitions. Synchronization builds the schema your code expects, which is not necessarily the schema your migrations produce. That gap is exactly where drift, missing indexes and broken constraints hide.

Next, the test builds a Nest testing module from the real AppModule. Only the database configuration is overridden, so it points at the container's host, port and credentials. Everything else stays real: providers, pipes, guards and the repository layer. You then call createNestApplication() and init(), and HTTP requests go through the full stack.

Jest runs test files in separate worker processes, so connection details have to cross process boundaries. The usual approach is environment variables, which the config module reads at boot.

There are two lifecycle models:

  • Per file: start a container in beforeAll and stop it in afterAll. Isolation is strong, but you pay startup cost once per file.
  • Shared: start one container in globalSetup and export its details to process.env. Workers spawned afterwards inherit those variables, and globalTeardown stops the container.

Container startup and migrations usually dominate total suite time, so this choice matters more than most other tuning.

+-----------------------------+   start    +-------------------------------+
| globalSetup (shared model)  |----------->| Docker daemon                 |
|  or beforeAll (per file)    |            |  +-------------------------+  |
|                             |            |  | postgres container      |  |
| 1. start container          |            |  | :5432 -> host:<random>  |  |
| 2. apply real migrations ---+----------->|  +-------------------------+  |
|    (same tool, same order;  |            |  reaper removes leftovers     |
|     no ORM synchronize)     |            |  if the process crashes       |
| 3. export DB_HOST, DB_PORT, |            +-------------------------------+
|    DB_USER... -> process.env|                            ^
+-----------------------------+                            |
              |                                            |
              | env vars inherited by workers              | SQL
              v                                            |
+-----------------------------------------------------+    |
| Jest worker (one process per test file)             |    |
|                                                     |    |
|  Test.createTestingModule({ imports: [AppModule] }) |    |
|    override: DB config <- process.env               |    |
|    real: providers, pipes, guards, repositories ----+----+
|  app = createNestApplication(); await app.init()    |
|                                                     |
|  HTTP --> controller --> service --> repository     |
+-----------------------------------------------------+
              |
              v
+-----------------------------------------------------+
| globalTeardown / afterAll: stop the container       |
+-----------------------------------------------------+

Setup starts a Postgres container on a random host port, applies the project's real migrations, and passes connection details through environment variables to Jest workers, which boot the real AppModule with only the database config overridden; teardown then stops the container.

Implementing it: start Postgres, run migrations and boot the Nest app

Start a pinned Postgres container

In globalSetup, start a PostgreSqlContainer from the @testcontainers/postgresql module. Pin the image to the same major version that production runs, and use an image that ships the extensions your schema needs, such as a PostGIS image if you use it. A floating latest tag lets a test run pass against behaviour that production doesn't have. The module's default wait strategy holds start() until Postgres accepts connections, so you don't need sleeps.

After start, read the connection details from the container: getHost(), getPort(), getUsername(), getPassword() and getDatabase(), or getConnectionUri() for a single string. Testcontainers maps 5432 to a random free host port, so a hardcoded port breaks as soon as two suites or a local Postgres share the machine.

Run the real migrations

Apply the same migrations that production uses. With TypeORM, that means dataSource.runMigrations(). With Prisma, it means prisma migrate deploy. Don't use synchronize: true, because it skips the migration files and can hide broken ones.

Boot the Nest app

In the test file, call Test.createTestingModule({ imports: [AppModule] }). Use overrideProvider(ConfigService) to point it at the container's values. If your ORM module is registered with forRootAsync, you can instead make its factory read the environment variables that globalSetup wrote. Then call createNestApplication(), apply the same global pipes as main.ts, and call await app.init(). Write each supertest assertion against app.getHttpServer(), for example a POST followed by a GET that reads the row back.

Shut down in order

In afterAll, call await app.close(). This runs the onModuleDestroy hooks, which close the ORM pool. Stop the container last, in globalTeardown. If you stop the container first, the pool is left holding dead sockets, Jest reports open handles, and the worker can hang.

import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { DataSource } from 'typeorm';
import request from 'supertest';
import { AppModule } from '../src/app.module';

jest.setTimeout(60_000); // the first run may need to pull the image

describe('Users (e2e)', () => {
  let container: StartedPostgreSqlContainer;
  let app: INestApplication;

  beforeAll(async () => {
    // Pin the major version production runs. start() waits until Postgres accepts connections.
    container = await new PostgreSqlContainer('postgres:16-alpine').start();
    // The host port is random. Read it from the container instead of assuming 5432.
    // AppModule's TypeORM forRootAsync factory reads DATABASE_URL through ConfigService.
    process.env.DATABASE_URL = container.getConnectionUri();

    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // same pipes as main.ts
    await app.init();
    // Apply the real migration files. The config must have synchronize: false.
    await app.get(DataSource).runMigrations();
  });

  afterAll(async () => {
    await app?.close();      // runs onModuleDestroy, which closes the TypeORM pool
    await container?.stop(); // stop Postgres only after the pool is gone
  });

  it('creates a user and reads it back', async () => {
    const server = app.getHttpServer();
    const created = await request(server).post('/users').send({ email: 'ada@example.test', name: 'Ada' }).expect(201);
    const res = await request(server).get(`/users/${created.body.id}`).expect(200);
    expect(res.body.email).toBe('ada@example.test');
  });
});

A condensed single-file version of the flow that uses beforeAll instead of globalSetup: it starts a pinned Postgres container, passes its mapped connection URI to the Nest app, runs the real TypeORM migrations, makes a supertest round trip, and closes the app before stopping the container.

Isolating tests and running them in parallel

Shared state between tests is the next problem. There are three common ways to reset it.

Strategy Reset cost Breaks when
TRUNCATE ... RESTART IDENTITY CASCADE after each test One statement per test, slower as table count grows Rarely. Every write is really committed.
Per-test transaction, rolled back Near zero Code opens its own transactions, uses a second connection, or depends on commit-time checks
Fresh database per worker, cloned from a migrated template One CREATE DATABASE ... TEMPLATE per worker The template has open connections, which makes Postgres refuse the clone

The rollback approach is fast, but it is not realistic. If a service calls its own transaction manager, the result is either a nested transaction your ORM may not map to a savepoint, or a separate pooled connection that cannot see the test's uncommitted rows. Deferred constraints are only checked at commit, and a rolled-back test never commits, so those violations never surface. The same applies to triggers and LISTEN/NOTIFY. Default to truncation, and use rollback only for code paths you know are single-connection.

For parallelism, give each Jest worker its own database or schema, named from JEST_WORKER_ID. Clone the databases from a template in globalSetup so migrations run once. Within a worker, truncate between tests. The alternative is --runInBand, which removes contention but makes wall-clock time roughly the sum of every test file.

Seed data through small factories such as createUser(app, overrides). Each factory should call the real repository or service, so validation and defaults apply. Each test should create only the rows it needs. A large shared seed file couples unrelated tests and hides which data a test actually depends on.

import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { Client } from 'pg';
import { UsersService } from '../src/users/users.service';
import { CreateUserDto } from '../src/users/dto/create-user.dto';

// globalSetup has already run migrations into app_template, then disconnected.
const adminUrl = process.env.PG_ADMIN_URL!; // e.g. postgres://test:test@localhost:5432/postgres
export const dbName = `test_w${process.env.JEST_WORKER_ID ?? '1'}`;

// Call before the Nest app opens its pool. Each worker gets its own migrated copy.
export async function createWorkerDb(): Promise<void> {
  const admin = new Client({ connectionString: adminUrl });
  await admin.connect();
  await admin.query(`DROP DATABASE IF EXISTS ${dbName}`);
  // Fails if anything is still connected to app_template.
  await admin.query(`CREATE DATABASE ${dbName} TEMPLATE app_template`);
  await admin.end();
}

// afterEach: commit-realistic reset that leaves the schema and migration history intact.
export async function truncateAll(client: Client): Promise<void> {
  const { rows } = await client.query<{ tablename: string }>(
    `SELECT tablename FROM pg_tables
     WHERE schemaname = 'public' AND tablename <> 'migrations'`,
  );
  if (rows.length === 0) return;
  const tables = rows.map((r) => `"public"."${r.tablename}"`).join(', ');
  await client.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
}

// Factory goes through the real service so validation, defaults and hooks apply.
export async function createUser(
  app: INestApplication,
  overrides: Partial<CreateUserDto> = {},
) {
  const users = app.get(UsersService);
  return users.create({ email: `user-${randomUUID()}@example.test`, name: 'Test User', ...overrides });
}

Per-worker test helpers that clone a database from a migrated template using JEST_WORKER_ID, truncate all application tables between tests, and seed one user through the real UsersService.

Running the suite in CI

Docker access and image pulls

Testcontainers needs a Docker daemon it can reach. On hosted runners that already have Docker, that usually works with no setup. In containerized CI jobs you have two options. You can mount the host's Docker socket, which is simple, but sibling containers then run on the host network. You can also run a Docker-in-Docker service, which isolates the daemon but needs privileged mode. Pull the pinned Postgres image in an earlier step, or cache it, so the first test does not absorb a cold pull and hit its timeout.

Cleanup and shared setup

Do not rely on afterAll to stop containers. A crashed worker or a cancelled job skips it. The Testcontainers reaper (Ryuk) watches the session and removes any orphaned containers when the session's connection drops.

Start the container once in Jest's globalSetup. Then write its host, mapped port and credentials to process.env. Workers are spawned after globalSetup finishes, so they inherit those variables. Values set on globalThis there are visible only to globalTeardown, which is where you stop the container.

Flakiness sources

  • Readiness waits: Postgres logs "ready" once during init and again after it restarts. Waiting on a bare port check races that restart. Keep the module's default log-based wait.
  • Hardcoded ports: Fixed ports collide across parallel jobs on the same host. Always read the mapped port at runtime.
  • Leaked connections: If the app or pool is not closed, Jest hangs after the run. Close both in afterAll, and use --detectOpenHandles to find which handles are still open.
  • Order dependence: Incomplete truncation lets one test's rows leak into the next. Assert that the database is empty at test start to surface the problem.
// jest.config.ts (excerpt): run once per Jest invocation, not per worker
//   globalSetup: '<rootDir>/test/global-setup.ts',
//   globalTeardown: '<rootDir>/test/global-teardown.ts',

// test/global-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';

export default async function globalSetup(): Promise<void> {
  // Pin the same tag the CI job pre-pulls so no test pays for a cold pull.
  // The module's default log-based wait handles Postgres's init restart.
  const container = await new PostgreSqlContainer('postgres:16-alpine')
    .withDatabase('app_test')
    .withUsername('test_user')
    .withPassword('test_password')
    .start();

  // Workers are spawned after this returns, so they inherit process.env.
  // Always use the mapped port. Never hardcode 5432.
  process.env.DB_HOST = container.getHost();
  process.env.DB_PORT = String(container.getPort());
  process.env.DB_USER = container.getUsername();
  process.env.DB_PASSWORD = container.getPassword();
  process.env.DB_NAME = container.getDatabase();

  // globalThis here is shared only with globalTeardown, not with workers.
  (globalThis as any).__PG_CONTAINER__ = container;
}

// test/global-teardown.ts
export default async function globalTeardown(): Promise<void> {
  // Best-effort stop. If this never runs (crash, cancelled job),
  // the Ryuk reaper removes the container when the session drops.
  await (globalThis as any).__PG_CONTAINER__?.stop();
}

A Jest globalSetup that starts one pinned Postgres container and passes its mapped connection details to workers through process.env, paired with a globalTeardown that stops the container and falls back on Ryuk when teardown is skipped.

When to use it, when to avoid it, and the alternatives

Container-backed tests pay off when the code under test depends on PostgreSQL behaviour. Examples include constraint violations mapped to HTTP errors, transaction isolation, ON CONFLICT upserts, JSONB queries, row locking, and migrations that must apply cleanly. Pure business logic, such as pricing rules or state transitions, belongs in unit tests. Booting a database to check arithmetic only slows feedback.

Avoid this approach, or plan around it, in these cases:

  • No Docker daemon in CI. Some locked-down runners don't provide one.
  • Rootless runtimes. Some rootless setups need extra socket or networking configuration before Testcontainers can reach them.
  • Architecture mismatch. ARM laptops pulling an image built only for another architecture fall back to emulation, or fail outright.

The tests also give false confidence if the container doesn't match production. A floating tag like latest changes underneath you. A missing extension such as pgcrypto or PostGIS turns a production failure into a test that never runs. Pin the major version and install the same extensions you deploy.

Approach Fidelity Speed Isolation Setup effort
Mocked repositories Low: SQL never runs Fastest Full Low
SQLite / in-memory substitute Medium-low: different dialect and types Fast Full Medium
Shared long-lived test DB High, until it drifts Fast Poor: shared state Low initially, high to maintain
CI service container High Fast Per job, not per worker Low in CI, none locally
Testcontainers High Slower startup Per run or per worker Medium

None of these replace unit tests. Keep most coverage in fast unit tests, and use container-backed tests for the database contract that mocks can't verify.

Impact on cost and engineering time

Every container start costs time before the first test runs: pulling the image (cached after the first run), starting Postgres, waiting for readiness and applying migrations. That cost recurs for every container, so the choice between per-file and shared containers sets the baseline duration of the suite.

Illustrative estimate (assumed numbers, not a benchmark). Assume S = 5 s to start a container and migrate it, F = 40 test files, W = 4 Jest workers, and T = 60 s of total test execution.

  • Per-file containers: (F × S) / W + T / W = (40 × 5) / 4 + 60 / 4 = 65 s
  • One container per worker: S + T / W = 5 + 15 = 20 s

With these assumptions, startup takes most of the per-file run. Replace the inputs with your own measurements to compare the two layouts for your suite. Per-worker sharing is faster, but it needs global setup, per-worker database naming and teardown logic. Someone has to maintain that code, and debugging it is hard when it fails.

Docker must also be available on every developer machine and CI runner. Contributors who cannot run Docker locally lose fast local feedback.

The savings come from three places:

  • Migration errors, constraint violations and SQL that only Postgres rejects fail in CI instead of in production.
  • There are no mocked repositories to update whenever a query changes.
  • A failing test reproduces on a laptop with the same database engine.

The losses grow when too much coverage moves into container-backed tests. Feedback slows down. A failure can also come from the schema, the query, the service or the test setup, so it takes longer to locate the cause than with a failing unit test.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs testcontainers, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.