You want real infrastructure in your integration tests. You don't want to write cleanup code. Here is how to get both.
The problem with containers in Playwright today
Testcontainers works great in Node.js, but fitting it into Playwright requires manual lifecycle management:
// the old way — lots of ceremony
let container: StartedTestContainer;
test.beforeAll(async () => {
container = await new GenericContainer("redis:8")
.withExposedPorts(6379)
.start();
});
test.afterAll(async () => {
await container.stop(); // what if beforeAll threw halfway through?
});
test("my test", async () => {
const port = container.getMappedPort(6379);
// ...
});
Problems:
- If
beforeAllfails midway,afterAllstill runs and may throw oncontainer.stop()against an undefined value. - All tests in the file share one container — isolation suffers.
- The container setup has nothing to do with what you're testing, but it's taking up a third of your file.
The new way
npm install -D @playwright-labs/fixture-testcontainers testcontainers
import { test } from "@playwright-labs/fixture-testcontainers";
test("redis test", async ({ useContainer }) => {
const container = await useContainer("redis:8", { ports: 6379 });
const port = container.getMappedPort(6379);
// container.stop() is called automatically after the test
});
That's it. One import change, and you get:
- ✅ Container starts when the test needs it
- ✅ Container stops after the test ends (even on failure)
- ✅ Multiple containers tracked and stopped in parallel
- ✅ Full
ContainerOptssupport — allGenericContainer.with*methods
Quick examples
Postgres with wait strategy
import { test } from "@playwright-labs/fixture-testcontainers";
import { Wait } from "testcontainers";
test("postgres integration", async ({ useContainer }) => {
const pg = await useContainer("postgres:16", {
ports: 5432,
environment: { POSTGRES_PASSWORD: "secret" },
waitStrategy: Wait.forLogMessage("ready to accept connections"),
startupTimeout: 30_000,
});
const connStr = `postgresql://postgres:secret@localhost:${pg.getMappedPort(5432)}/postgres`;
// connect and run queries
});
Multiple containers at once
test("full stack", async ({ useContainer }) => {
const [redis, pg] = await Promise.all([
useContainer("redis:8", { ports: 6379 }),
useContainer("postgres:16", {
ports: 5432,
environment: { POSTGRES_PASSWORD: "secret" },
}),
]);
// both stop in parallel after the test
});
Build from Dockerfile
test("custom service", async ({ useContainerFromDockerFile }) => {
const app = await useContainerFromDockerFile("./docker", "Dockerfile", {
ports: 3000,
waitStrategy: Wait.forHttp("/health", 3000),
});
});
Compose with your own fixtures
Because useContainer is a Playwright fixture, it plugs directly into your existing fixture chain:
// fixtures.ts
import { test as base } from "@playwright-labs/fixture-testcontainers";
export const test = base.extend<{ redisUrl: string }>({
redisUrl: async ({ useContainer }, use) => {
const container = await useContainer("redis:8", { ports: 6379 });
await use(`redis://${container.getHost()}:${container.getMappedPort(6379)}`);
},
});
// my.spec.ts — tests never touch Docker at all
import { test } from "./fixtures";
test("cache behavior", async ({ redisUrl }) => {
// just use the URL
});
Custom matchers
Import expect from the package to unlock container-specific assertions:
import { test, expect } from "@playwright-labs/fixture-testcontainers";
test("container assertions", async ({ useContainer }) => {
const container = await useContainer("postgres:16", {
ports: 5432,
environment: { POSTGRES_PASSWORD: "secret" },
healthCheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 1_000, retries: 5 },
waitStrategy: Wait.forHealthCheck(),
});
await expect(container).toBeContainerRunning();
await expect(container).toBeContainerHealthy();
expect(container).toBeContainerPort(5432);
await expect(container).toMatchContainerLogMessage("ready to accept connections");
expect(container).toMatchContainerPortInRange(5432, { min: 1024 });
});
Full matcher list:
| Matcher | What it checks |
|---|---|
toBeContainerRunning() |
State.Running === true |
toBeContainerStarted() |
State.Status === "running" |
toBeContainerStopped() |
State.Status === "exited" |
toBeContainerHealthy() |
State.Health.Status === "healthy" |
toMatchContainerLogMessage(pattern) |
Logs contain string or match RegExp |
toBeContainerPort(port) |
Port is exposed and mapped |
toMatchContainerPortInRange(port, range?) |
Mapped port is within bounds |
toHaveContainerLabel(key, value?) |
Label exists (optionally with value) |
toHaveContainerName(name) |
Exact name match |
toMatchContainerName(pattern) |
Name contains / matches RegExp |
toHaveContainerNetwork(name) |
Connected to the network |
toHaveContainerUser(user?) |
Exact user or any non-empty user |
toMatchContainerUser(pattern) |
User contains / matches RegExp |
All .not variants are supported.
Requirements
-
@playwright/test>= 1.57.0 -
testcontainers>= 10.0.0 - Docker (local or CI)
Source: github.com/vitalics/playwright-labs
If you've been putting off writing integration tests because of the Docker lifecycle boilerplate, this is the package that removes that excuse. Give it a try and let me know what you think in the comments!
Top comments (0)