Skip to content
SDETRole
SDETRole

Lesson 5 of 8 · DevOps for QA

Docker for Testers

Use Docker to make test runs reproducible: images vs containers, a Playwright runner Dockerfile, version matching, Docker Compose v2 stacks with healthchecks, and CI usage.

intermediate 8 min read Updated

You will learn to

  • Explain images, containers, volumes and networks in terms of test runs
  • Write a Dockerfile for a Playwright test runner based on the official image with a matching version
  • Run an application, database and test container together with Docker Compose v2 and healthchecks
  • Debug common container test failures: networking, shared memory and file permissions

Prerequisites

  • Basic command line
  • Git for Testers
  • Playwright basics

“It passes on my machine” is the oldest excuse in testing, and Docker is one of the best answers to it. A container packages a program with its operating system libraries, runtime and dependencies, so the test run on your laptop, your colleague’s laptop and the CI runner uses the same environment. For testers, Docker solves three recurring problems: reproducible test runners, disposable dependencies (databases, mock servers, message queues) and consistent browser rendering.

Core concepts

  • Image: a read-only template built from a Dockerfile, such as “Ubuntu plus Node 22 plus browsers”. Images are versioned with tags, for example postgres:16.
  • Container: a running (or stopped) instance of an image. Containers are cheap to create and throw away, which is exactly what you want for test dependencies.
  • Registry: where images are stored and pulled from, such as Docker Hub or the Microsoft Container Registry (mcr.microsoft.com).
  • Volume / bind mount: a way to persist data or share files between the host and a container. Bind-mounting your project folder lets a container run tests on your current code and write reports back to the host.
  • Network: containers on the same user-defined network reach each other by service name, for example http://app:3000.

Everyday commands:

docker pull postgres:16                      # download an image
docker run --rm -d --name db -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16
docker ps                                    # running containers
docker logs db                               # container output
docker exec -it db psql -U postgres          # run a command inside it
docker stop db                               # --rm means it is deleted on stop
docker images                                # local images

--rm deletes the container when it stops, -d runs it in the background, -e sets environment variables and -p host:container publishes a port to your machine.

A Dockerfile for a Playwright runner

Microsoft publishes official Playwright images with Node, browsers and all their system dependencies preinstalled. Build your test runner on top of one:

FROM mcr.microsoft.com/playwright:v1.65.0-noble

WORKDIR /tests

# Copy only the manifests first so the npm ci layer is cached
# until dependencies actually change.
COPY package.json package-lock.json ./
RUN npm ci

# Then copy the rest of the test code.
COPY . .

ENV CI=true
CMD ["npx", "playwright", "test"]

Add a .dockerignore so the build context stays small and your local node_modules (built for your OS) never overwrites the container’s:

node_modules
playwright-report
test-results
blob-report
.git

Build and run:

docker build -t e2e-runner .
docker run --rm --ipc=host -e BASE_URL=https://staging.example.com \
  -v "$(pwd)/playwright-report:/tests/playwright-report" e2e-runner

Match the image version to the npm package

The image tag (v1.65.0 above) must match the @playwright/test version your lockfile resolves. Each Playwright release expects specific browser builds; the image contains the browsers for its own version. If your project resolves 1.66 but the image is 1.65, Playwright will look for browsers the image does not have and fail with an error telling you to run npx playwright install.

Practical rules:

  • Pin an exact version in package.json for @playwright/test (no ^), or rely on the lockfile and read the resolved version from it.
  • Upgrade both together in the same pull request: the package version and the FROM tag.
  • Do not use a floating tag such as latest for the runner image. It makes runs non-reproducible, which defeats the purpose.

The -noble suffix means the image is based on Ubuntu 24.04 (Noble Numbat). Older tags such as -jammy (22.04) exist; use whichever matches the Ubuntu base your team standardises on, and keep it consistent between local runs and CI, because rendering differences matter for screenshot tests.

Disposable dependencies with Docker Compose

Most test suites need more than a browser: an application, a database, perhaps a mock of a third-party API. Docker Compose defines these services in one YAML file and starts them together.

Compose v2 is a Docker CLI plugin invoked as docker compose (with a space). The old standalone docker-compose binary is deprecated. Modern Compose files also no longer need a top-level version: key; the Compose Specification ignores it and recent versions warn that it is obsolete, so leave it out.

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: shop
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build: ./app
    environment:
      DATABASE_URL: postgres://postgres:test@db:5432/shop
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20

  tests:
    build: ./e2e
    ipc: host
    environment:
      BASE_URL: http://app:3000
    volumes:
      - ./e2e/playwright-report:/tests/playwright-report
    depends_on:
      app:
        condition: service_healthy

