Skip to content
shellhacks.dev
Testing

How to Run Tests in Docker for a Clean Environment

Run tests in Docker for a clean, repeatable environment: a one-line run, a test stage, Compose with services, dependency caching and file ownership.

6 min read
Summarize in AI
Illustration for "How to Run Tests in Docker for a Clean Environment": a shell script in an editor, test results, environment variables.

A test that passes on one machine and fails on another usually means that the first machine has something the others do not: a global package, an environment variable, a leftover file or a different tool version. A container removes that variable. This post shows three levels, from a one-liner to a Compose setup, with the details that cause trouble: caching, file ownership and exit codes. It is the local counterpart to service containers in CI. Examples use Docker Engine 27, Compose 2.30 and Python 3.13, with the pattern the same for any language.

Level 1: a one-liner

Shell
docker run --rm -v "$PWD":/src -w /src --user "$(id -u):$(id -g)" \
  python:3.13 sh -c 'pip install --user -r requirements.txt && python -m pytest -q'
echo "exit status: $?"

The working tree is mounted into a clean image, the tests run and the container is discarded. --user makes any files the tests create, such as .pytest_cache or coverage output, belong to you instead of root; see bind mount permissions. The exit status of docker run is the command's, so it works in scripts. The weakness is that dependencies are installed on every run, which takes time.

Level 2: a test stage

DockerfileDockerfile
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
CMD ["python", "-m", "pytest", "-q"]

FROM base AS runtime
COPY src/ ./src/
CMD ["python", "-m", "app"]
Shell
docker build --target test -t app:test .
docker run --rm app:test

Dependencies are installed in layers that Docker caches until the requirement files change, so a re-run after a code edit takes seconds. The test stage is separate from runtime, so test tools never reach the production image. Use a .dockerignore to keep .git, caches and virtual environments out of the build context. For the editing loop, mount the source over the copy: add -v "$PWD":/app and the tests see your latest edits without a rebuild.

Level 3: Compose with dependencies

compose.test.yamlYAML
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: test
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 2s
      retries: 15

  test:
    build:
      context: .
      target: test
    environment:
      DATABASE_URL: postgres://postgres:test@db:5432/postgres
    depends_on:
      db:
        condition: service_healthy
Shell
docker compose -f compose.test.yaml run --rm test
docker compose -f compose.test.yaml down -v

The database waits for its healthcheck before the test container starts, as explained in healthchecks and depends_on. A tmpfs data directory makes the database fast and throwaway. run --rm returns the test container's exit code; follow with down -v to remove the database and its volume, even on failure, for example with trap in a script.

Several versions

Shell
for v in 3.11 3.12 3.13; do
  docker run --rm -v "$PWD":/src -w /src python:$v sh -c 'pip install -q -r requirements.txt pytest && pytest -q' || exit 1
done

A loop over image tags tests against the versions you support, which is the cheapest compatibility matrix. Do the same with distributions for shell scripts, as described in testing install scripts.

Things that go wrong

  • Tests pass in the container and fail in CI: different image tags; pin versions.
  • Slow bind mounts on macOS and Windows: put dependency directories in a volume instead of the mount.
  • Stale state: a __pycache__ or node_modules in the mounted tree compiled for another platform; exclude it.
  • Flaky network access in tests: use --network none for unit tests to prove they need none.

Where this does not apply

Tests for hardware access, GUI or host integration may not work in a container, and on Apple silicon an amd64-only image runs under emulation with different performance. A container is not a perfect copy of production: kernel, filesystem and network differ. For the broader picture of clean test environments, see testing scripts on multiple distros.

The simplest form and why it helps

Running tests inside a container removes the biggest source of "it passes here": the state of your machine. A clean image has no leftover files, no extra tools installed years ago, no configuration in your home directory and a known version of the language and libraries. The simplest form mounts the source and runs the test command: docker run --rm -v "$PWD":/src:ro -w /src image make test. The read-only mount stops the tests from altering your source, and anything they need to write goes to a temporary path inside the container. Pin the image by version, and by digest if you want exact reproducibility, since a moving tag changes the result without a change in your code. The container shares the host kernel, so tests of kernel-dependent behavior, such as file system features or time, still reflect the host, and Docker Desktop on macOS and Windows runs a Linux virtual machine, so file system performance and some behaviors differ from native Linux.

The speed cost is real. A fresh install of dependencies on each run is slow, and caching them in a volume or in an image layer is what makes the technique usable day to day.

A test stage in the Dockerfile

For repeatable runs, build the test environment as a stage in a multi-stage Dockerfile, with dependencies installed in an early layer so they are cached until the dependency files change, then the source copied in, then the test command as the default. docker build --target test . builds and runs it, and the build fails if the tests fail, which makes the same file usable locally and in CI. A later stage can take the compiled output for the production image, so what you test and what you ship come from the same build. Keep the test dependencies out of the final image, which is what stages are for. Run as a non-root user in the test stage if the software is meant to run that way, since tests that pass as root can fail with ordinary permissions, which is exactly the class of problem you want to find.

Dependencies, versions and the traps

When the tests need a database or another service, use Compose with health checks, and run docker compose run --rm tests so the services start and stop with the run. The options --abort-on-container-exit and --exit-code-from make up stop everything when the test container ends and return its exit code, which a CI job needs. To test against several versions of a language or a dependency, pass the version as a build argument and loop over the values, or use a matrix in CI, with each combination built from the same Dockerfile. The usual problems are predictable. A bind mount creates files owned by root on the host, which are annoying to delete, so run as your own user ID or write output to a volume. Leftover containers and volumes pile up without --rm and down -v. Tests that depend on time zone, locale or the number of CPUs behave differently in a container, so set them explicitly. On Apple silicon, an image built for x86 runs under emulation, slowly, and may behave differently, so match the architecture to what you deploy.

Making the runs fast enough to use

A clean environment that takes ten minutes to build is one people skip. Keep dependencies in cached layers, copy the dependency manifest before the source so the install layer is reused, and mount a cache directory for the package manager, using a BuildKit cache mount, so even a changed dependency file does not download everything again. Run the suite in parallel where it is safe. Use a small base image for the test environment, and pull it ahead of time in CI. Keep a local script with the exact command, so running the tests in a container is as easy as running them directly, since an awkward command is a command nobody runs before pushing. Measure the time per stage and fix the slowest first; it is usually dependency installation or a service waiting for readiness.

See multi-stage builds and docker compose run.

Also inDocker
Taggeddocker
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.