Back to Blog

Docker for Test Automation: Run Your Tests Anywhere With Zero Environment Drift

Master Docker for test automation. Learn how to containerize tests, use Docker Compose for test environments, integrate with CI/CD, and ensure consistent test execution across all environments.

David Johnson

Senior QA Engineer with 10+ years in test automation and DevOps

Published

12 min read

Reading time

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.

Related Posts