Skip to main content
BlogSoftware Development

Integration Testing NestJS with Testcontainers and Postgres

Why mocked repositories hide SQL and constraint bugs, and how I run NestJS integration tests against a real throwaway Postgres with Testcontainers and Jest.

Matthews Wong

Software Engineer

August 20269 min read
Integration Testing NestJS with Testcontainers and Postgres

Photo by Ian Taylor on Unsplash

Key Takeaway

Testcontainers spins up a real, throwaway Postgres in Docker for each NestJS test run, so integration tests hit actual SQL, migrations, and database constraints instead of mocks. You get a random mapped port, apply real migrations in a Jest beforeAll hook, run your NestJS TestingModule against it, then tear the container down.

Most NestJS test suites I inherit mock the repository layer. A jest.fn returns a fake user, the service does its thing, the assertion passes, everyone is happy. Then production throws a unique-constraint violation the mock never modeled, or a query that referenced a column a migration renamed months ago. The tests were green because they were testing my mock, not my database.

Testcontainers fixes that by making a real database cheap to start and cheap to throw away. It is an open source library that provides throwaway, lightweight instances of databases, message brokers, or anything else that runs in a Docker container. Instead of maintaining a shared test database or an in-memory substitute that behaves differently from Postgres, each run boots its own Postgres, runs the real migrations, and disposes of it when the tests finish.

Why mocking the database hides real bugs

A mocked repository can only fail the way you told it to fail. It cannot enforce a NOT NULL column, cascade a delete, honor a UNIQUE index, or reject a value that violates a CHECK constraint, because none of that logic lives in your TypeScript. It lives in the database engine. So the entire class of bugs that Postgres would catch at runtime sails straight through a mocked suite.

The same blind spot covers SQL correctness. A hand-written query, a raw fragment, a subtle join, or a migration that quietly drops the wrong index will never surface against a mock that returns whatever you hardcoded. Against a real Postgres those bugs fail loudly and immediately, which is exactly where you want them: in the test run, not in an incident channel at 2am.

Mocked repositories vs Testcontainers

Both approaches have a place. Mocks are still the right tool for pure unit tests of business logic that has nothing to do with persistence. But the moment a test asserts anything about how data is stored, queried, or constrained, a mock is measuring the wrong thing. Here is how I weigh the two for the persistence layer.

AspectMocked repositoryTestcontainers + real Postgres
SQL correctnessNever executed, so wrong queries passRuns real SQL against the real engine
Constraints and migrationsNot enforced at allUNIQUE, NOT NULL, and FK all fire
Startup speedInstant, no Docker neededSeconds per run to pull and boot
Environment dependencyNoneNeeds a Docker daemon locally and in CI
Confidence in the data layerLow, tests only the mockHigh, matches production behavior

One throwaway Postgres per test run

The pattern is small. In a Jest beforeAll hook, start a PostgreSqlContainer from the @testcontainers/postgresql package, read its connection details, point your NestJS config at them, build the TestingModule, and run the migrations. Testcontainers binds an available random port on the host, so parallel runs never clash. getConnectionUri returns a ready-made connection string; getHost, getPort, getDatabase, getUsername, and getPassword expose the pieces individually if you need them.

