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: 10Three 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 -vThe -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.ymlleaner 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_onwithoutcondition: service_healthy. By default,depends_ononly waits for the container process to start — not for the service inside it to be ready. Withoutcondition: 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) ondocker 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 usedocker compose down -vin CI. If you want to inspect state after a failure, rundocker compose down(no-v) first to investigate, then clean up withdocker compose down -vbefore the next run. - Using the same
docker-compose.ymlfor 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
- Write a
docker-compose.test.ymlfor a two-service system you own or design — for example, Order Service plus its PostgreSQL database and User Service plus its database. Addhealthcheckblocks for all four containers anddepends_on: condition: service_healthyso services wait for their databases. - Run
docker compose -f docker-compose.test.yml up -dand wait for all services to report healthy. Rundocker compose psand verify every container showshealthyin the status column. Record how long startup took from the firstupto the last healthcheck passing. - Write one integration test that calls Order Service, which in turn calls User Service. Use
ComposeContainerin Testcontainers to manage the environment from JUnit, or configure the test to read the host-mapped ports from system properties passed by Maven. - Run
docker compose down(without-v) and thendocker compose up -dagain. If your tests insert data during the run, check whether they still pass on the second run. Now trydocker compose down -vbefore the second run. Explain in a comment in the test file why the-vflag is required for repeatable results. - Add the
docker compose upanddocker compose down -vsteps to a sample GitHub Actions workflow file. Placeup -das a step before themvn verifystep anddown -vin a subsequent step. Useworking-directoryto ensure Docker Compose finds the correct file, and adddocker compose psbetween 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.