Docker Compose Integration Testing: Full-Stack Test Environments

Docker Compose Integration Testing: Full-Stack Test Environments

Docker Compose creates complete multi-service environments for integration testing: your app, database, cache, and message queue all in one network. This guide covers writing test Compose files, running tests against them in CI, managing service startup order, and cleaning up after tests.

Integration tests that run against real infrastructure—PostgreSQL instead of SQLite, Redis instead of an in-memory map, a real message queue instead of a mock—catch an entirely different class of bugs. Docker Compose makes this practical: define your full service stack in one file, start it with one command, run tests, tear it down.

Why Compose for Integration Tests?

  • Real services: Tests run against the same database engine as production, not an H2 approximation
  • Isolation: Each test run gets a fresh network namespace—no shared state between pipeline runs
  • Reproducibility: Every developer and CI agent uses the same container versions
  • Speed: Compose caches layer builds; subsequent runs start in seconds
  • Cleanup: docker compose down removes containers, networks, and volumes in one command

Test Compose File Structure

Separate test configuration from production:

project/
├── docker-compose.yml          # Production/development
├── docker-compose.test.yml     # Integration test overrides
├── docker-compose.ci.yml       # CI-specific settings
└── tests/
    └── integration/

docker-compose.test.yml

version: '3.8'

services:
  app:
    build:
      context: .
      target: test  # Multi-stage: build the test stage
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgresql://testuser:testpass@db:5432/testdb
      - REDIS_URL=redis://redis:6379
      - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    command: npm run test:integration

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 10
    tmpfs:
      - /var/lib/postgresql/data  # In-memory for speed

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

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    environment:
      RABBITMQ_DEFAULT_USER: guest
      RABBITMQ_DEFAULT_PASS: guest
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 5s
      timeout: 10s
      retries: 10

Key choices:

  • tmpfs for database: Stores PostgreSQL data in RAM, making it faster and automatically cleaned up
  • Health checks on every service: Prevents app from starting before dependencies are ready
  • condition: service_healthy: Compose waits for green health checks before starting dependents

Running Tests

Local Development

# Start services and run tests
docker compose -f docker-compose.test.yml up \
  --build \
  --abort-on-container-exit \
  --exit-code-from app

# Clean up everything including volumes
docker compose -f docker-compose.test.yml down -v

--abort-on-container-exit: Stop all services when any one exits. --exit-code-from app: Use the app container's exit code as the command's exit code (0 = tests passed).

Quick Alias

# Add to package.json scripts
{
  "scripts": {
    "test:integration": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from app",
    "test:integration:clean": "docker compose -f docker-compose.test.yml down -v"
  }
}

Service Startup Order and Readiness

depends_on with condition: service_healthy handles ordering, but the health check needs to match the actual service readiness:

Application Readiness Wait

Sometimes your app needs to wait for migrations or additional setup:

app:
  build: .
  depends_on:
    db:
      condition: service_healthy
  environment:
    - DATABASE_URL=postgresql://testuser:testpass@db:5432/testdb
  command: >
    sh -c "
      echo 'Running migrations...' &&
      npm run db:migrate &&
      echo 'Starting tests...' &&
      npm run test:integration
    "

External Wait Scripts

For complex dependencies, use wait-for-it or dockerize:

# In test Dockerfile
RUN curl -o /usr/local/bin/wait-for-it \
  https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh \
  && chmod +x /usr/local/bin/wait-for-it
app:
  command: >
    sh -c "
      wait-for-it db:5432 --timeout=60 &&
      wait-for-it rabbitmq:5672 --timeout=60 &&
      npm run test:integration
    "

Isolated Test Databases per Suite

Run multiple test suites simultaneously without conflicts using Compose project names:

# Different project names = different network namespaces
docker compose -p suite-a -f docker-compose.test.yml up -d
docker compose -p suite-b -f docker-compose.test.yml up -d

