docker-compose-tests
Authors a `compose.test.yaml` for tests - declares the SUT plus its real backing services as one declarative topology, wires healthcheck-driven `depends_on: condition: service_healthy` start ordering, isolates parallel CI jobs via per-job `--project-name`, gates the test step on `--wait` / `--wait-timeout` / `--exit-code-from`, and tears the stack down deterministically with `down --volumes --remove-orphans`. Use when the test environment is multi-service (app + db + cache + queue) and the topology is best expressed in YAML rather than imperative test code.
Install with skills.sh (any agent)
npx skills add testland/qa --skill docker-compose-testsdocker-compose-tests
Reach for Compose in tests when the SUT has two or more real backing services that must see each other (app -> db -> cache -> queue), the same topology must run locally and in CI without imperative drift, or parallel CI jobs each need an isolated stack. For a single dependency wired into one test, testcontainers is lighter - it lives inside the test process with no separate docker compose up step.
Step 1 - Author compose.test.yaml
Compose's default file lookup is compose.yaml or docker-compose.yaml in the working directory or any parent (compose-cli (opens in new window)); using the explicit compose.test.yaml filename + -f flag keeps test topology separate from local-dev topology.
# compose.test.yaml
name: orders-tests
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: orders_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d orders_test"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
cache:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 30
app:
build:
context: .
target: test
environment:
DATABASE_URL: postgres://postgres:test@db:5432/orders_test
REDIS_URL: redis://cache:6379/0
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 2s
timeout: 3s
retries: 30
start_period: 10s
e2e:
image: mcr.microsoft.com/playwright:v1.50.0-noble
working_dir: /work
volumes:
- .:/work
environment:
BASE_URL: http://app:3000
depends_on:
app:
condition: service_healthy
command: npx playwright testThe name: top-level key sets the project name (which Compose normally derives from the working directory) - important for CI isolation, see Step 4.
Step 2 - Healthchecks are load-bearing
service_started (the depends_on default) is almost never what a test wants. Postgres "started" doesn't mean "accepting connections". Always pair a real dependency with a healthcheck and gate its dependents with condition: service_healthy. The three depends_on conditions are tabulated in references/compose-flags.md.
The healthcheck syntax per compose-services (opens in new window):
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40sFor tests, use shorter intervals (2s vs the 1m30s default) and more retries so the gate trips quickly when the service is up but waits long enough for cold starts.
Step 3 - Migrations as one-shot dependencies
Database migrations belong in their own service that the app depends_on with service_completed_successfully:
services:
db:
image: postgres:15
healthcheck: { test: ["CMD-SHELL", "pg_isready"], interval: 2s, retries: 30 }
migrate:
image: orders-app:test
command: npm run db:migrate
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres:test@db:5432/orders_test
app:
image: orders-app:test
depends_on:
db: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
environment:
DATABASE_URL: postgres://postgres:test@db:5432/orders_testThe chain - db healthy → migrate runs → migrate exits 0 → app starts - ensures the app never connects to an unmigrated database.
Step 4 - Per-job isolation in CI
Per compose-cli (opens in new window), the project name controls the namespace of every resource Compose creates (containers, networks, volumes). Two parallel jobs that use the same project name fight each other.
Set the project name from a unique-per-job value:
# .github/workflows/integration.yml
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
env:
COMPOSE_PROJECT_NAME: orders-${{ github.run_id }}-${{ matrix.shard }}
steps:
- uses: actions/checkout@v5
- name: Compose up
run: docker compose -f compose.test.yaml up --build --wait --wait-timeout 180
- name: Run tests
run: docker compose -f compose.test.yaml run --rm e2e
- name: Compose down
if: always()
run: docker compose -f compose.test.yaml down --volumes --remove-orphansThe -p / --project-name resolution order is: flag > COMPOSE_PROJECT_NAME env var > top-level name: > directory basename. Using the env var keeps the YAML clean while overriding the in-file name: per CI job.
"Project names must contain only lowercase letters, decimal digits, dashes, and underscores, and must begin with a lowercase letter or decimal digit." (compose-cli (opens in new window))
Step 5 - Gate the test step on readiness
Two up flags pair for "block until everything is healthy": --wait ("Wait for services to be running|healthy. Implies detached mode.") and --wait-timeout <seconds> ("Maximum duration in seconds to wait for the project to be running|healthy") (compose-up (opens in new window)). The full flag table (--abort-on-container-exit, --abort-on-container-failure, --exit-code-from) is in references/compose-flags.md.
Two valid CI shapes:
Shape A - up --wait then run the test service
docker compose -f compose.test.yaml up --build --wait --wait-timeout 180
docker compose -f compose.test.yaml run --rm e2e
EXIT=$?
docker compose -f compose.test.yaml down --volumes --remove-orphans
exit $EXITThe up --wait step blocks until every service is healthy or the 180s budget elapses. The run step executes the test container; its exit code is the build verdict.
Shape B - up --abort-on-container-exit --exit-code-from <test-service>
docker compose -f compose.test.yaml up \
--build \
--abort-on-container-exit \
--exit-code-from e2eCompose returns the test container's exit code as its own. Cleaner when the test runs as a Compose service rather than via run.
Step 6 - Tear down deterministically
down stops and removes containers and networks (compose-cli (opens in new window)). Two flags matter for tests: --volumes / -v (remove named volumes, else DB state persists across runs and the next run sees stale data) and --remove-orphans (remove containers for services no longer in the file). The full table is in references/compose-flags.md.
docker compose -f compose.test.yaml down --volumes --remove-orphansAlways run down in an if: always() step (GitHub Actions) or trap-on-EXIT (shell) - leaked containers consume CI runner disk and poison the next run.
Step 7 - Profiles for selective enablement
When the test compose file has services that aren't always needed (seeded fixtures, a debugging admin UI, a heavyweight observability stack), gate them behind a profile:
services:
db: { image: postgres:15, healthcheck: { ... } }
pgadmin:
image: dpage/pgadmin4
profiles: [debug]
ports: ["5050:80"]
depends_on: { db: { condition: service_healthy } }Run with the profile when needed:
docker compose -f compose.test.yaml --profile debug up
# or via env:
COMPOSE_PROFILES=debug docker compose -f compose.test.yaml upPer compose-cli (opens in new window): "Specify a profile to enable" - multiple profiles via repeating --profile or comma-separated COMPOSE_PROFILES.
Anti-patterns
Common failure modes and their fixes - missing service_healthy gates, shared project names across parallel jobs, down without --volumes, foreground up without --abort-on-container-exit, migrations in the app entrypoint, host bind mounts in CI, reusing the dev compose file, and hard-coded host ports - are cataloged in references/anti-patterns.md.
Limitations
References
docker-compose-tests anti-patterns
View source (opens in new window)docker-compose-tests anti-patterns
Catalog of the failure modes summarized in the skill's Anti-patterns section, with the reason each fails and the fix.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
depends_on: db without condition: service_healthy | Default service_started means "container exists" - Postgres isn't ready; app crashes on first connection. | Add a healthcheck to the dependency and gate with condition: service_healthy. |
| Sharing one project name across parallel CI jobs | Two jobs pick the same container/network names; one accidentally tears down the other's state. | COMPOSE_PROJECT_NAME=<unique-per-job>. |
down without --volumes | DB state carries over to the next run; tests pass on dirty state, fail on clean state. | Always down --volumes --remove-orphans in if: always(). |
up foreground in CI without --abort-on-container-exit | The test container exits but Compose keeps the others running; the job hangs until timeout. | --abort-on-container-exit --exit-code-from <test-svc>, or up --wait then explicit run --rm. |
Running migrations from inside app's entrypoint | App and migrations race on cold start; intermittent connection-refused. | Separate migrate service with service_completed_successfully gate (Step 3). |
Mounting host source into the test app via volumes: in CI | CI source tree and image both write to the same path; build artifacts pollute the workspace. | Use bind mounts only for local-dev compose; tests run against the built image. |
| Reusing the local-dev compose.yaml for tests | Test topology grows hidden coupling to the dev convenience services. | Separate compose.test.yaml; -f compose.test.yaml everywhere. |
Hard-coded host ports (ports: ["5432:5432"]) in the test compose | Two parallel jobs collide on the host port. | Don't expose ports on the host in CI; rely on the Compose network for in-stack reachability. |
Sources: compose CLI reference (opens in new window), compose services schema (opens in new window).
Compose flag reference for tests
View source (opens in new window)Compose flag reference for tests
Source docs: compose CLI reference (opens in new window), compose up (opens in new window), compose services schema (opens in new window).
depends_on conditions
| Condition | Meaning |
|---|---|
service_started | Container has been created and started (default - usually wrong for tests). |
service_healthy | Dependency's healthcheck succeeded - the right choice for DBs / queues / app. |
service_completed_successfully | Dependency ran to completion and exited 0 - for one-shot init / migration containers. |
up flags (gate on readiness)
| Flag | Effect |
|---|---|
--wait | "Wait for services to be running|healthy. Implies detached mode." |
--wait-timeout <seconds> | "Maximum duration in seconds to wait for the project to be running|healthy" |
--abort-on-container-exit | "Stops all containers if any container was stopped." (Foreground.) |
--abort-on-container-failure | "Stops all containers if any container exited with failure." |
--exit-code-from <service> | "Return the exit code of the selected service container. Implies --abort-on-container-exit." |
down flags (teardown)
| Flag | Effect |
|---|---|
--volumes / -v | Remove named volumes declared in the compose file. Without this, DB state persists across runs and the next run sees stale data. |
--remove-orphans | Remove containers for services not defined in the current file. Defends against renamed services leaving stragglers. |
Related skills
feature-flag-test-harness
Builds a test harness that runs the same suite under every relevant flag combination - picks the minimum cover (single flags + pairwise interactions where the team marks them, not the full 2^N cartesian product), wires an OpenFeature in-memory provider so the suite never hits the production flag service, runs each combination as its own labeled CI matrix shard, and emits a per-combination result matrix. Use when a feature behind a flag must be verified on AND off (release toggles + experiment toggles per Hodgson) and the team wants those runs deterministic and parallel.
playwright-fixture-builder
Builds reusable Playwright fixtures via `test.extend` - picks the right scope (test vs worker), wires the `use(value)` setup/teardown split, composes auth (storageState per worker), database (per-test snapshot/restore), and feature-flag fixtures into one custom `test` object the whole suite imports. Outputs the `fixtures.ts` file plus per-fixture review notes (scope rationale, teardown ordering, `workerInfo.workerIndex` for parallel isolation). Use when the suite has copy-pasted `beforeEach` boilerplate that should be a fixture, or when adding auth / db / flag setup that crosses many specs.
testcontainers
Brings up real backing services (databases, message brokers, browsers, anything dockerizable) as throwaway containers from inside a test process - Java, Node.js, Python, Go, .NET, Ruby and ten other languages - using the Testcontainers library family. Wires the per-test container lifecycle, exposed-port → host-port mapping, wait strategies (port / log / HTTP / SQL), Ryuk-based cleanup, container-to-container networks, and the (experimental) `withReuse` shortcut for local dev. Use when integration tests need a real Postgres / Redis / Kafka / Selenium / etc. and the team wants per-test isolation without hand-rolled docker-compose teardown.