DEV Community

Cover image for Real Docker Containers in Playwright Tests — Zero Boilerplate
Vitali Haradkou
Vitali Haradkou

Posted on

Real Docker Containers in Playwright Tests — Zero Boilerplate

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);
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Build from Dockerfile

test("custom service", async ({ useContainerFromDockerFile }) => {
  const app = await useContainerFromDockerFile("./docker", "Dockerfile", {
    ports: 3000,
    waitStrategy: Wait.forHttp("/health", 3000),
  });
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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)