skip to content

In Testcontainers, when is ComposeContainer the right tool and how do you reach a service in the stack?

level: seniorimportance: should knowfreq 32%

answer

  1. Whose file is it, really?
  2. Not the ports in the file
  3. withExposedService, then read back
  4. Readiness still needs a wait strategy
  5. Owned services belong in code

basics

~10 s

ComposeContainer runs an existing docker-compose stack for a test. Ports are not the file's published ones: declare each service with withExposedService and read getServiceHost and getServicePort.

solid answer

~40 s

`new ComposeContainer(new File("src/test/resources/stack.yml")).withExposedService("redis", 6379, Wait.forListeningPort())` starts the stack and exposes the named service's port; your test then connects to `getServiceHost("redis", 6379)` and `getServicePort("redis", 6379)`, never to whatever the compose file publishes. Testcontainers deliberately controls the host side so parallel runs do not collide. It is the right choice when a compose file already exists and is maintained for another purpose — local development or a deployment fixture — and reproducing it as individual containers would mean duplicating it. It is the wrong choice when you own the services and want per-test configuration, programmatic wait strategies and direct handles, because the compose file becomes a second source of truth that tests cannot influence. By default the compose project runs through a container; `withLocalCompose(true)` shells out to the compose binary on the host instead.

code

java · 7 lines
java
ComposeContainer stack = new ComposeContainer(new File("src/test/resources/stack.yml"))
        .withExposedService("redis", 6379, Wait.forListeningPort())
        .withExposedService("api", 8080, Wait.forHttp("/health"));

stack.start();
String url = "http://" + stack.getServiceHost("api", 8080)
        + ":" + stack.getServicePort("api", 8080);

go deeper

for a junior

Recall that ComposeContainer starts a docker-compose stack for a test and that you obtain a service's address through getServiceHost and getServicePort.

for a middle

Explain why the compose file's published ports are bypassed and why each exposed service still needs its own wait strategy.

for a senior

Judge compose versus code by who owns the file, and anticipate the coupling when a development compose file changes underneath the suite.

for a principal

Decide the organisation's stance on shared compose fixtures in tests, including the cost of a file with two owners and a per-machine compose binary.

## What ComposeContainer gives you You hand it a compose file and it brings the whole stack up for the duration of a test class, then tears it down. That is attractive when a multi-service fixture already exists in compose form: you avoid re-expressing five services, their environment and their inter-service links in Java or Kotlin. ## Ports: the part people get wrong The compose file's published ports are not what your test should use. You declare intent with `withExposedService("redis", 6379, Wait.forListeningPort())`, and then read the actual coordinates back: `getServiceHost("redis", 6379)` and `getServicePort("redis", 6379)`. The reason is the same as for any container: fixed host ports collide when several runs or several jobs share a machine, so Testcontainers assigns them and tells you what it chose. A test that reads the compose file's port works exactly until two builds run at once. When a service is scaled to several instances, the overload taking an instance index selects which one you mean. Inter-service traffic is unaffected: inside the compose network, services still reach each other by their compose service names, exactly as they would outside a test. ## Readiness A compose file does not tell Testcontainers when a service is usable, so attach a wait strategy per exposed service. Skipping this is the standard cause of a stack that "starts" and then fails the first request: `up` returning is not readiness. ## Local compose versus containerised compose By default the compose project is executed via a container, so the machine running the build does not need a compose binary — good for heterogeneous developer machines and CI images. `withLocalCompose(true)` runs the compose CLI installed on the host instead, which can be faster and supports whatever features that binary has, at the price of requiring it to be present and of the same version everywhere. ## When not to use it If you own the services being started, individual containers usually beat a compose file. You get typed handles, per-test configuration, precise wait strategies, module conveniences, and the ability to vary the stack per test class — none of which a static compose file offers. You also avoid the coupling: a compose file maintained for local development will eventually change for a reason unrelated to tests and break them. The honest test is whether the compose file has an owner outside the test suite. If it does and it must stay authoritative, run it. If the tests are its only consumer, express the stack in code. ## Version note `ComposeContainer` is the current API, targeting Compose v2. The older `DockerComposeContainer` remains in the wild in existing suites; new code should use `ComposeContainer`. ## Interview framing A strong answer names the port indirection and why it exists, insists on per-service wait strategies, and frames compose-versus-code as a question of who owns the file rather than as a preference.

  • Why can't a test just use the ports published in the compose file?
    Because fixed host ports collide as soon as two runs or two CI jobs share a machine, and a test that hardcodes them is unrunnable in parallel. Testcontainers therefore assigns the host side itself and exposes it through getServiceHost and getServicePort. Services inside the stack still address each other by compose service name, so only the test-to-stack hop is affected.
  • What replaces ComposeContainer when the services are yours?
    Individual containers wired together on a shared network with aliases, configured in code. You gain typed handles, per-test configuration, module conveniences and precise wait strategies, and you lose a compose file that can change for reasons unrelated to the tests. The trade is more code in exchange for the test owning its own fixture.
  • What does withLocalCompose(true) change?
    It runs the compose CLI installed on the host rather than executing the compose project through a container. That can be faster and gives you exactly the features of the installed binary, but it now requires that binary to be present and consistent on every developer machine and CI image — a portability cost you take on deliberately.

saying these in an interview costs you the question

  • Connects to the ports published in the compose file
  • Assumes docker compose up returning means services are ready
  • Uses a compose file for services the test suite fully owns
  • Expects getMappedPort on the compose container itself
  • Treats a development compose file as a stable test contract

context