Docker-Based Test Environments in CI Pipelines

Docker-Based Test Environments in CI Pipelines

"It works on my machine" is a testing problem, not a bragging rights problem. When your local environment differs from CI, which differs from staging, which differs from production, your tests are lying to you. Docker-based test environments solve this by making environments explicit, reproducible, and version-controlled.

This post covers how to build Docker-based test environments that work reliably in CI, from single-container unit test setups to multi-service integration environments.

The Core Problem Docker Solves for Testing

Without Docker, your test environment is an accumulation of decisions: what Node/Python/Go version is installed, which system libraries are present, what environment variables are set, which background services are running. This state drifts over time and differs between machines.

With Docker, your test environment is a file. The Dockerfile and docker-compose.yml describe every dependency explicitly. Anyone running those files gets an identical environment.

The practical benefits:

  • Reproducibility: A test passing locally means something, because local and CI are identical
  • Isolation: Tests can't interfere with each other through shared system state
  • Parallelism: Spin up multiple identical environments for parallel test execution
  • Dependencies pinned: No more "CI uses Postgres 14.2 but I have 15.1 locally"

Structuring Dockerfiles for Testing

The most common mistake is writing a single Dockerfile optimized for production and then trying to use it for tests. Production and test images have different requirements — tests need dev tools, test frameworks, coverage tools, and sometimes relaxed security settings that you'd never want in production.

Use multi-stage builds:

# Dockerfile
# ---- Base Stage ----
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# ---- Test Stage ----
FROM node:20-alpine AS test
WORKDIR /app
COPY package*.json ./
# Install ALL dependencies including devDependencies
RUN npm ci
COPY . .
# Don't use CMD here — let the CI runner specify the test command

# ---- Builder Stage ----
FROM test AS builder
RUN npm run build

# ---- Production Stage ----
FROM base AS production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

Build and run tests using the test stage:

# Build the test image
docker build --target test -t myapp:test .

# Run tests
docker run --rm myapp:test npm test

# Run with environment variables
docker run --rm \
  -e DATABASE_URL=postgresql://... \
  -e REDIS_URL=redis://... \
  myapp:test npm run test:integration

This approach keeps your production image lean while giving tests everything they need.

Layer Caching: The Critical Performance Factor

Docker builds in CI can be slow if you don't understand layer caching. The rule: put things that change rarely at the top, things that change often at the bottom.

# WRONG: Every code change invalidates the npm ci layer
FROM node:20-alpine
WORKDIR /app
COPY . .            # Changes on every commit
RUN npm ci          # Forced to re-run every time
# RIGHT: Dependencies cached separately from code
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # Only changes when deps change
RUN npm ci                                # Cached until package.json changes
COPY . .                                  # Changes on every commit

In CI, you need to explicitly enable the cache. Each platform has its own mechanism:

GitHub Actions with Docker layer caching:

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build test image
  uses: docker/build-push-action@v5
  with:
    context: .
    target: test
    tags: myapp:test
    load: true
    cache-from: type=gha
    cache-to: type=gha,mode=max

The type=gha cache uses GitHub's Actions Cache API, persisting layer data between runs. The mode=max exports all layers, not just the final image.

GitLab CI with registry caching:

build-test-image:
  stage: build
  script:
    - docker buildx build
        --target test
        --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:test-cache
        --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:test-cache,mode=max
        --tag $CI_REGISTRY_IMAGE:test-$CI_COMMIT_SHA
        --push
        .

Docker Compose for Multi-Service Test Environments

Unit tests often only need your application image. Integration tests need your application plus databases, caches, and sometimes third-party service mocks. Docker Compose handles this elegantly.

# docker-compose.test.yml
version: '3.8'

services:
  app:
    build:
      context: .
      target: test
    environment:
      - DATABASE_URL=postgresql://testuser:testpass@postgres:5432/testdb
      - REDIS_URL=redis://redis:6379
      - NODE_ENV=test
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run test:integration
    volumes:
      - ./test-results:/app/test-results

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  # Mock external services
  email-mock:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"  # SMTP
      - "8025:8025"  # Web UI for debugging

Run the full test environment:

# Run tests and capture exit code
docker-compose -f docker-compose.test.yml run --rm app
TEST_EXIT_CODE=$?

# Always clean up, even on failure
docker-compose -f docker-compose.test.yml down -v

# Exit with test exit code
exit $TEST_EXIT_CODE

The -v flag on down removes volumes — important for test isolation between runs.

In CI:

# GitHub Actions
- name: Run integration tests
  run: |
    docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from app
    docker-compose -f docker-compose.test.yml down -v

- name: Collect test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: test-results/

