Real Docker Containers in Playwright Tests — Zero Boilerplate

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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);
    // ...
    });







    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(6 379)}`);
    },
    });

    // 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("read y to accept connections");
    expect(container).toMatchContainerPortInRange(5432 , { min: 1024 });
    });







    Full matcher list:


    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!




    More...
Working...