testcontainers
Testcontainers for Node.js starts real containerized dependencies from test code, waits until they are usable, exposes their random host ports, and cleans them up afterward. The base `testcontainers` package provides GenericContainer, image building, Docker Compose, networks, wait strategies, file copying, command execution, and runtime discovery. Separate `@testcontainers/*` modules wrap common services such as PostgreSQL, Redis, Kafka, and LocalStack with service-specific configuration and connection helpers.
Use Testcontainers when confidence from a real dependency outweighs slower, runtime-dependent tests. Keep unit tests separate, pin images, choose readiness checks carefully, and prove the Docker wiring on every CI runner before making it a required gate.
Use it if
- Your integration tests must exercise the real database, broker, or service version used in production
- Developers and CI runners have a Docker-compatible runtime and can tolerate image pulls and container startup time
- You need isolated dependencies with random host ports so test suites can run concurrently without collisions
- You are testing a custom image or uncommon service that is not covered by a prebuilt @testcontainers module
- Your CI runners cannot expose a Docker-compatible socket or remote daemon: the package has no container engine of its own and cannot fall back to an in-process fake
- Fast unit feedback is the goal: the official quickstart warns that first runs pull images, and even cached containers add process, network, and readiness time
- Your team uses rootless Podman on macOS and requires automatic cleanup: the runtime guide says the Ryuk resource reaper does not work in that setup and must be disabled
- Tests need identical networking on every desktop runtime: Colima and Rancher Desktop have documented IPv6 resolution and delayed port-forwarding issues that can cause connection refusals
- You plan to hardcode container names, hostnames, bind mounts, or host ports: the docs mark several of these patterns not recommended because they break parallelism, remote daemons, or Docker-in-Docker
Setup reality
Version 12.1.0 requires Node 22.22 or newer, a large jump that can rule out otherwise supported application runtimes. Install it as a development dependency, then provide a working Docker-compatible daemon. Docker works directly; Podman, Colima, and Rancher Desktop need environment-specific `DOCKER_HOST` and socket overrides. Rootless Podman on macOS cannot run the Ryuk cleanup container, while rootful mode may require privileged Ryuk. Disabling Ryuk shifts leaked-container cleanup to you, especially after killed tests. The first run pulls every missing image and can be slow or fail behind a registry proxy; pin image tags and configure Docker authentication rather than relying on mutable latest tags. Never connect to the internal port directly. Call `getHost()` and `getMappedPort(containerPort)` because the host and chosen port vary across local, remote, and nested runtimes. Readiness is not the same as container creation: the default waits for a health check when present, otherwise mapped listening ports, but many applications open a socket before migrations or recovery finish. Add a log, HTTP, health, command, or composite wait strategy and a realistic startup timeout. Always stop containers and networks in `finally` or test teardown even though Ryuk is a safety net. Prefer copying files over bind mounts, which the docs say are not portable to remote Docker or Docker-in-Docker. Reuse is enabled by default when its environment flag is absent in current docs, so disable it where clean-state isolation matters. Prebuilt service modules are separate npm installs; `testcontainers` alone does not give you a PostgreSQL connection URL helper.
Patterns
Start a pinned service and discover its portstart-generic-container
import { GenericContainer } from 'testcontainers'
const redis = await new GenericContainer('redis:8.2-alpine')
.withExposedPorts(6379)
.start()
const url = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`
try {
// connect and test with url
} finally {
await redis.stop()
}Use getHost and getMappedPort after start. The internal port is not necessarily reachable as localhost:6379.
Pass environment and command argumentsconfigure-environment
const container = await new GenericContainer('postgres:18-alpine')
.withEnvironment({
POSTGRES_USER: 'test',
POSTGRES_PASSWORD: 'test',
POSTGRES_DB: 'app_test',
})
.withCommand(['postgres', '-c', 'log_statement=all'])
.withExposedPorts(5432)
.start()Credentials here are disposable test values. Prefer @testcontainers/postgresql for its connection helpers and maintained defaults.
Wait for an application readiness logwait-for-log-message
import { GenericContainer, Wait } from 'testcontainers'
const app = await new GenericContainer('example/api:1.4.2')
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage(/ready on port 8080/i))
.withStartupTimeout(120_000)
.start()A listening socket may appear before the application is ready. Match a stable readiness message, not incidental startup text.
Wait for an HTTP health endpointwait-for-http-health
import { GenericContainer, Wait } from 'testcontainers'
const api = await new GenericContainer('example/api:1.4.2')
.withExposedPorts(8080)
.withWaitStrategy(
Wait.forHttp('/health/ready', 8080)
.forStatusCode(200)
.forResponsePredicate(body => body.includes('ready'))
)
.start()The HTTP wait runs against the container port. Keep the endpoint dependency-light so readiness does not flap on unrelated services.
Require both a port and a readiness signalcombine-wait-strategies
import { Wait } from 'testcontainers'
const wait = Wait.forAll([
Wait.forListeningPorts(),
Wait.forLogMessage('Ready to accept connections'),
]).withDeadline(90_000)
const container = await new GenericContainer('redis:8.2-alpine')
.withExposedPorts(6379)
.withWaitStrategy(wait)
.start()A composite deadline caps the whole group. Individual strategy timeouts otherwise apply separately and can extend total startup time.
Run an assertion helper inside the containerexecute-container-command
const result = await container.exec([
'sh', '-c', 'test -f /opt/app/config.json && echo ready',
])
if (result.exitCode !== 0) {
throw new Error(`container check failed: ${result.stderr}`)
}
console.log(result.output)exec does not automatically fail the JavaScript test on a nonzero exit code. Inspect exitCode and stderr yourself.
Copy generated configuration into a containercopy-config-before-start
const container = await new GenericContainer('nginx:1.29-alpine')
.withCopyContentToContainer([{
content: 'server { listen 8080; location / { return 200 "ok"; } }',
target: '/etc/nginx/conf.d/default.conf',
mode: parseInt('0644', 8),
}])
.withExposedPorts(8080)
.start()The docs prefer copies over bind mounts because copies work with remote Docker and Docker-in-Docker.
Connect services by a network aliasconnect-container-network
import { GenericContainer, Network } from 'testcontainers'
const network = await new Network().start()
try {
const redis = await new GenericContainer('redis:8.2-alpine')
.withNetwork(network)
.withNetworkAliases('cache')
.start()
const worker = await new GenericContainer('example/worker:2.0.0')
.withNetwork(network)
.withEnvironment({ REDIS_URL: 'redis://cache:6379' })
.start()
} finally {
await network.stop()
}Network aliases are for container-to-container traffic. Host-side test code still uses mapped ports, and all containers should stop before the network.
Let a container call a host test serverexpose-host-service
import { GenericContainer, TestContainers } from 'testcontainers'
await TestContainers.exposeHostPorts(8000)
const client = await new GenericContainer('curlimages/curl:8.16.0')
.withCommand(['sh', '-c', 'curl -f http://host.testcontainers.internal:8000'])
.start()This starts an SSHd helper container and forwards the host port. Call exposeHostPorts only after the host server is listening.
Use the service-specific PostgreSQL moduleuse-postgresql-module
import { PostgreSqlContainer } from '@testcontainers/postgresql'
const postgres = await new PostgreSqlContainer('postgres:18-alpine')
.withDatabase('app_test')
.withUsername('test')
.withPassword('test')
.start()
try {
const connectionString = postgres.getConnectionUri()
} finally {
await postgres.stop()
}Install @testcontainers/postgresql separately. The base testcontainers package does not export PostgreSqlContainer.
Stream service logs during a failing testdebug-container-logs
const stream = await container.logs()
stream
.on('data', line => process.stdout.write(`[service] ${line}`))
.on('err', line => process.stderr.write(`[service] ${line}`))
// run assertions after listeners are attachedApplication logs can contain credentials or test payloads. Enable broad streaming only in controlled test environments.
Wait for a migration container to exit successfullyrun-one-shot-container
import { GenericContainer, Wait } from 'testcontainers'
const migration = await new GenericContainer('example/migrations:3.1.0')
.withEnvironment({ DATABASE_URL: internalDatabaseUrl })
.withWaitStrategy(Wait.forOneShotStartup())
.start()forOneShotStartup treats exit code 0 as success. It is for short-lived jobs, not services expected to remain running.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| dockerode | npm | You need direct Docker API control and are willing to implement readiness, port discovery, and cleanup yourself |
| docker-compose | npm | Your test stack already lives in a Compose file and JavaScript only needs to start and stop the project |
| mongodb-memory-server | npm | You only need MongoDB tests and prefer a specialized ephemeral server with no general container API |
| @testcontainers/postgresql | npm | You specifically need PostgreSQL and want typed credentials and connection helpers over GenericContainer |