Testcontainers: Real Databases and Services in Your Integration Tests
Testcontainers: Real Databases and Services in Your Integration Tests

The biggest lie in software testing is the mock. You mock your database, your cache, your message queue, and your external APIs. Your tests pass. Then you deploy, and the real PostgreSQL behaves differently from your mock. The real Redis returns data in a format your mock did not anticipate. The real Kafka has partition behavior that your in-memory fake never simulated.
Testcontainers fixes this by running real services in Docker containers during your tests. Need a PostgreSQL instance? Testcontainers starts one in a container, gives you a connection string, runs your tests, and tears it down when done. Every test run gets a fresh, isolated environment. No shared test databases. No "it works on my machine" because someone has a different PostgreSQL version.
How It Works
Testcontainers uses the Docker API to manage containers programmatically. When a test starts, it:
- Pulls the Docker image (cached after the first pull)
- Starts a container with a random available port
- Waits for the service to be ready (health checks, log messages, or port availability)
- Provides the connection details to your test code
- Stops and removes the container after the test completes
The container lifecycle is tied to your test lifecycle. No manual setup, no orphaned containers, no port conflicts.
TypeScript Setup
Installation
# Core library
npm install -D testcontainers
# Database-specific modules
npm install -D @testcontainers/postgresql
npm install -D @testcontainers/mysql
npm install -D @testcontainers/mongodb
npm install -D @testcontainers/redis
Basic PostgreSQL Example
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import postgres from "postgres";
describe("User repository", () => {
let container: StartedPostgreSqlContainer;
let sql: ReturnType<typeof postgres>;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16")
.withDatabase("testdb")
.withUsername("testuser")
.withPassword("testpass")
.start();
sql = postgres(container.getConnectionUri());
// Run migrations
await sql`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`;
}, 60_000); // Container startup timeout
afterAll(async () => {
await sql.end();
await container.stop();
});
test("inserts and retrieves a user", async () => {
await sql`INSERT INTO users (name, email) VALUES ('Alice', '[email protected]')`;
const [user] = await sql`SELECT * FROM users WHERE email = '[email protected]'`;
expect(user.name).toBe("Alice");
expect(user.id).toBeDefined();
});
test("enforces unique email constraint", async () => {
await sql`INSERT INTO users (name, email) VALUES ('Bob', '[email protected]')`;
expect(async () => {
await sql`INSERT INTO users (name, email) VALUES ('Bob2', '[email protected]')`;
}).toThrow();
});
});
Redis Example
import { RedisContainer, StartedRedisContainer } from "@testcontainers/redis";
import { createClient } from "redis";
describe("Cache service", () => {
let container: StartedRedisContainer;
let client: ReturnType<typeof createClient>;
beforeAll(async () => {
container = await new RedisContainer("redis:7").start();
client = createClient({ url: container.getConnectionUrl() });
await client.connect();
}, 30_000);
afterAll(async () => {
await client.disconnect();
await container.stop();
});
test("sets and gets a cached value", async () => {
await client.set("user:1:name", "Alice");
const name = await client.get("user:1:name");
expect(name).toBe("Alice");
});
test("expires keys after TTL", async () => {
await client.set("temp", "value", { EX: 1 });
const before = await client.get("temp");
expect(before).toBe("value");
await new Promise((r) => setTimeout(r, 1100));
const after = await client.get("temp");
expect(after).toBeNull();
});
});
Generic Container for Any Docker Image
When there is no dedicated module, use GenericContainer:
import { GenericContainer, Wait } from "testcontainers";
const container = await new GenericContainer("localstack/localstack:3")
.withExposedPorts(4566)
.withEnvironment({
SERVICES: "s3,sqs",
DEFAULT_REGION: "us-east-1",
})
.withWaitStrategy(Wait.forLogMessage("Ready."))
.start();
const endpoint = `http://${container.getHost()}:${container.getMappedPort(4566)}`;
Java Setup
Testcontainers originated in the Java ecosystem and has the most mature Java support.
Maven Dependency
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
JUnit 5 Integration
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void shouldConnectToDatabase() {
assertTrue(postgres.isRunning());
assertNotNull(postgres.getJdbcUrl());
}
@Test
void shouldInsertAndRetrieveUser() {
// Use postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()
// to configure your DataSource or ORM
var jdbcUrl = postgres.getJdbcUrl();
// ... test logic
}
}
The @Container annotation manages the container lifecycle automatically. The @Testcontainers annotation activates the extension.
Common Patterns
Pattern 1: Shared Container Across Tests
Starting a container per test is slow. Share a container across all tests in a suite:
// test-setup.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
let container: StartedPostgreSqlContainer | null = null;
export async function getTestContainer(): Promise<StartedPostgreSqlContainer> {
if (!container) {
container = await new PostgreSqlContainer("postgres:16").start();
}
return container;
}
// Clean up after all test suites
afterAll(async () => {
if (container) {
await container.stop();
container = null;
}
});
Pattern 2: Database Per Test with Transactions
Share the container but isolate each test with a transaction that rolls back:
describe("Order service", () => {
let sql: ReturnType<typeof postgres>;
beforeEach(async () => {
await sql`BEGIN`;
});
afterEach(async () => {
await sql`ROLLBACK`;
});
test("creates an order", async () => {
// Changes are visible within this test but rolled back after
await sql`INSERT INTO orders (user_id, total) VALUES (1, 99.99)`;
const [order] = await sql`SELECT * FROM orders WHERE user_id = 1`;
expect(order.total).toBe("99.99");
});
});
Pattern 3: Custom Docker Images
Test against your exact production configuration:
const container = await new GenericContainer("postgres:16")
.withCopyFilesToContainer([
{
source: "./test/init.sql",
target: "/docker-entrypoint-initdb.d/init.sql",
},
])
.withCopyFilesToContainer([
{
source: "./test/postgresql.conf",
target: "/etc/postgresql/postgresql.conf",
},
])
.withCommand(["-c", "config_file=/etc/postgresql/postgresql.conf"])
.start();
Pattern 4: Network Composition
Test multiple services that communicate with each other:
import { Network } from "testcontainers";
const network = await new Network().start();
const postgres = await new PostgreSqlContainer("postgres:16")
.withNetwork(network)
.withNetworkAliases("db")
.start();
const app = await new GenericContainer("my-app:latest")
.withNetwork(network)
.withEnvironment({ DATABASE_URL: "postgres://test:test@db:5432/testdb" })
.withExposedPorts(3000)
.start();
// Test your app through its exposed port
const response = await fetch(`http://${app.getHost()}:${app.getMappedPort(3000)}/health`);
expect(response.status).toBe(200);
await app.stop();
await postgres.stop();
await network.stop();
Performance Optimization
Container startup time is the main cost of Testcontainers. Here are ways to minimize it:
Reuse containers across test runs: Enable Testcontainers' reuse feature to keep containers running between test executions during development:
const container = await new PostgreSqlContainer("postgres:16")
.withReuse()
.start();
Add to ~/.testcontainers.properties:
testcontainers.reuse.enable=true
Pre-pull images: In CI, pull Docker images in a setup step before running tests. This avoids image pull time counting against test timeouts.
Use lightweight images: Alpine-based images start faster. postgres:16-alpine starts noticeably faster than postgres:16.
Parallel container startup: If you need multiple services, start them in parallel:
const [pgContainer, redisContainer] = await Promise.all([
new PostgreSqlContainer("postgres:16-alpine").start(),
new RedisContainer("redis:7-alpine").start(),
]);
CI Integration
GitHub Actions
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:integration
env:
TESTCONTAINERS_RYUK_DISABLED: "true" # Not needed in ephemeral CI
Docker is pre-installed on GitHub Actions ubuntu-latest runners, so Testcontainers works without additional setup. Set TESTCONTAINERS_RYUK_DISABLED=true in CI environments where the runner is destroyed after each job -- the Ryuk sidecar container (which cleans up orphaned containers) is unnecessary.
GitLab CI
integration-tests:
image: node:20
services:
- docker:dind
variables:
DOCKER_HOST: "tcp://docker:2375"
TESTCONTAINERS_HOST_OVERRIDE: "docker"
script:
- npm ci
- npm run test:integration
When to Use Testcontainers
Use Testcontainers for: Integration tests against real databases, cache systems, or message queues. Testing database migrations. Testing queries against specific database versions. End-to-end tests that need a realistic infrastructure stack.
Do not use Testcontainers for: Unit tests (too slow, wrong abstraction level). Tests that do not interact with external services. Environments where Docker is not available.
Combine with mocks: Use mocks for unit tests of business logic. Use Testcontainers for integration tests that verify your code works with real infrastructure. The two approaches complement each other -- they test different things.
Testcontainers changes the economics of integration testing. When spinning up a real database is a one-line setup and takes a few seconds, there is no reason to mock it. Your tests become more reliable, your bugs get caught earlier, and your confidence in deployments goes up.