From Single Container to Full Stack

In the previous lesson, you learned how to run tests in a single Docker container. But real applications rarely run in isolation. A typical web application needs a database, a cache, possibly a message queue, and maybe an email service. Docker Compose lets you define all of these as a single stack that starts and stops together.

For QA, Docker Compose is transformative. Instead of manually setting up PostgreSQL, Redis, and the application before running integration tests, you define everything in a docker-compose.yml file and start it with a single command.

Docker Compose Basics

Minimal Example

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://test:test@db:5432/testdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine

Starting and Stopping

# Start all services
docker compose up -d

# Start and rebuild images
docker compose up -d --build

# View logs
docker compose logs -f

# Stop and remove containers
docker compose down

# Stop, remove containers AND volumes (clean state)
docker compose down -v

Test Environment Patterns

Pattern 1: App + Dependencies + Test Runner

The most common pattern for integration and E2E testing:

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgresql://test:test@db:5432/testdb
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    volumes:
      - ./scripts/init-test-db.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  tests:
    build:
      context: .
      dockerfile: Dockerfile.tests
    environment:
      - BASE_URL=http://app:3000
      - DATABASE_URL=postgresql://test:test@db:5432/testdb
    depends_on:
      app:
        condition: service_started
    volumes:
      - ./test-results:/app/test-results

Running the tests:

# Start dependencies, run tests, get exit code
docker compose run --rm tests
echo "Exit code: $?"

# Clean up everything
docker compose down -v

Pattern 2: Parallel Browser Testing

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"

  chrome-tests:
    image: mcr.microsoft.com/playwright:v1.40.0-focal
    working_dir: /app
    volumes:
      - .:/app
      - ./results/chrome:/app/test-results
    environment:
      - BASE_URL=http://app:3000
    command: npx playwright test --project=chromium
    depends_on:
      - app

  firefox-tests:
    image: mcr.microsoft.com/playwright:v1.40.0-focal
    working_dir: /app
    volumes:
      - .:/app
      - ./results/firefox:/app/test-results
    environment:
      - BASE_URL=http://app:3000
    command: npx playwright test --project=firefox
    depends_on:
      - app

Pattern 3: Full Integration Stack

version: '3.8'

services:
  app:
    build: .
    environment:
      - DATABASE_URL=postgresql://test:test@db:5432/testdb
      - REDIS_URL=redis://cache:6379
      - MAIL_HOST=mailhog
      - MAIL_PORT=1025
      - S3_ENDPOINT=http://localstack:4566
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

  localstack:
    image: localstack/localstack
    environment:
      - SERVICES=s3
      - DEFAULT_REGION=us-east-1

Health Checks

Health checks are essential for test reliability. Without them, tests may start before the database is ready to accept connections.

db:
  image: postgres:15
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U test"]
    interval: 5s
    timeout: 5s
    retries: 5
    start_period: 10s

elasticsearch:
  image: elasticsearch:8.11.0
  healthcheck:
    test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
    interval: 10s
    timeout: 10s
    retries: 10
    start_period: 30s

Exercise: Build a Complete Test Environment

Design a docker-compose.yml for an e-commerce application with:

  • Node.js API backend
  • PostgreSQL database (with seed data)
  • Redis cache
  • Email service (for order confirmations)
  • Test runner for API and E2E tests
  • Test results saved to the host machine
Solution
version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgresql://test:test@db:5432/ecommerce_test
      - REDIS_URL=redis://cache:6379
      - MAIL_HOST=mailhog
      - MAIL_PORT=1025
      - JWT_SECRET=test-secret
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 5s
      timeout: 5s
      retries: 10

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: ecommerce_test
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    volumes:
      - ./scripts/seed-test-data.sql:/docker-entrypoint-initdb.d/01-seed.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

  api-tests:
    build:
      context: .
      dockerfile: Dockerfile.tests
    environment:
      - BASE_URL=http://api:3000
      - DATABASE_URL=postgresql://test:test@db:5432/ecommerce_test
      - MAILHOG_URL=http://mailhog:8025
    command: npm run test:api
    depends_on:
      api:
        condition: service_healthy
    volumes:
      - ./test-results/api:/app/test-results

  e2e-tests:
    image: mcr.microsoft.com/playwright:v1.40.0-focal
    working_dir: /app
    volumes:
      - .:/app
      - ./test-results/e2e:/app/test-results
    environment:
      - BASE_URL=http://api:3000
    command: npx playwright test
    depends_on:
      api:
        condition: service_healthy

volumes:
  test-results:

Usage:

# Run API tests
docker compose run --rm api-tests

# Run E2E tests
docker compose run --rm e2e-tests

# Run both (parallel)
docker compose up api-tests e2e-tests

# Clean up
docker compose down -v

CI Integration

Integrate Docker Compose into your CI pipeline:

# GitHub Actions example
- name: Start test environment
  run: docker compose up -d --build --wait

- name: Run tests
  run: docker compose run --rm tests

- name: Collect results
  if: always()
  run: |
    docker compose logs > docker-logs.txt

- name: Teardown
  if: always()
  run: docker compose down -v

Best Practices

  1. Always use health checks with depends_on: condition: service_healthy. Starting order alone is not enough — the database process may start but not be ready for connections.

  2. Use docker compose down -v after tests. The -v flag removes volumes, ensuring each test run starts with a clean state.

  3. Mount test results as volumes. This makes reports accessible on the host machine and in CI artifacts.

  4. Keep test data in SQL seed files mounted via docker-entrypoint-initdb.d/. This ensures consistent, reproducible test data.

  5. Use docker compose run --rm for test execution. The --rm flag removes the container after it exits, preventing accumulation of stopped containers.

  6. Pin image versions. Use postgres:15 instead of postgres:latest to avoid unexpected behavior changes.