import { Test, TestingModule } from "@nestjs/testing";
import {
  PostgreSqlContainer,
  StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { DataSource } from "typeorm";
import { AppModule } from "../src/app.module";
import { UsersService } from "../src/users/users.service";

describe("UsersService (integration)", () => {
  let container: StartedPostgreSqlContainer;
  let moduleRef: TestingModule;
  let users: UsersService;

  beforeAll(async () => {
    // Spin up a real, throwaway Postgres for this run.
    container = await new PostgreSqlContainer("postgres:16-alpine").start();

    // Point the app's config at the random host port Testcontainers mapped.
    process.env.DATABASE_URL = container.getConnectionUri();

    moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    // Apply the same migrations production runs, before any test executes.
    const dataSource = moduleRef.get(DataSource);
    await dataSource.runMigrations();

    users = moduleRef.get(UsersService);
  }, 60_000); // pulling the image on a cold cache can take a while

  afterAll(async () => {
    await moduleRef?.close();
    await container?.stop();
  });

  it("rejects a duplicate email at the database level", async () => {
    await users.create({ email: "a@example.com" });
    await expect(
      users.create({ email: "a@example.com" }),
    ).rejects.toThrow(); // fires the real UNIQUE constraint, not a mock
  });
});

Give the beforeAll hook a generous timeout. The first run on a machine has to pull the postgres image before it can boot, which can easily blow past Jest's default 5-second timeout. Passing a per-hook timeout like 60000 milliseconds avoids a confusing failure that looks like a bug in your code but is really just a slow image pull.

Running migrations before the tests

A fresh container starts with an empty database, so migrations are not optional, they are the setup step. Resolve your DataSource from the TestingModule and call runMigrations before any test executes. This proves two things at once: that your schema applies cleanly from zero, and that your queries run against exactly the shape production will have. A migration that fails to apply fails the whole suite, which is the correct place to learn about it.

Between tests you have a choice: truncate the tables, wrap each test in a rolled-back transaction, or accept accumulated state. I truncate in a beforeEach for most suites because it is simple and predictable. Transaction rollback is faster but forces every test to share one connection, which breaks the moment your code opens its own transaction. Pick isolation first, cleverness second.

Do not point Testcontainers at a database you care about. The container is disposable by design, and any seed or fixture you load lives only for that run. Never reuse a real staging connection string as a shortcut, and make sure your test config cannot accidentally resolve to a shared environment, or a truncate step will happily wipe it.

Adding Redis and other services

The same mechanism scales past Postgres. Testcontainers ships modules for many services, and anything without a module can run through the GenericContainer class with an explicit image, exposed ports, and a wait strategy. A typical integration suite for a NestJS app that uses a cache and a queue might start:

  • A Postgres container via PostgreSqlContainer for the primary data store and migrations.
  • A Redis container for cache and rate-limit behavior you actually want to exercise.
  • Any other dependency via GenericContainer with withExposedPorts and getMappedPort to reach it.
  • A single shared setup file so every spec boots the same stack once, not per file.

CI speed trade-offs: Docker-in-CI and reuse

The one real cost is Docker. Your CI runner needs a working Docker daemon, and each cold run pays for pulling images and booting containers. In practice you cache the base images, boot the containers once per job rather than per test file, and run specs in parallel so the fixed startup cost amortizes across the whole suite. It is measurably slower than a mock-only run, and worth it for the bugs it catches before they ship.

Locally there is a shortcut. Testcontainers supports container reuse: enable it and it will not start a new container if a managed container with the same configuration is already running, keeping one warm between runs so your edit-test loop stays fast. Reuse is controlled by withReuse and the TESTCONTAINERS_REUSE_ENABLE environment variable. Keep it on for local development and off in CI, where a fresh, isolated container every time is the entire point.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why use Testcontainers instead of mocking the repository in NestJS?

A mocked repository only fails the way you tell it to. It cannot enforce UNIQUE, NOT NULL, or foreign-key constraints, run real SQL, or apply migrations, because that logic lives in Postgres, not TypeScript. Testcontainers starts a real, throwaway Postgres so integration tests catch the SQL and constraint bugs mocks silently pass.

How do I start a Postgres container in a Jest beforeAll hook?

Import PostgreSqlContainer from the testcontainers postgresql package, call start() on a new PostgreSqlContainer inside beforeAll, and read getConnectionUri() to point your NestJS config at the random mapped port. Give the hook a longer timeout, such as 60000 ms, because the first run must pull the image before it can boot.

How do I run migrations before the tests execute?

A fresh container starts empty, so migrations are the setup step, not optional. Build the NestJS TestingModule, resolve your DataSource with moduleRef.get, and call runMigrations() in beforeAll before any test runs. This proves the schema applies cleanly from zero and that queries run against the exact shape production will have.

Does Testcontainers slow down CI, and can I make it faster?

Yes, it needs a working Docker daemon and pays a cold cost to pull images and boot containers. Cache base images, boot containers once per job rather than per test file, and run specs in parallel so the fixed startup cost amortizes. It is slower than a mock-only run but catches real database bugs before they ship.

What is Testcontainers container reuse and when should I enable it?

Reuse keeps a container warm between runs: it will not start a new container if a managed one with the same configuration is already running. It is controlled by withReuse and the TESTCONTAINERS_REUSE_ENABLE environment variable. Keep it on locally for a fast edit-test loop, and off in CI where a fresh, isolated container each time is the point.