Related articles: Also see scaling Docker-based tests to ephemeral Kubernetes environments, managing test environments that Docker containers power, and the CI/CD pipeline context Docker test environments run inside.
Docker for Test Automation: Run Your Tests Anywhere With Zero Environment Drift
Your tests pass on your laptop. You push to CI. Build fails.
"Works on my machine!" you protest.
Your colleague runs the same tests. Their tests fail differently. Different Node version. Different browser. Different database state.
This is the environment inconsistency problem that Docker solves.
Docker for test automation means packaging your tests, dependencies, and runtime environment into containers that run identically everywhere�your laptop, your colleague's machine, CI servers, and production-like staging environments.
This comprehensive guide teaches you how to containerize your test suite, use Docker Compose for complex test environments, integrate with CI/CD pipelines, and eliminate "works on my machine" forever.
Why Docker for Testing?
The Environment Problem
| Without Docker | With Docker |
|---|---|
| "Works on my machine" syndrome | Identical environment everywhere |
| Manual dependency installation | Dependencies in container image |
| Version conflicts (Node, browsers) | Isolated, versioned containers |
| Database state pollution | Fresh database per test run |
| Slow CI setup (10+ minutes) | Pre-built images (30 seconds) |
| Environment drift over time | Immutable infrastructure |
Docker Testing Benefits
1. Reproducibility Every test run uses the exact same environment�same OS, same dependencies, same configuration.
2. Isolation Tests don't interfere with each other. Each container is a clean slate.
3. Speed Pre-built images start in seconds. Parallel execution is trivial.
4. Portability Same container runs on Windows, Mac, Linux, CI, cloud.
5. Scalability Spin up 100 containers as easily as 1.
Containerizing Your Test Suite
Basic Test Container
# Dockerfile
FROM node:18-alpine
# Install Playwright browsers
RUN npx playwright install-deps
RUN npx playwright install chromium
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy test files
COPY . .
# Run tests
CMD ["npm", "test"]
# Build image
docker build -t my-app-tests .
# Run tests
docker run --rm my-app-tests
# Run with environment variables
docker run --rm \
-e BASE_URL=https://staging.example.com \
-e TEST_USER_EMAIL=test@example.com \
my-app-tests
Multi-Stage Build for Smaller Images
# Dockerfile.multistage
# Stage 1: Build
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Test
FROM node:18-slim AS test
# Install only Chromium (not all browsers)
RUN npx playwright install-deps chromium
RUN npx playwright install chromium
WORKDIR /app
# Copy built artifacts and dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
# Copy test files
COPY tests ./tests
COPY playwright.config.ts ./
CMD ["npm", "run", "test:docker"]
# Build test image (only test stage)
docker build --target test -t my-app-tests:slim .
# Image is 500MB instead of 1.5GB!
Optimizing Docker Build Cache
# Dockerfile.optimized
FROM node:18-alpine
# Install system dependencies (cached unless OS updates)
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
# Set Playwright to use installed Chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /app
# Copy only package files (cached unless dependencies change)
COPY package*.json ./
RUN npm ci
# Copy config files (cached unless config changes)
COPY playwright.config.ts ./
COPY tsconfig.json ./
# Copy source files last (changes most frequently)
COPY src ./src
COPY tests ./tests
CMD ["npm", "test"]
Build time optimization:
- First build: ~5 minutes
- Subsequent builds (code changes only): ~10 seconds
- Subsequent builds (dependency changes): ~2 minutes
Docker Compose for Test Environments
Complete Test Stack
# docker-compose.test.yml
version: '3.8'
services:
# Database
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U testuser']
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
# Redis cache
redis:
image: redis:7-alpine
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5
# API backend
api:
build:
context: .
dockerfile: Dockerfile.api
ports:
- '3000:3000'
environment:
DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb
REDIS_URL: redis://redis:6379
NODE_ENV: test
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 10s
timeout: 5s
retries: 5
# Test runner
tests:
build:
context: .
dockerfile: Dockerfile.test
environment:
BASE_URL: http://api:3000
DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb
REDIS_URL: redis://redis:6379
depends_on:
api:
condition: service_healthy
volumes:
# Mount test results
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
# Mount videos/screenshots
- ./test-artifacts:/app/test-artifacts
command: npm run test:e2e
volumes:
postgres_data:
redis_data:
# Run full test stack
docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests
# Cleanup
docker-compose -f docker-compose.test.yml down -v
Parallel Test Execution with Compose
# docker-compose.parallel.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
api:
build: .
environment:
DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb
depends_on:
- postgres
# Shard 1
tests-shard-1:
build:
context: .
dockerfile: Dockerfile.test
environment:
BASE_URL: http://api:3000
SHARD: '1/4'
depends_on:
- api
volumes:
- ./test-results-1:/app/test-results
command: npm run test -- --shard=1/4
# Shard 2
tests-shard-2:
build:
context: .
dockerfile: Dockerfile.test
environment:
BASE_URL: http://api:3000
SHARD: '2/4'
depends_on:
- api
volumes:
- ./test-results-2:/app/test-results
command: npm run test -- --shard=2/4
# Shard 3
tests-shard-3:
build:
context: .
dockerfile: Dockerfile.test
environment:
BASE_URL: http://api:3000
SHARD: '3/4'
depends_on:
- api
volumes:
- ./test-results-3:/app/test-results
command: npm run test -- --shard=3/4
# Shard 4
tests-shard-4:
build:
context: .
dockerfile: Dockerfile.test
environment:
BASE_URL: http://api:3000
SHARD: '4/4'
depends_on:
- api
volumes:
- ./test-results-4:/app/test-results
command: npm run test -- --shard=4/4
Result: Test suite that took 20 minutes now takes 5 minutes.
CI/CD Integration
GitHub Actions with Docker
# .github/workflows/tests.yml
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Build test image
run: docker build -f Dockerfile.test -t my-app-tests .
- name: Run tests
run: |
docker run --rm \
--network host \
-e DATABASE_URL=postgres://testuser:testpass@localhost:5432/testdb \
-e REDIS_URL=redis://localhost:6379 \
-e BASE_URL=http://localhost:3000 \
-v $(pwd)/test-results:/app/test-results \
my-app-tests
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results/
- name: Upload test report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
GitLab CI with Docker
# .gitlab-ci.yml
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: '/certs'
stages:
- build
- test
- report
build-test-image:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -f Dockerfile.test -t $CI_REGISTRY_IMAGE/tests:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE/tests:$CI_COMMIT_SHA
only:
- branches
test:
stage: test
image: docker/compose:latest
services:
- docker:dind
script:
- docker-compose -f docker-compose.test.yml pull
- docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests
after_script:
- docker-compose -f docker-compose.test.yml down -v
artifacts:
when: always
paths:
- test-results/
- playwright-report/
expire_in: 30 days
only:
- branches
test-parallel:
stage: test
image: docker/compose:latest
services:
- docker:dind
parallel: 4
script:
- export SHARD="$CI_NODE_INDEX/$CI_NODE_TOTAL"
- docker-compose -f docker-compose.test.yml run -e SHARD=$SHARD tests npm run test -- --shard=$SHARD
artifacts:
when: always
paths:
- test-results-$CI_NODE_INDEX/
expire_in: 7 days
Advanced Docker Testing Patterns
Pattern 1: Test Data Seeding
# Dockerfile.test
FROM node:18-alpine
WORKDIR /app
# Copy seed data
COPY db/seeds ./db/seeds
# Copy seed script
COPY scripts/seed-test-data.ts ./scripts/
# Install dependencies
COPY package*.json ./
RUN npm ci
# Run tests with seeding
CMD ["sh", "-c", "npm run db:seed && npm test"]
// scripts/seed-test-data.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding test data...');
// Clear existing data
await prisma.user.deleteMany();
await prisma.product.deleteMany();
// Create test users
const users = await prisma.user.createMany({
data: [
{ email: 'test1@example.com', name: 'Test User 1' },
{ email: 'test2@example.com', name: 'Test User 2' },
],
});
// Create test products
const products = await prisma.product.createMany({
data: [
{ name: 'Product 1', price: 99.99 },
{ name: 'Product 2', price: 149.99 },
],
});
console.log(`Seeded ${users.count} users and ${products.count} products`);
}
main()
.then(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
Pattern 2: Wait-for-it Script
# Dockerfile.test
FROM node:18-alpine
# Install wait-for-it
ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh
WORKDIR /app
COPY . .
RUN npm ci
# Wait for dependencies before running tests
CMD ["/wait-for-it.sh", "postgres:5432", "--", "/wait-for-it.sh", "redis:6379", "--", "npm", "test"]
Pattern 3: Shared Test Utilities
# docker-compose.test.yml
version: '3.8'
services:
test-utils:
build:
context: .
dockerfile: Dockerfile.test-utils
volumes:
- test-utils:/utils
tests:
build: .
volumes:
- test-utils:/utils:ro
depends_on:
- test-utils
command: npm test
volumes:
test-utils:
Docker Testing Decision Tree
graph TD
A[Need to run tests?] --> B{Local dev or CI?}
B -->|Local dev| C{Fast feedback needed?}
B -->|CI| D{Parallel execution needed?}
C -->|Yes| E["docker run<br/>(single container)"]
C -->|No| F["docker-compose up<br/>(full stack)"]
D -->|Yes| G["Docker + CI matrix<br/>(4-8 workers)"]
D -->|No| H["docker-compose<br/>(single runner)"]
E --> I[2-5 minutes]
F --> J[5-10 minutes]
G --> K[5-10 minutes total]
H --> L[15-30 minutes]
style I fill:#ccffcc
style J fill:#ffffcc
style K fill:#ccffcc
style L fill:#ffcccc
Debugging Tests in Docker
Access Container Logs
# View logs from running container
docker logs <container-id>
# Follow logs in real-time
docker logs -f <container-id>
# View compose service logs
docker-compose -f docker-compose.test.yml logs tests
# Follow all service logs
docker-compose -f docker-compose.test.yml logs -f
Interactive Debugging
# Run tests interactively
docker run -it --rm my-app-tests sh
# Inside container
$ npm test --debug
$ npm run test:headed # Run with browser visible (requires X11 forwarding)
# Debug specific test
docker run -it --rm my-app-tests npm test -- --grep "login flow"
VNC for Visual Debugging
# Dockerfile.debug
FROM node:18
# Install VNC server and browsers
RUN apt-get update && apt-get install -y \
x11vnc \
xvfb \
fluxbox \
wmctrl
# Install Playwright with browsers
RUN npx playwright install --with-deps
# VNC setup
ENV DISPLAY=:99
EXPOSE 5900
WORKDIR /app
COPY . .
RUN npm ci
# Start VNC and run tests
CMD ["sh", "-c", "Xvfb :99 -screen 0 1920x1080x24 & fluxbox & x11vnc -display :99 -forever -shared & npm test"]
# Run with VNC access
docker run -p 5900:5900 my-app-tests:debug
# Connect with VNC client to localhost:5900
# Watch tests run in real-time!
Common Docker Testing Challenges
Challenge 1: Slow Build Times
Problem: Docker builds take 10+ minutes
Solutions:
# Use .dockerignore
# .dockerignore
node_modules/
dist/
.git/
*.log
test-results/
playwright-report/
# Use build cache
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Use multi-stage builds
FROM base AS build
COPY src ./src
RUN npm run build
FROM base AS test
COPY --from=build /app/dist ./dist
COPY tests ./tests
CMD ["npm", "test"]
Result: Build time from 10min ? 2min
Challenge 2: Network Issues
Problem: Tests can't reach services
Solutions:
# Use custom network
services:
api:
networks:
- test-network
tests:
networks:
- test-network
environment:
# Use service name as hostname
API_URL: http://api:3000
networks:
test-network:
driver: bridge
Challenge 3: File Permissions
Problem: Test artifacts have wrong permissions
Solutions:
# Match host user ID
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN addgroup --gid $GROUP_ID testuser && \
adduser --disabled-password --gecos '' --uid $USER_ID --gid $GROUP_ID testuser
USER testuser
# Build with current user
docker build \
--build-arg USER_ID=$(id -u) \
--build-arg GROUP_ID=$(id -g) \
-t my-app-tests .
Performance Optimization
Image Size Comparison
| Strategy | Image Size | Build Time | Comments |
|---|---|---|---|
| node:18 | 1.1 GB | - | Full Node.js |
| node:18-slim | 240 MB | -40% | Minimal OS |
| node:18-alpine | 120 MB | -60% | Alpine Linux |
| Multi-stage | 180 MB | -50% | Only runtime deps |
| Alpine + Multi-stage | 95 MB | -70% | Best of both |
Build Time Optimization
# Optimize layer caching
FROM node:18-alpine
# Layer 1: OS dependencies (rarely changes)
RUN apk add --no-cache chromium
# Layer 2: Node dependencies (changes occasionally)
COPY package*.json ./
RUN npm ci
# Layer 3: Config files (changes sometimes)
COPY *.config.ts ./
# Layer 4: Source code (changes frequently)
COPY src ./src
COPY tests ./tests
Result:
- Full rebuild: 5 min
- Code-only change: 10 sec (12x faster!)
- Dependency change: 2 min
Best Practices
1. One Responsibility Per Container
# ? GOOD - Separate concerns
services:
api:
build: ./api
database:
image: postgres:15
tests:
build: ./tests
# ? BAD - Everything in one container
services:
all-in-one:
build: .
command: "start-postgres && start-api && run-tests"
2. Use Health Checks
services:
api:
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
3. Clean Up After Tests
# Always cleanup
docker-compose -f docker-compose.test.yml down -v
# Remove all test containers
docker rm $(docker ps -a -q -f "label=test=true")
# Remove dangling images
docker image prune -f
4. Version Your Images
# Tag with commit SHA
docker build -t my-app-tests:${GIT_COMMIT} .
docker build -t my-app-tests:latest .
# Tag with semantic version
docker build -t my-app-tests:1.2.3 .
5. Use Docker Layer Caching in CI
# GitHub Actions
- name: Build with cache
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.test
push: false
tags: my-app-tests:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Conclusion: Consistent Testing Everywhere
Docker eliminates environment inconsistencies, making tests reproducible across all platforms.
Key takeaways:
- Containerize tests for consistency across dev/CI/prod
- Use Docker Compose for complex multi-service test environments
- Optimize build caching to reduce build times by 10x
- Parallelize with containers to cut test time by 75%
- Integrate with CI/CD for automated, isolated test runs
- Use health checks to ensure dependencies are ready
- Clean up containers to avoid resource leaks
"Works on my machine" is no longer an excuse�Docker ensures it works on every machine.
Ready to containerize your test suite? Start with ScanlyApp's Docker-ready test platform and run tests consistently everywhere.