Why healthchecks matter

Plain depends_on only waits for a container to start, not for the program inside it to be ready. Postgres takes a few seconds to accept connections; your app takes time to boot and run migrations. Without readiness checks, the first tests fail with connection errors, which looks exactly like flakiness.

A healthcheck tells Docker how to decide whether a service is healthy, and depends_on with condition: service_healthy makes the next service wait until it is. The chain above means: database ready, then app ready, then tests start.

The healthcheck command runs inside the container, so it can only use tools that exist in that image. pg_isready ships with the Postgres image; whether your app image has wget or curl depends on its base image. A small dedicated health endpoint in the app (/health that also checks the database connection) is the most reliable target.

Running the stack

docker compose up --build --abort-on-container-exit --exit-code-from tests
docker compose down -v
  • --abort-on-container-exit stops everything when any container exits, so the stack does not hang after the tests finish.
  • --exit-code-from tests makes the command’s exit code the test container’s exit code, which is what CI needs to pass or fail the build.
  • down -v removes containers and the anonymous volumes, so the next run starts with a clean database.

During development you will often want the dependencies running while you iterate on tests from your host:

docker compose up -d --wait db app      # start and wait until healthy
npx playwright test                     # BASE_URL=http://localhost:3000
docker compose logs app                 # inspect the app when something fails

--wait blocks until the listed services are running and healthy.

Networking: the most common surprise

Inside a container, localhost means that container, not your machine and not another container.

From To reach the app use
Host (your laptop) http://localhost:3000 (published port)
Another Compose service http://app:3000 (service name)
A container reaching a server on the host http://host.docker.internal:3000 on Docker Desktop; on Linux this needs extra configuration

When a test passes locally but fails in a container with “connection refused”, check this table first.

Docker in CI

Most CI systems can run jobs inside a container or run Docker commands directly:

  • GitHub Actions jobs can set container: mcr.microsoft.com/playwright:v1.65.0-noble so every step runs in the image, and services: to start sidecar containers such as a database.
  • Jenkins declarative pipelines use agent { docker { image '...' } }, covered in Jenkins Pipelines.
  • Any CI runner with Docker can run the docker compose up ... --exit-code-from tests command above.

Keep image hygiene in mind: pin tags, rebuild regularly to pick up security fixes, never bake secrets into images (anything in a layer can be extracted), and pass credentials at run time through environment variables or your CI’s secret store.

Practice exercise

A team’s E2E job runs docker compose up with this file and fails intermittently on the first two or three tests with ECONNREFUSED. Their runner image is mcr.microsoft.com/playwright:latest while package.json has "@playwright/test": "^1.55.0". Identify the problems and fix them.

version: "3.8"
services:
  db:
    image: postgres
  app:
    build: ./app
    depends_on: [db]
  tests:
    build: ./e2e
    environment:
      BASE_URL: http://localhost:3000
    depends_on: [app]
Show a model answer

Problems:

  1. depends_on without conditions only waits for containers to start, so tests begin before the database and app are ready: that is the intermittent ECONNREFUSED.
  2. BASE_URL uses localhost, which inside the tests container is the tests container itself. It should be http://app:3000. (If it sometimes passes, something else is listening, which is worth investigating.)
  3. postgres and the runner image use floating tags; latest will drift away from the ^1.55.0 range and break browser lookups. Pin both, and pin @playwright/test to the exact version that matches the image tag.
  4. The version: key is obsolete in Compose v2 and can be removed.
  5. Chromium should run with ipc: host.
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10
  app:
    build: ./app
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
      interval: 5s
      retries: 20
  tests:
    build: ./e2e
    ipc: host
    environment:
      BASE_URL: http://app:3000
    depends_on:
      app:
        condition: service_healthy

The e2e/Dockerfile should start FROM mcr.microsoft.com/playwright:v1.55.0-noble (or whichever exact version the lockfile pins), and CI should run docker compose up --build --abort-on-container-exit --exit-code-from tests.

Key takeaways

  • Images are templates, containers are disposable instances; use them for both test runners and test dependencies.
  • Base Playwright runners on mcr.microsoft.com/playwright:v<version>-noble and keep the tag in lockstep with @playwright/test.
  • Use docker compose (v2), omit the version: key, and gate start-up with healthchecks and condition: service_healthy.
  • localhost inside a container is the container; use service names on Compose networks.
  • Run Chromium with --ipc=host, pin every tag, and never bake secrets into images.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →