VOOZH about

URL: https://dev.to/vitalicset/real-docker-containers-in-playwright-tests-zero-boilerplate-4ml7

⇱ Real Docker Containers in Playwright Tests — Zero Boilerplate - DEV Community


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 beforeAll fails midway, afterAll still runs and may throw on container.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 ContainerOpts support — all GenericContainer.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!