isolated, reproducible and CI ready
Integration and end-to-end tests rarely fail because of the test logic itself, they fail because of the environment: the wrong database version, services that never started, race conditions during startup. Docker Compose solves exactly that, with declared dependencies, healthchecks and network isolation that make every test run reproducible.
Table of Contents
- 1. Why Docker Compose is the right approach for testing
- 2. Basic structure of a test compose file
- 3. Healthchecks and depends_on: waiting until services are truly ready
- 4. Network isolation: keeping test environments cleanly separated
- 5. Setting up reproducible database initialization and fixtures
- 6. End-to-end tests with Playwright and Cypress in a container
- 7. Wiring Docker Compose into GitHub Actions and GitLab CI
- 8. Clean teardown: saving volumes, logs and artifacts
- 9. Test strategy comparison: with and without Docker Compose
- 10. Summary
- 11. FAQ
1. Why Docker Compose is the right approach for testing
Integration tests and end-to-end tests place different demands on infrastructure than unit tests do. While unit tests run entirely in-process and need no external infrastructure, Docker Compose backed integration tests require databases, message brokers, cache servers and external services to actually be reachable and in the correct state. The most common mistake: relying on the fact that the right MySQL version happens to be running on the developer's machine, when that same version is not present at all in the CI environment.
Docker Compose solves this problem through declarative environment definitions. The entire test environment is described in a single file: which services get started, in which version, with which environment variables, and which services must be available before the actual test runs. The result is a test environment that comes up identically on every developer machine and in every CI pipeline, regardless of what else is installed on the system. For teams working on several projects in parallel, the network and volume isolation that Docker Compose provides is essential to avoid conflicts between different test runs.
The second major benefit is reproducibility when something goes wrong. If an integration test fails, the developer can spin up the exact same environment locally in which the test failed in the CI pipeline, with the same data, the same versions and the same network conditions. This trait significantly reduces the debugging effort for intermittent failures.
2. Basic structure of a test compose file
A Docker Compose file for integration tests differs structurally from one used for local development: it contains no volumes for live code reload, no persistent data volumes and no host port mappings that could collide with other test runs. Instead, it defines a closed network in which all services communicate exclusively through internal DNS names.
Naming the project through the COMPOSE_PROJECT_NAME environment variable or the --project-name flag is essential in CI environments. It prevents parallel builds on the same CI worker from reusing the same network and container names and interfering with each other. Every test run in the pipeline should use a unique project name, for example by appending the build ID or a short hash value.
# docker-compose.test.yml: Integration test environment
# All services communicate only via internal Docker network
services:
# Application under test
app:
build:
context: .
target: test # Use dedicated test build stage
environment:
APP_ENV: testing
DB_HOST: db
DB_PORT: 3306
DB_NAME: testdb
DB_USER: testuser
DB_PASSWORD: testpass
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- test-network
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: testdb
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
MYSQL_ROOT_PASSWORD: rootpass
volumes:
- ./tests/fixtures/init.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./tests/fixtures/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql:ro
# No host port mapping, prevents conflicts with local MySQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "testuser", "-ptestpass"]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
networks:
- test-network
redis:
image: redis:7.4-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 10
networks:
- test-network
networks:
test-network:
driver: bridge
# Unique name prevents collision between parallel CI runs
name: "test-${COMPOSE_PROJECT_NAME:-default}"
3. Healthchecks and depends_on: waiting until services are truly ready
The most common problem with Docker Compose test environments is assuming that a container "running" means the service inside it is ready. A MySQL container starts within seconds, but the MySQL server itself needs considerably longer to run initialization scripts, warm up the InnoDB buffer pool and actually accept connections. Anyone using depends_on without condition: service_healthy risks race conditions where the application tries to reach the database before it is ready.
The correct pattern in Docker Compose v2 is combining a precise healthcheck on the service with condition: service_healthy in the dependent service's depends_on configuration. The start_period parameter is particularly important here: it defines a window after startup during which healthcheck failures are not counted as failures. This lets slow starting services like MySQL or Elasticsearch finish their initialization without the container being incorrectly marked unhealthy. In practice, a start_period of 20 to 30 seconds has proven reliable for database containers.
4. Network isolation: keeping test environments cleanly separated
In a Docker Compose test environment, network isolation is not an optional optimization, it is a basic requirement for stable tests. Without an explicit network configuration, Docker Compose connects all services of a project to a default bridge network, which is fine for a single test run but can cause unexpected interactions when parallel builds share the same host.
For robust CI pipelines, it is worth explicitly naming networks with a unique prefix per build. The ${CI_JOB_ID} variable in GitLab CI or ${GITHUB_RUN_ID} in GitHub Actions are good candidates for this. In addition, no ports should be exposed to the host system in the test environment: internal service communication should run exclusively through the container DNS name. This avoids port conflicts between parallel test runs and prevents external processes on the CI worker from unintentionally accessing the test services.
# Running integration tests with unique project name per CI build
# This prevents network and container name collisions on shared CI workers
# GitHub Actions example
PROJECT_NAME="test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker compose \
--file docker-compose.test.yml \
--project-name "$PROJECT_NAME" \
up --detach --wait
# Run the actual integration test suite
docker compose \
--file docker-compose.test.yml \
--project-name "$PROJECT_NAME" \
exec app php vendor/bin/phpunit --testsuite integration --log-junit /tmp/results.xml
# Copy test results from container to host for CI reporting
docker compose \
--file docker-compose.test.yml \
--project-name "$PROJECT_NAME" \
cp app:/tmp/results.xml ./test-results/integration.xml
# Always clean up, even if tests fail
docker compose \
--file docker-compose.test.yml \
--project-name "$PROJECT_NAME" \
down --volumes --remove-orphans
5. Setting up reproducible database initialization and fixtures
Reproducible integration tests require a defined starting state for the database. The MySQL and PostgreSQL images support an init directory (/docker-entrypoint-initdb.d/) in which SQL scripts run automatically on first startup. For Docker Compose test environments, this means schema and test data are mounted as read-only volumes and applied automatically the first time the container starts.
Because Docker Compose test environments typically use anonymous volumes and get fully wiped during teardown with docker compose down --volumes, every test run starts from an empty database state. This is the decisive advantage over shared test databases for integration testing: no test run leaves behind data that affects the next one. For tests that need different database states, a pattern with separate compose overrides that mount different fixture sets works well.
6. End-to-end tests with Playwright and Cypress in a container
End-to-end tests with browser automation are the most demanding use case for Docker Compose test environments. Playwright and Cypress both offer official Docker images that include a full browser stack without any graphical output. In a Docker Compose setup, the E2E test runner runs as its own service that reaches the application under test through the internal DNS name, with no network access to the host.
The critical detail with browser tests in containers: the application under test must be fully ready before the browser sends its first request. This calls for multi-stage healthchecks: the database healthcheck confirms MySQL is ready, and a separate HTTP healthcheck against the application confirms the app itself has initialized and is answering requests. Only once both conditions are met does the E2E container start. Running Selenium Grid as a separate Docker Compose service with one or more browser nodes enables parallel E2E tests across multiple browser instances.
# E2E test service using Playwright official container
# Waits for app to be fully ready before running tests
services:
e2e:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
working_dir: /tests
volumes:
- ./e2e:/tests:ro # Mount test files read-only
- ./test-results/e2e:/tests/results # Write results to host
environment:
BASE_URL: http://app:8080 # Internal Docker DNS name
CI: "true"
command: >
npx playwright test
--reporter=html
--output=/tests/results
depends_on:
app:
condition: service_healthy # App must pass HTTP healthcheck
networks:
- test-network
app:
build: .
environment:
APP_ENV: testing
DB_HOST: db
healthcheck:
# HTTP healthcheck: app is ready when it responds 200
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
depends_on:
db:
condition: service_healthy
networks:
- test-network
7. Wiring Docker Compose into GitHub Actions and GitLab CI
Integrating Docker Compose into CI pipelines requires particular care around parallel builds and resource cleanup. In GitHub Actions, each job runs in its own VM instance, which rules out network and port conflicts between jobs on different runners. On self-hosted runners or in GitLab CI with shared workers, however, unique project names and network names must be guaranteed.
The most reliable pattern for CI: run the teardown command in a separate step with if: always() (GitHub Actions) or as a separate job with when: always (GitLab CI). This ensures containers and volumes are cleaned up even if the test step fails or is cancelled. Without this pattern, failed CI builds accumulate leftover containers on the worker that consume memory and network resources and lead to hard-to-debug follow-up problems.
8. Clean teardown: saving volumes, logs and artifacts
A professional Docker Compose test workflow copies artifacts out of the containers before stopping and removing them. Test reports, coverage data and, especially valuable when something fails, application logs from the container should be saved to the host or to CI artifact storage before docker compose down runs. In GitHub Actions, the upload-artifact action step handles this task, and in GitLab CI the job's artifacts configuration does the same.
The command docker compose logs --no-color --timestamps prints the logs of all services in a structured format and can be redirected straight into a file. This step should always run before the teardown, and it should run automatically whenever tests fail. The Docker Compose --volumes flag on the down command removes all anonymous volumes and guarantees that no database state persists between test runs. Named volumes must be defined explicitly in the volumes section and removed separately with docker volume rm if they should not be deleted by down --volumes.
#!/usr/bin/env bash
# ci-test.sh: Complete integration test lifecycle with proper teardown
set -euo pipefail
PROJECT_NAME="test-${CI_JOB_ID:-local}"
COMPOSE_FILE="docker-compose.test.yml"
cleanup() {
echo "--- Collecting logs before teardown ---"
# Save all service logs as CI artifact
docker compose \
--file "$COMPOSE_FILE" \
--project-name "$PROJECT_NAME" \
logs --no-color --timestamps > test-results/docker-compose.log 2>&1 || true
echo "--- Tearing down test environment ---"
docker compose \
--file "$COMPOSE_FILE" \
--project-name "$PROJECT_NAME" \
down --volumes --remove-orphans --timeout 30
}
# Always run cleanup, even if tests fail
trap cleanup EXIT
echo "--- Starting test environment ---"
docker compose \
--file "$COMPOSE_FILE" \
--project-name "$PROJECT_NAME" \
up --detach --wait --wait-timeout 120
echo "--- Running integration tests ---"
docker compose \
--file "$COMPOSE_FILE" \
--project-name "$PROJECT_NAME" \
exec -T app php vendor/bin/phpunit \
--testsuite integration \
--log-junit /tmp/junit.xml \
--coverage-clover /tmp/coverage.xml
# Copy test artifacts before cleanup runs
docker compose \
--file "$COMPOSE_FILE" \
--project-name "$PROJECT_NAME" \
cp app:/tmp/junit.xml test-results/integration-junit.xml
echo "--- Tests completed successfully ---"
9. Test strategy comparison: with and without Docker Compose
Choosing to build integration tests around Docker Compose, or not, has concrete consequences for test speed, maintenance effort and reproducibility. A fair comparison looks not only at the initial setup effort but also at the long-term cost of flaky tests and environment-related failures.
| Criterion | Without Docker Compose | With Docker Compose | Advantage |
|---|---|---|---|
| Reproducibility | Depends on local install | Identical on every system | Docker Compose |
| Parallel execution | Port conflicts possible | Network isolation per build | Docker Compose |
| Startup time | Instant (service already running) | 30 to 90 seconds warmup | Without Docker Compose |
| Data state | Manual reset required | Fresh volume every run | Docker Compose |
| Debugging | Directly on the system | docker exec / logs available | Comparable |
The decisive point in this comparison: the extra time Docker Compose test environments take to start up pays for itself after just a handful of flaky test bugs caused by environment differences. A failure that only shows up in the CI environment and cannot be reproduced locally typically costs far more debugging time than every startup delay combined.
Mironsoft
Docker Compose, CI/CD infrastructure and test automation
Want stable integration tests that run in every environment?
We design Docker Compose test environments with complete healthchecks, network isolation and automated teardown, so your integration and E2E tests run reliably and reproducibly in your CI pipelines.
Test environment setup
Docker Compose for integration tests with healthchecks and fixture initialization
CI integration
GitHub Actions and GitLab CI with parallel builds and clean teardown
E2E automation
Playwright and Cypress in Docker containers for cross-browser testing
10. Summary
Docker Compose is the right tool for integration and end-to-end tests because it closes the gap between "it runs on my machine" and "it runs in the CI pipeline." The combination of declared dependencies, precise healthchecks and network isolation makes test environments reproducible and parallel friendly. Healthchecks with condition: service_healthy eliminate race conditions at startup. Anonymous volumes and automatic teardown guarantee that no test run leaves state behind for the next one.
The key principles in summary: use a unique project name for every CI build to prevent conflicts on shared CI workers. Always save logs and artifacts before teardown. Run end-to-end tests in official browser containers and use the app healthcheck as a gate for the browser container. The initial extra effort of the setup pays off through stable, reproducible tests that actually move the team forward instead of slowing it down.
Docker Compose for testing: the essentials at a glance
Healthchecks
condition: service_healthy in depends_on and start_period for slow starting services prevent race conditions at startup.
Network isolation
A unique project name per CI build, no host port mappings, and internal DNS communication prevent conflicts in parallel builds.
Clean teardown
down --volumes --remove-orphans always inside a trap or always block. Save logs before teardown, never after.
E2E tests
Playwright/Cypress in official Docker images, an app HTTP healthcheck as a depends_on condition, and results mounted as a host volume.
11. FAQ: Docker Compose for Integration and End-to-End Tests
1Why does my test fail even though the container is running?
condition: service_healthy with a healthcheck in depends_on solves this.2How do I prevent port conflicts in parallel CI builds?
--project-name per build with the build ID. Communicate internally via DNS through container names.3How do I start only certain services?
docker compose up service1 service2 starts only the named services and their depends_on dependencies. Use profiles for test groups.4Difference between --wait and depends_on?
--wait on the CLI command waits until all healthchecks are green before it returns.5How do I initialize the test database?
/docker-entrypoint-initdb.d/. MySQL/Postgres run them automatically on first startup.6How do I copy results out of the container?
docker compose cp service:/path ./host copies files out of the container. Alternatively, mount a named volume for results.7Playwright in Docker: any special requirements?
mcr.microsoft.com/playwright image includes all browser dependencies. Headless, no GPU needed. Define the app healthcheck as a depends_on gate.8How do I clean up containers safely after a test failure?
trap cleanup EXIT in the shell script. if: always() in GitHub Actions. when: always in GitLab CI for the teardown job.9How do I debug a failed integration test?
docker compose logs --follow while the test runs. docker compose exec app bash for interactive access. Set a fixed project name and skip down to inspect state.