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:integrationThis 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 commitIn 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=maxThe 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 debuggingRun 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_CODEThe -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 startFor 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 startRunning 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:unitTests 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:apiTests 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 startuppostgres:
build:
context: .
dockerfile: Dockerfile.testdbFaster — 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.1GBPre-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 testTest 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:
- Different environment variables — print all env vars at test start in debug mode
- Timing differences — CI machines are often slower; increase timeouts
- Filesystem permissions — Alpine containers run as root by default; your local user may be different
- 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 /appThis 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.