Integration Testing NestJS with Testcontainers and Postgres

Photo by Ian Taylor on Unsplash
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.
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.
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.
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.
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.

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.
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.
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.
| Aspect | Mocked repository | Testcontainers + real Postgres |
|---|---|---|
| SQL correctness | Never executed, so wrong queries pass | Runs real SQL against the real engine |
| Constraints and migrations | Not enforced at all | UNIQUE, NOT NULL, and FK all fire |
| Startup speed | Instant, no Docker needed | Seconds per run to pull and boot |
| Environment dependency | None | Needs a Docker daemon locally and in CI |
| Confidence in the data layer | Low, tests only the mock | High, matches production behavior |
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: "[email protected]" });
await expect(
users.create({ email: "[email protected]" }),
).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.
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.
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:
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.