Docker Compose for Multi-Service Test Environments

9 min read

Testcontainers is great for starting individual services in Java tests. But when you need to test a full slice of your system — five services plus their databases plus a message broker — defining everything in test code becomes unwieldy. Docker Compose solves the environment definition problem: one YAML file that describes exactly what "a running test environment" means, executable identically on every developer's machine and in every CI job.

The docker-compose.yml for a test environment

Here is a complete, realistic file for a three-service e-commerce slice — User Service, Order Service, and their databases. This is the file you commit to your repository as docker-compose.test.yml.

version: '3.8'
 
services:
  user-service:
    image: mycompany/user-service:${VERSION:-latest}
    ports:
      - "8081:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://user-db:5432/users
      SPRING_DATASOURCE_USERNAME: test
      SPRING_DATASOURCE_PASSWORD: test
    depends_on:
      user-db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  user-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: users
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d users"]
      interval: 5s
      retries: 10
 
  order-service:
    image: mycompany/order-service:${VERSION:-latest}
    ports:
      - "8083:8080"
    environment:
      USER_SERVICE_URL: http://user-service:8080
      PRODUCT_SERVICE_URL: http://product-service:8080
      SPRING_DATASOURCE_URL: jdbc:postgresql://order-db:5432/orders
    depends_on:
      order-db:
        condition: service_healthy
      user-service:
        condition: service_healthy
 
  order-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d orders"]
      interval: 5s
      retries: 10

Three details are worth highlighting. depends_on with condition: service_healthy prevents a service from starting before its dependencies are actually ready — not just started, but passing their healthcheck. Without this, Order Service can boot before User Service has finished initialising and immediately fail every outbound call. The healthcheck block defines what "ready" means for each container: for databases, pg_isready; for Spring Boot services, the Actuator health endpoint. ${VERSION:-latest} allows CI to inject the precise image tag being tested — the build artifact from that pipeline run — while local runs default to latest.

Running it

The workflow on a developer machine or in a CI shell step is straightforward:

# Start everything in the background
docker compose -f docker-compose.test.yml up -d
 
# Watch startup logs for all services
docker compose -f docker-compose.test.yml logs -f
 
# Check all services are healthy
docker compose -f docker-compose.test.yml ps
 
# Run your tests (from a separate terminal or CI step)
mvn verify -Pit-tests \
  -Duser.service.url=http://localhost:8081 \
  -Dorder.service.url=http://localhost:8083
 
# Tear down and clean volumes
docker compose -f docker-compose.test.yml down -v

The -v flag on down removes named volumes. This is critical for test environments: it clears every database back to empty so the next run starts from a clean slate. Forgetting it is one of the most common causes of tests that pass on the first run and fail on the second.

Using ComposeContainer in Testcontainers

If you prefer to drive the environment from JUnit rather than from shell commands, Testcontainers provides ComposeContainer — a JUnit extension that starts and stops a Compose stack as part of the test lifecycle.

@Testcontainers
class DockerComposeIntegrationTest {
 
    @Container
    static ComposeContainer environment = new ComposeContainer(
        new File("src/test/resources/docker-compose.test.yml"))
        .withExposedService("user-service", 8080, 
            Wait.forHttp("/actuator/health").forStatusCode(200))
        .withExposedService("order-service", 8080, 
            Wait.forHttp("/actuator/health").forStatusCode(200))
        .withLocalCompose(true);  // use locally installed docker compose
 
    @Test
    void shouldCreateOrderForValidUser() {
        String userServiceUrl = "http://localhost:" + 
            environment.getServicePort("user-service", 8080);
        String orderServiceUrl = "http://localhost:" + 
            environment.getServicePort("order-service", 8080);
        
        // Make a user, then place an order using the mapped URLs
        // ...
    }
}

ComposeContainer starts the entire Compose stack before the test class runs and tears it down after the last test method completes. withExposedService tells Testcontainers which ports to map to the host and which readiness condition to wait for. getServicePort returns the actual mapped host port, which may differ from the container port — use it rather than hardcoding port numbers in your test assertions.

