Testing Docker Compose Multi-Service Setups
Docker Compose is the standard way to run multi-service integration tests locally and in CI. Use docker compose up --wait with healthchecks to ensure services are ready before tests run. Run tests against the composed services from the host or from a test runner container. Always clean up with docker compose down -v to remove volumes between runs.
Most real applications are not single services. They are a web server talking to a database, reading from a cache, publishing to a queue. Testing any one of those components in isolation tells you whether the component works — it does not tell you whether the system works. Docker Compose is the practical answer to running the whole system locally and in CI, with enough control to write reliable tests against it.
This post covers how to structure Compose-based integration tests, how to handle service readiness correctly, and how to set up CI that does not leave orphaned containers behind.
The Problem with Shared Test Environments
Before Compose was widely used, teams shared a staging database for integration tests. Every developer ran tests against the same database, tests stepped on each other, state leaked between runs, and flaky tests were blamed on "environment issues" rather than the real cause: non-isolated test infrastructure.
Docker Compose solves this by making the entire environment disposable. Each developer, each CI job, each pull request gets its own stack. Tests that pass locally will behave the same in CI because the environment is defined in code, not in a shared server's configuration.
A Real Example: Web App + PostgreSQL + Redis
Here is the target system we will test: a Node.js API server that stores user data in PostgreSQL and uses Redis for session caching.
# docker-compose.test.yml
version: '3.9'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: apptest
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d apptest"]
interval: 2s
timeout: 5s
retries: 10
start_period: 5s
cache:
image: redis:7-alpine
ports:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 10
app:
build:
context: .
target: app
environment:
DATABASE_URL: postgres://testuser:testpass@db:5432/apptest
REDIS_URL: redis://cache:6379
NODE_ENV: test
ports:
- "3000"
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/health"]
interval: 2s
timeout: 5s
retries: 15
start_period: 10sKey decisions in this file:
Port mapping without host port binding. Specifying "5432" without a host port ("5432:5432") tells Compose to assign a random available port on the host. This prevents port conflicts when multiple stacks run concurrently — which happens when two CI jobs run on the same machine.
Healthchecks on every service. The healthcheck block defines how Compose determines whether a service is ready. Without this, depends_on only waits for the container to start, not for the service inside it to be ready. PostgreSQL takes a few seconds to accept connections after the container starts; Redis is faster but still needs a moment.
depends_on with condition: service_healthy. This tells Compose not to start the app service until both db and cache pass their healthchecks. Without this condition, your application starts while the database is still initializing, hits a connection error, and crashes before tests can run.
Starting the Stack and Waiting for Readiness
The key command is:
docker compose -f docker-compose.test.yml up --wait--wait blocks until all services with healthchecks report healthy. It is the most important flag for reliable test setup. Without it, you would need to write your own polling loop.
Check the exit code to catch startup failures:
docker compose -f docker-compose.test.yml up --wait
if [ $? -ne 0 ]; then
echo "Stack failed to start"
docker compose -f docker-compose.test.yml logs
exit 1
fiTo get the dynamically assigned host port:
APP_PORT=$(docker compose -f docker-compose.test.yml port app 3000 | cut -d: -f2)
DB_PORT=$(docker compose -f docker-compose.test.yml port db 5432 | cut -d: -f2)Running Tests Against Compose Services
Option 1: Tests Run on the Host
The host machine runs the test suite and connects to services via the dynamically mapped ports:
# Start services
docker compose -f docker-compose.test.yml up --wait -d
# Get ports
APP_PORT=$(docker compose -f docker-compose.test.yml port app 3000 | cut -d: -f2)
# Run tests with the port injected
APP_URL=http://localhost:$APP_PORT npm test
# Cleanup
docker compose -f docker-compose.test.yml down -vThe Node.js test file reads the URL from the environment:
const BASE_URL = process.env.APP_URL || 'http://localhost:3000';
describe('User API', () => {
test('POST /users creates a user', async () => {
const response = await fetch(`${BASE_URL}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'test@example.com', name: 'Test User' }),
});
expect(response.status).toBe(201);
const body = await response.json();
expect(body.id).toBeDefined();
expect(body.email).toBe('test@example.com');
});
test('GET /users/:id returns the created user', async () => {
const createResponse = await fetch(`${BASE_URL}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'find-me@example.com', name: 'Find Me' }),
});
const { id } = await createResponse.json();
const getResponse = await fetch(`${BASE_URL}/users/${id}`);
expect(getResponse.status).toBe(200);
const user = await getResponse.json();
expect(user.email).toBe('find-me@example.com');
});
test('GET /users/:id returns 404 for unknown user', async () => {
const response = await fetch(`${BASE_URL}/users/99999999`);
expect(response.status).toBe(404);
});
});Option 2: Test Runner as a Compose Service
Add a test runner service to the Compose file. This keeps everything inside the Docker network, eliminating port mapping complexity:
test-runner:
build:
context: .
target: test
environment:
APP_URL: http://app:3000
DATABASE_URL: postgres://testuser:testpass@db:5432/apptest
depends_on:
app:
condition: service_healthy
command: npm test
profiles:
- testThe profiles: [test] key means this service only starts when explicitly requested:
docker compose -f docker-compose.test.yml --profile test up --wait --abort-on-container-exit --exit-code-from test-runner--abort-on-container-exit shuts down all services when any container exits. --exit-code-from test-runner makes the docker compose up command exit with the same code as the test runner container, propagating test failures to CI.
Java Integration Tests with Compose
For a Spring Boot application, you can use the host-port approach with REST-assured:
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
class UserApiIntegrationTest {
@BeforeAll
static void setUp() {
String appPort = System.getenv().getOrDefault("APP_PORT", "3000");
RestAssured.baseURI = "http://localhost";
RestAssured.port = Integer.parseInt(appPort);
}
@Test
void createUserReturns201() {
given()
.contentType("application/json")
.body("{\"email\": \"java@example.com\", \"name\": \"Java Test\"}")
.when()
.post("/users")
.then()
.statusCode(201)
.body("id", notNullValue())
.body("email", equalTo("java@example.com"));
}
@Test
void getUserReturns404ForUnknownId() {
given()
.when()
.get("/users/99999999")
.then()
.statusCode(404);
}
}Run with:
docker compose -f docker-compose.test.yml up --wait -d
APP_PORT=$(docker compose -f docker-compose.test.yml port app 3000 | cut -d: -f2)
APP_PORT=$APP_PORT ./mvnw test
docker compose -f docker-compose.test.yml down -vCleanup and Volume Management
Always use -v when tearing down:
docker compose -f docker-compose.test.yml down -vWithout -v, named volumes persist between runs. Your PostgreSQL data volume from the previous test run remains, and the next test run starts with leftover data. This causes tests that assert "list is empty" or "count is zero" to fail intermittently.
For CI, add cleanup to a trap to ensure it runs even if tests fail:
#!/bin/bash
set -e
cleanup() {
docker compose -f docker-compose.test.yml down -v --remove-orphans
}
trap cleanup EXIT
docker compose -f docker-compose.test.yml up --wait -d
APP_PORT=$(docker compose -f docker-compose.test.yml port app 3000 | cut -d: -f2)
APP_URL=http://localhost:$APP_PORT npm testThe trap cleanup EXIT runs the cleanup function whenever the script exits, regardless of whether it exits normally or due to an error.
CI Parallelism
When multiple CI jobs run on the same machine, you need to ensure the Compose stacks do not conflict. The port conflict problem is solved by using unmapped ports as shown earlier. But you also need unique project names so Compose treats each job as a separate stack:
export COMPOSE_PROJECT_NAME="test-run-${GITHUB_RUN_ID}-${GITHUB_JOB}"
docker compose -f docker-compose.test.yml up --wait -dIn GitHub Actions, GITHUB_RUN_ID is unique per workflow run and GITHUB_JOB is unique per job within that run. This ensures that if the "unit tests" job and the "integration tests" job run on the same runner simultaneously, their Compose networks and volumes do not overlap.
Full GitHub Actions workflow:
name: Integration Tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Start services
run: docker compose -f docker-compose.test.yml up --wait -d
env:
COMPOSE_PROJECT_NAME: test-${{ github.run_id }}-${{ github.job }}
- name: Run tests
run: |
APP_PORT=$(docker compose -f docker-compose.test.yml port app 3000 | cut -d: -f2)
APP_URL=http://localhost:$APP_PORT npm test
env:
COMPOSE_PROJECT_NAME: test-${{ github.run_id }}-${{ github.job }}
- name: Dump logs on failure
if: failure()
run: docker compose -f docker-compose.test.yml logs
env:
COMPOSE_PROJECT_NAME: test-${{ github.run_id }}-${{ github.job }}
- name: Cleanup
if: always()
run: docker compose -f docker-compose.test.yml down -v --remove-orphans
env:
COMPOSE_PROJECT_NAME: test-${{ github.run_id }}-${{ github.job }}The "Dump logs on failure" step is essential for debugging. When tests fail, the most common question is "what was the application logging?" — this step captures the answer automatically.
Database Migrations in the Test Stack
Most applications need schema migrations before tests can run. Two approaches:
Migration as an init container:
migrate:
image: flyway/flyway:10-alpine
command: migrate
environment:
FLYWAY_URL: jdbc:postgresql://db:5432/apptest
FLYWAY_USER: testuser
FLYWAY_PASSWORD: testpass
volumes:
- ./migrations:/flyway/sql
depends_on:
db:
condition: service_healthyMake the app service depend on migrate completing:
app:
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
migrate:
condition: service_completed_successfullyMigration on application startup:
Alternatively, run migrations in your application startup code before the server begins accepting connections. This is simpler for development but makes startup slower. For test environments, the init container approach is cleaner because it gives the migration a clear separation from the application.
Wrapping Up
Compose-based integration testing is not complicated, but it requires discipline in a few key areas: healthchecks on every service, --wait on startup, random port mapping for parallelism, and -v on teardown. Get these right and you have an integration test suite that is as reliable as your unit tests and actually tests the full system behavior. The payoff is tests that find the bugs that matter — the ones that only appear when real services talk to each other.