The --abort-on-container-exit flag stops all containers when any container exits. --exit-code-from app uses the exit code from the app service as the command's exit code. Together, these ensure your CI step fails correctly when tests fail.

Healthchecks and Startup Dependencies

The most common failure mode in Docker-based test environments: tests start before the database is ready. Using depends_on without health checks only waits for the container to start, not for the service inside it to be ready.

Always use health checks for services your tests depend on:

postgres:
  image: postgres:16-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
    interval: 5s
    timeout: 5s
    retries: 10
    start_period: 10s  # Grace period before health checks start

For services without built-in health check commands, use a simple TCP or HTTP probe:

elasticsearch:
  image: elasticsearch:8.12.0
  healthcheck:
    test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"]
    interval: 10s
    timeout: 10s
    retries: 20
    start_period: 30s  # Elasticsearch takes a while to start

Running Tests in the Container vs. Against the Container

There's an important architectural choice: do your tests run inside the application container (testing the code directly) or against the container via its public API (testing it as a black box)?

Inside (unit/integration tests):

FROM node:20-alpine AS test
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "test"]
docker run --rm myapp:test npm run test:unit

Tests have direct access to your code, can mock internal modules, and run without a network. Fast and granular.

Against (API/contract tests):

# docker-compose.api-test.yml
services:
  app:
    build: .
    target: production  # Use production image
    ports:
      - "3000:3000"

  test-runner:
    build:
      context: ./tests
      dockerfile: Dockerfile.test
    depends_on:
      app:
        condition: service_healthy
    environment:
      - API_BASE_URL=http://app:3000
    command: npm run test:api

Tests exercise the real application through its API. Slower, but they catch configuration issues, middleware problems, and integration bugs that in-container tests miss.

A mature test setup uses both: in-container tests for fast iteration on business logic, against-container tests for API contract verification and smoke testing.

Database Migrations in Test Containers

Tests that share a database need to start from a known state. There are three approaches:

1. Apply migrations on startup:

app:
  depends_on:
    postgres:
      condition: service_healthy
  command: |
    sh -c "npm run db:migrate && npm run test:integration"

Simple, but adds time on every test run.

2. Pre-bake a migrated database image:

# Dockerfile.testdb
FROM postgres:16-alpine

COPY migrations/ /docker-entrypoint-initdb.d/
# PostgreSQL runs .sql files in this directory on first startup
postgres:
  build:
    context: .
    dockerfile: Dockerfile.testdb

Faster — migrations run once when building the image, not on every test run.

3. Use a database snapshot/dump:

FROM postgres:16-alpine
COPY test-fixtures/db-snapshot.sql /docker-entrypoint-initdb.d/

The fastest option for complex databases, but requires keeping the snapshot in sync with your schema.

Optimizing for CI Speed

Docker in CI has specific optimization opportunities beyond layer caching:

Use Alpine-based images: Alpine images are significantly smaller than their -debian counterparts, reducing pull time and storage.

FROM node:20-alpine  # ~180MB
# vs
FROM node:20         # ~1.1GB

Pre-pull base images: Some CI platforms let you configure base images to pre-pull. This eliminates pull time from your test run.

Minimize the build context:

# .dockerignore
node_modules/
.git/
*.log
test-results/
coverage/
.env*

Without a .dockerignore, Docker sends your entire working directory (including node_modules) to the daemon as build context. This can add seconds to every build.

Use bind mounts for test output:

docker run --rm \
  -v $(pwd)/test-results:/app/test-results \
  myapp:test npm test

Test artifacts are written directly to the host filesystem, making them available for artifact upload without needing to docker cp.

Debugging Failed Tests in Docker

When tests fail in CI but pass locally, the likely causes are:

  1. Different environment variables — print all env vars at test start in debug mode
  2. Timing differences — CI machines are often slower; increase timeouts
  3. Filesystem permissions — Alpine containers run as root by default; your local user may be different
  4. Network behavior — service discovery via hostname vs. localhost

To debug a failing CI run locally:

# Reproduce the exact CI environment
docker-compose -f docker-compose.test.yml up --build

# Instead of running tests, open a shell
docker-compose -f docker-compose.test.yml run --rm --entrypoint sh app

# Now you're inside the exact test environment
# Run tests manually, inspect the filesystem, check env vars
npm test
env | sort
ls -la /app

This shell gives you the exact environment CI uses, making it dramatically easier to reproduce and fix CI-only failures.

Docker-based test environments require upfront investment but eliminate entire categories of "works on my machine" problems. The reproducibility dividend — knowing that a passing test in CI means what you think it means — is worth the initial complexity.

Read more

Start now free