Testing Docker Compose Multi-Service Setups

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: 10s

Key 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
fi

To 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 -v

The 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:
      - test

The 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 -v

Cleanup and Volume Management

Always use -v when tearing down:

docker compose -f docker-compose.test.yml down -v

Without -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 test

The 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 -d

In 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_healthy

Make the app service depend on migrate completing:

  app:
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully

Migration 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.

Read more

Start now free