| name | node-testcontainers |
| description | Testcontainers for Node.js integration testing methodology — container lifecycle, PostgreSQL/RabbitMQ modules, NestJS test wiring, Prisma migration in tests, snapshot-based reset, Jest configuration, and CI patterns. Load when writing integration tests with real containers. |
| user-invocable | false |
Node Testcontainers
Integration testing methodology using Testcontainers for Node.js with NestJS,
Prisma, PostgreSQL, and RabbitMQ. Real containers, real infrastructure, no mocks.
1. When to Use
Load this skill when:
- Writing integration tests that need a real PostgreSQL or RabbitMQ instance
- Wiring Testcontainers into a NestJS
TestingModule
- Setting up Prisma migrations inside a test lifecycle
- Configuring Jest for integration vs unit test separation
- Designing CI pipelines that run containerized integration tests
- Choosing between snapshot-based and TRUNCATE-based state reset
Do NOT load for:
- Unit tests with mocked repositories (no containers needed)
- E2E tests that hit a running dev environment (not container-managed)
- Docker Compose orchestration (different tool, different lifecycle)
2. Package Structure (v10+)
Testcontainers for Node.js uses a modular package structure since v10.
Import from named packages, not from a monolith.
| Package | Purpose |
|---|
testcontainers | Core: GenericContainer, Network, Wait, Ryuk |
@testcontainers/postgresql | PostgreSqlContainer with snapshot support |
@testcontainers/rabbitmq | RabbitMQContainer with management API |
Install as dev dependencies:
pnpm add -D testcontainers @testcontainers/postgresql @testcontainers/rabbitmq
Pin container image tags in tests (postgres:16, rabbitmq:3-management) —
never use latest. Unpinned tags cause CI drift when upstream publishes a
breaking change.
3. Container APIs
PostgreSqlContainer
import { PostgreSqlContainer } from "@testcontainers/postgresql";
const pg = await new PostgreSqlContainer("postgres:16").start();
pg.getConnectionUri();
pg.getHost();
pg.getPort();
pg.getUsername();
pg.getPassword();
pg.getDatabase();
await pg.snapshot();
await pg.restoreSnapshot();
await pg.stop();
Snapshot warning: never call .withDatabase("postgres"). The snapshot
mechanism needs the postgres system database internally to DROP and
re-CREATE the test database. Using "postgres" as the test database name
breaks this — the container silently fails to restore.
RabbitMQContainer
import { RabbitMQContainer } from "@testcontainers/rabbitmq";
const rmq = await new RabbitMQContainer("rabbitmq:3-management").start();
rmq.getAmqpUrl();
rmq.getMappedPort(5672);
rmq.getMappedPort(15672);
const rmq = await new RabbitMQContainer()
.withEnvironment({
RABBITMQ_DEFAULT_USER: "test",
RABBITMQ_DEFAULT_PASS: "test",
})
.start();
await rmq.stop();
4. Wait Strategies
Containers are ready when they respond, not when they start. The default
(Wait.forListeningPorts()) works for most modules. Use explicit strategies
when the default is insufficient.
| Strategy | When to use |
|---|
Wait.forListeningPorts() | Default — sufficient for PostgreSQL and RabbitMQ modules |
Wait.forLogMessage(str|regex) | Wait for a specific log line (e.g., "database system is ready") |
Wait.forHealthCheck() | Container defines its own HEALTHCHECK in Dockerfile |
Wait.forHttp("/health", port) | HTTP endpoint readiness |
Wait.forAll([...]) | Composite — all conditions must pass |
import { Wait } from "testcontainers";
const pg = await new PostgreSqlContainer("postgres:16")
.withWaitStrategy(
Wait.forLogMessage("database system is ready to accept connections")
)
.start();
The PostgreSQL and RabbitMQ module containers ship with appropriate built-in
wait strategies. Override only when debugging startup timing issues.
5. Multi-Container Networking
When containers must communicate with each other (not just with the host),
use a shared Network with aliases.
import { Network } from "testcontainers";
const net = await new Network().start();
const pg = await new PostgreSqlContainer("postgres:16")
.withNetwork(net)
.withNetworkAliases("postgres")
.start();
const rmq = await new RabbitMQContainer("rabbitmq:3-management")
.withNetwork(net)
.withNetworkAliases("rabbitmq")
.start();
await pg.stop();
await rmq.stop();
await net.stop();
For NestJS integration tests where the app runs on the host (not in a
container), networking is unnecessary — getMappedPort() and getHost()
are sufficient.
6. Lifecycle Strategies
| Strategy | Scope | Startup cost | Isolation | Use when |
|---|
beforeAll/afterAll per suite | 1 container per test file | Paid once per file | Medium (manual reset between tests) | Standard NestJS integration tests |
beforeEach/afterEach | 1 container per test | Paid every test | Maximum | Pure isolation required, few tests |
Jest globalSetup + env var | 1 container per run | Paid once | Lowest (all suites share) | Read-only test suites, speed-critical CI |
.withReuse() | Persists across runs | Near-zero after first | None | Local dev iteration only |
Recommended default: beforeAll/afterAll per suite with snapshot-based
reset (section 8). This balances startup cost against isolation.
.withReuse() in CI is an anti-pattern. CI always starts clean — reuse
only makes sense for local development iteration speed. Enable via
TESTCONTAINERS_REUSE_ENABLE=true env var (never hardcode).
7. NestJS Integration Pattern
Critical ordering constraint
The env-var-before-compile ordering is load-bearing. Every source agrees
on this exact sequence. Violating it causes NestJS modules to read stale or
undefined DATABASE_URL at compile time.
1. Start container → get dynamic connection URI
2. Set process.env → DATABASE_URL = container URI
3. Run Prisma migrate → schema applied to container DB
4. Compile NestJS module → reads env, connects to container
Full pattern
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from "child_process";
import * as request from "supertest";
import { AppModule } from "../src/app.module";
describe("Bikes Integration", () => {
let pg: StartedPostgreSqlContainer;
let app: INestApplication;
beforeAll(async () => {
pg = await new PostgreSqlContainer("postgres:16").start();
process.env.DATABASE_URL = pg.getConnectionUri();
execSync("npx prisma migrate deploy", {
env: { ...process.env, DATABASE_URL: pg.getConnectionUri() },
});
await pg.snapshot();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
}, 120_000);
afterEach(async () => {
await app.close();
await pg.restoreSnapshot();
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
});
afterAll(async () => {
await app.close();
await pg.stop();
});
it("should list bikes", async () => {
await request(app.getHttpServer())
.get("/bikes")
.expect(200);
});
});
Alternative: ConfigService override
When env-var-before-init is insufficient (e.g., ConfigService caches at
import time), override the provider directly:
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(ConfigService)
.useValue({
get: (key: string) =>
key === "DATABASE_URL" ? pg.getConnectionUri() : process.env[key],
})
.compile();
migrate deploy vs migrate dev
Always use prisma migrate deploy in tests. prisma migrate dev is
interactive — it prompts for a migration name and can reset the database
without warning. It will hang in CI.
8. State Reset
Two approaches for cleaning state between tests within a shared container:
| Approach | Speed | Complexity | Connection requirement |
|---|
pg.snapshot() / pg.restoreSnapshot() | Fast (DROP + CREATE from template) | Low | All connections must be closed first |
TRUNCATE ... CASCADE | Slower (per-table) | Medium (must list tables) | Works with open connections |
Snapshot pattern (recommended)
await pg.snapshot();
await app.close();
await pg.restoreSnapshot();
Snapshot is faster for schemas with many tables. The trade-off is the
connection-close requirement — the NestJS app must be destroyed and
re-created between tests.
TRUNCATE pattern (simpler)
const prisma = app.get(PrismaService);
const tables = await prisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
`;
for (const { tablename } of tables) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${tablename}" CASCADE`);
}
Simpler because the app stays alive between tests. Slower with many tables.
9. Jest Configuration
Separate configs prevent integration test timeouts from slowing unit test
feedback loops.
jest.unit.config.ts
export default {
testRegex: "\\.spec\\.ts$",
testPathIgnorePatterns: ["\\.integration\\."],
transform: { "^.+\\.ts$": "ts-jest" },
testTimeout: 10_000,
};
jest.integration.config.ts
export default {
testRegex: "\\.integration\\.spec\\.ts$",
transform: { "^.+\\.ts$": "ts-jest" },
testTimeout: 120_000,
maxWorkers: 1,
};
package.json scripts
{
"scripts": {
"test": "jest --config jest.unit.config.ts",
"test:integration": "jest --config jest.integration.config.ts"
}
}
Timeout: 120s covers container startup (5-15s) + migration (2-5s) + test
execution. Reduce once you have empirical data on your suite's runtime.
Workers: use maxWorkers: 1 (sequential) when suites share a single
container via globalSetup. Use maxWorkers: "50%" when each suite file
starts its own container — random ports prevent conflicts.
10. GitHub Actions CI
ubuntu-latest runners have Docker pre-installed. Testcontainers works
out-of-the-box with Ryuk. No Docker-in-Docker setup needed.
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".node-version"
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm turbo test:integration
DinD is only needed for: self-hosted runners without Docker, Kubernetes
runners. In those cases, set:
env:
DOCKER_HOST: tcp://docker:2375
TESTCONTAINERS_HOST_OVERRIDE: docker
Ryuk: the resource reaper container — cleans up leaked containers after
test runs. Enabled by default. Only disable
(TESTCONTAINERS_RYUK_DISABLED=true) for debugging — it leaves containers
running on process crash.
11. Parallel Execution
Testcontainers assigns random host ports to every container. This means:
- Each Jest worker file starting its own container is safe — no port conflicts
- Each CI matrix entry can run integration tests independently
- No coordination or port reservation needed between parallel suites
When using globalSetup (single shared container), serialize database access
with maxWorkers: 1 or implement per-worker schema isolation.
12. Anti-Patterns
| Anti-pattern | Why it fails | Do this instead |
|---|
.withReuse() in CI | CI starts fresh — stale container state causes flaky tests | Remove .withReuse(), let CI start clean containers |
prisma migrate dev in tests | Interactive — prompts for name, can reset DB without warning, hangs in CI | prisma migrate deploy (applies pending migrations, non-interactive) |
.withDatabase("postgres") with snapshots | Snapshot uses postgres system DB for DROP/CREATE — collides | Use any other name (default test is fine) |
testTimeout: 5000 (Jest default) | Container startup alone takes 5-15s | testTimeout: 120_000 in integration config |
| Compile NestJS module before setting env | ConfigService / @nestjs/config reads env at compile time — gets stale values | Set process.env.DATABASE_URL BEFORE Test.createTestingModule() |
| Shared container without state reset | Tests pollute each other's data — flaky order-dependent failures | Snapshot restore or TRUNCATE CASCADE in afterEach |
maxWorkers: "50%" with globalSetup container | Multiple workers hit same DB concurrently — race conditions | maxWorkers: 1 with shared container, or per-suite containers |
13. Cross-References
service-governance rule — startup conventions (separate init from import)
toolchain-isolation rule — pnpm for dependencies, never bare npm
random-ports rule — Testcontainers handles port randomization natively
cross-platform-portability rule — container tests run on all CI platforms
build-discipline rule — integration tests are part of the Building tier
logging-hygiene rule — structured logging inside test helpers