Environment parity — why this matters

The docker-compose.test.yml file is infrastructure as code. Every developer on the team runs docker compose up and gets the same environment: the same database version, the same service configuration, the same network topology. CI runs docker compose up and gets the same thing. There is no shared staging environment with accumulated state, no "it works on my machine" failures caused by a different Postgres minor version, and no manual setup steps that drift over time.

When the test environment is fully described in a committed file, you also get change history for free. If tests start failing after a dependency upgrade, git log docker-compose.test.yml shows exactly when the file changed and what changed in it.

Step 1 of 5

docker compose up

Docker pulls any missing images and starts all containers in dependency order. Databases start before services; services start before the test runner.

Startup time and strategies for managing it

A realistic three-service stack takes 30–90 seconds to reach fully healthy, depending on image sizes and healthcheck intervals. That is not free, but it is predictable and manageable:

  • Pull images explicitly in CI before the test step (docker compose pull) so the startup timer measures only container initialisation, not network download.
  • Use Alpine-based images for databases (postgres:15-alpine) — they are smaller and start faster than full images.
  • Tune healthcheck intervals for the test environment. A 5-second interval with 10 retries (50 seconds maximum wait) is appropriate. Production-tuned intervals that check every 30 seconds will make your tests much slower.
  • Keep docker-compose.test.yml leaner than your production Compose file. The test environment should contain only what tests need. Observability sidecars, log shippers, and metrics exporters belong in production, not in the test stack.

⚠️ Common mistakes

  • Using depends_on without condition: service_healthy. By default, depends_on only waits for the container process to start — not for the service inside it to be ready. Without condition: service_healthy, Order Service starts, makes its first call to User Service, and hits a connection refused error because User Service is still running its database migrations. The result is intermittent failures that are hard to reproduce locally.
  • Forgetting --volumes (or -v) on docker compose down. Without it, Postgres data volumes persist between runs. Tests that assume a clean database will fail on the second run because the previous run's data is still present. Always use docker compose down -v in CI. If you want to inspect state after a failure, run docker compose down (no -v) first to investigate, then clean up with docker compose down -v before the next run.
  • Using the same docker-compose.yml for tests and local development. The two environments have different requirements. The test file should pin specific image versions (the build artifact being tested), use predictable ports, disable persistent volumes, and have lean healthchecks. The dev file should use bind mounts for hot-reload, may use named volumes for persistence, and may expose additional ports for debugging. Merging them into one file forces compromises that make both worse. Keep them separate.

🎯 Practice task

  1. Write a docker-compose.test.yml for a two-service system you own or design — for example, Order Service plus its PostgreSQL database and User Service plus its database. Add healthcheck blocks for all four containers and depends_on: condition: service_healthy so services wait for their databases.
  2. Run docker compose -f docker-compose.test.yml up -d and wait for all services to report healthy. Run docker compose ps and verify every container shows healthy in the status column. Record how long startup took from the first up to the last healthcheck passing.
  3. Write one integration test that calls Order Service, which in turn calls User Service. Use ComposeContainer in Testcontainers to manage the environment from JUnit, or configure the test to read the host-mapped ports from system properties passed by Maven.
  4. Run docker compose down (without -v) and then docker compose up -d again. If your tests insert data during the run, check whether they still pass on the second run. Now try docker compose down -v before the second run. Explain in a comment in the test file why the -v flag is required for repeatable results.
  5. Add the docker compose up and docker compose down -v steps to a sample GitHub Actions workflow file. Place up -d as a step before the mvn verify step and down -v in a subsequent step. Use working-directory to ensure Docker Compose finds the correct file, and add docker compose ps between startup and the test step to surface healthcheck failures in the CI log before the tests run.

The next lesson moves from two-service integration tests to full end-to-end tests — where every service runs together and the test drives the system through a browser or API client the way a real user would.

// tip to track lessons you complete and pick up where you left off across devices.