# Each has its own db, redis, rabbitmq — no conflicts

In CI, use the branch name or job ID as the project name:

PROJECT_NAME="test-${GITHUB_RUN_ID}-${GITHUB_JOB}"
docker compose -p "$PROJECT_NAME" -f docker-compose.test.yml up \
  --abort-on-container-exit --exit-code-from app
docker compose -p "$PROJECT_NAME" down -v

GitHub Actions Integration

# .github/workflows/integration-tests.yml
name: Integration Tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run integration tests
        run: |
          docker compose -f docker-compose.test.yml up \
            --build \
            --abort-on-container-exit \
            --exit-code-from app
          exit_code=$?
          docker compose -f docker-compose.test.yml logs app
          exit $exit_code

      - name: Clean up
        if: always()
        run: docker compose -f docker-compose.test.yml down -v

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

Capturing Test Output

Tests running inside containers can write reports to volumes:

app:
  volumes:
    - ./test-results:/app/test-results
  command: npm run test:integration -- --reporter=junit --output /app/test-results/results.xml
# GitHub Actions
- name: Publish test results
  uses: mikepenz/action-junit-report@v4
  if: always()
  with:
    report_paths: 'test-results/*.xml'

Data Seeding and Fixtures

Seed test data as part of startup:

db:
  image: postgres:15-alpine
  environment:
    POSTGRES_DB: testdb
    POSTGRES_USER: testuser
    POSTGRES_PASSWORD: testpass
  volumes:
    - ./tests/fixtures/init.sql:/docker-entrypoint-initdb.d/init.sql
-- tests/fixtures/init.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL
);

INSERT INTO users (email, name) VALUES
    ('alice@example.com', 'Alice'),
    ('bob@example.com', 'Bob');

Files in /docker-entrypoint-initdb.d/ are executed when the PostgreSQL container initializes. Combine with tmpfs — data loads fast and is gone after docker compose down -v.

Environment Profiles for Different Test Types

# docker-compose.test.yml with profiles
services:
  unit-tests:
    profiles: [unit]
    build: .
    command: npm run test:unit

  integration-tests:
    profiles: [integration]
    build: .
    command: npm run test:integration
    depends_on:
      db:
        condition: service_healthy

  db:
    profiles: [integration]
    image: postgres:15-alpine
    # ...
# Run only unit tests (no Docker dependencies)
docker compose -f docker-compose.test.yml --profile unit up

# Run integration tests
docker compose -f docker-compose.test.yml --profile integration up \
  --abort-on-container-exit --exit-code-from integration-tests

Debugging Failing Tests

When tests fail in CI but not locally, the usual culprits:

Check Service Logs

# After test failure
docker compose -f docker-compose.test.yml logs db
docker compose -f docker-compose.test.yml logs app

Interactive Debug Mode

Override the command to get a shell:

docker compose -f docker-compose.test.yml run --rm app bash
# Inside container: run tests manually, inspect database state

Check Health Status

docker compose -f docker-compose.test.yml ps
# Shows health status for all services

Cleanup Strategies

Always clean up to avoid resource accumulation:

# Remove containers and networks (keep volumes)
docker compose -f docker-compose.test.yml down

# Remove everything including volumes (complete clean)
docker compose -f docker-compose.test.yml down -v

# Nuclear option: remove all stopped containers and unused volumes
docker system prune -f
docker volume prune -f

In CI, add cleanup to a finally block or post step that runs even if tests fail.

Summary

Docker Compose integration testing delivers real infrastructure at development speed. The pattern: a docker-compose.test.yml with health-checked services and tmpfs storage for databases, --abort-on-container-exit --exit-code-from app for CI exit codes, unique project names for parallel pipeline runs, and down -v cleanup in post-always steps. Real services in tests means the class of bugs that only appear in production—SQL incompatibilities, race conditions in consumer groups, connection pool exhaustion—get caught before the code ships.

Start now free