Reliable Microservice Integration Tests with Docker Compose and Testcontainers

Microservices make it possible to release small, independently deployable components, but their boundaries also create more ways for software to fail. A service can pass its unit tests while using the wrong JSON field, database schema, message header or timeout when it communicates with a neighbour. Integration testing verifies those interactions in an environment close enough to production to expose such defects early.

Docker Compose and Testcontainers are practical tools for building this verification layer. Compose describes a multi-container system clearly and is convenient for local development. Testcontainers starts disposable infrastructure from code, allowing a test suite to create the exact database, broker or dependent service it needs and remove it afterwards.

For Australian engineering teams working across Sydney, Melbourne and Brisbane, this approach can reduce the “works on my machine” gap between developers and CI agents. It also helps teams test services that handle Australian addresses, AUD transactions, AEST timestamps or integrations hosted in the AWS Sydney region without relying on shared, fragile environments.

Concern Docker Compose Testcontainers
Main strength Reproducible multi-service environments Disposable infrastructure controlled by tests
Best fit Local development and broad system checks Automated integration and component tests
Configuration YAML files and environment variables Java, Groovy or another supported language
Lifecycle Usually started for a session or pipeline stage Created and removed per test suite or class
Typical dependencies Several application services, databases and brokers PostgreSQL, Kafka, Redis, WireMock and similar tools
Main risk Shared state and lengthy startup More code and container startup overhead

Choosing The Right Test Boundary

An integration test should verify a meaningful interaction across a real technical boundary. For example, an order service might write an order to PostgreSQL, publish an event to Kafka and call a shipping service over HTTP. Testing all three dependencies in every test would be slow and difficult to diagnose, so begin with a specific contract or behaviour.

A useful split is to run focused component tests for one service with its real database and mocked external HTTP APIs, then run a smaller number of end-to-end checks with several real services. This gives fast feedback for repository and persistence behaviour while preserving confidence that service-to-service communication works.

Avoid turning integration tests into a second unit-test suite. Testing every validation branch through a full Docker environment adds cost without increasing much confidence. Instead, cover database mappings, transaction behaviour, serialisation, authentication headers, retry handling and failure responses where a real dependency changes the result.

For a Java or Groovy project, name the boundary in the test itself. A test called publishesOrderCreatedEventAfterCommit communicates more value than createOrderIntegrationTest. Clear naming also makes failures easier to interpret when a CI job runs hundreds of checks overnight.

Building A Stable Compose Environment

Docker Compose is effective when several local services need to start together. A minimal file might define a PostgreSQL database, a message broker and a WireMock container that represents a third-party API. Use explicit image tags rather than floating tags, and configure health checks so application containers do not start before their dependencies are ready.

services:
  postgres:
    image: postgres:16.4
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: orders
      POSTGRES_PASSWORD: orders
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orders -d orders"]
      interval: 5s
      timeout: 3s
      retries: 10

  wiremock:
    image: wiremock/wiremock:3.9.1
    ports:
      - "8089:8080"

Do not hard-code hostnames that only work on one laptop. Inside a Compose network, a service should connect to postgres:5432, while a test running on the host may need localhost:5433. This distinction is a common source of failures when the same test is executed locally, in GitHub Actions or on a self-hosted runner.

Keep test data isolated. A fresh database volume is usually preferable for automated checks, while a persistent volume can be useful for manual development. Compose profiles can separate optional tools such as a local observability stack from the dependencies required by the test suite.

Australian teams should also account for regional CI behaviour. A pipeline running in Sydney may have different image-pull latency from one running in Singapore, and a corporate proxy can affect Docker Hub access. Pinning images, caching dependencies and using a registry approved by the organisation makes execution more predictable.

Using Testcontainers In Automated Tests

Testcontainers moves infrastructure setup into the test lifecycle. A Java test can start a PostgreSQL container, expose its mapped port and pass the resulting JDBC URL to the application under test. The test does not assume that PostgreSQL is installed locally or that port 5432 is available.

With Spring Boot, a common pattern is a @Testcontainers class containing a PostgreSQLContainer, combined with dynamic property registration:

@Testcontainers
@SpringBootTest
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16.4")
            .withDatabaseName("orders")
            .withUsername("orders")
            .withPassword("orders");

    @DynamicPropertySource
    static void databaseProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

The same pattern works for Redis, RabbitMQ, Kafka, LocalStack and generic Docker images. Reusable containers can improve local speed, but they weaken isolation and can conceal tests that depend on state left by another run. Use reuse deliberately, and keep CI runs disposable.

Testcontainers also supports networked scenarios. Place a service container and a WireMock container on a shared Docker network, then use the container alias rather than a mapped host port. This more closely resembles production networking and avoids assumptions about the machine executing the tests.

Container startup time should be measured rather than guessed. Reuse a container for all tests in a class or suite when the data can be reset safely. Use database migrations once per suite, truncate tables between tests, and keep test fixtures small. A test that creates an entire production-sized dataset is measuring infrastructure more than application behaviour.

Testing HTTP, Messaging And Asynchronous Workflows

REST integration tests should verify the complete request path: routing, authentication, validation, persistence and response serialisation. Send requests through the application’s real HTTP port and assert status codes, response bodies and important headers. Contract tests can then check that a provider still supplies the fields expected by a consumer.

WireMock or MockServer is useful for external systems that are expensive, rate-limited or unavailable in a test environment. Stub the exact request method, path, headers and body, then verify that the application made the call. Include error scenarios such as a 503 response, malformed payload and slow response to exercise timeout and retry policies.

Asynchronous systems need a different style of assertion. Do not rely on Thread.sleep(5000) and hope that an event arrives. Poll with a bounded timeout, use a unique correlation ID and assert that the expected message appears in the intended topic or queue. Awaitility is commonly used in JVM projects for this purpose.

A realistic event test should also check delivery semantics. Confirm whether duplicate messages are handled idempotently, whether processing is committed after database work, and what happens when a consumer restarts. For Kafka, test the topic name, key, schema and headers. For a local Australian payments workflow, this might include an AUD amount, a customer’s state abbreviation and an event timestamp represented consistently in UTC rather than local daylight-saving time.

Do not make asynchronous tests depend on wall-clock assumptions. Melbourne and Sydney move between daylight-saving and standard time, while Brisbane does not. Store instants in UTC, convert only at presentation boundaries and use deterministic clocks where business rules involve dates or cut-off times.

Making Failures Fast And Diagnosable

A reliable integration suite gives useful evidence when it fails. Capture container logs, application logs, database migration output and the request or message that triggered the error. Testcontainers can expose container logs through the test framework, while Compose can collect service logs as a CI artefact.

Use separate test profiles and explicit ports only where necessary. Random mapped ports reduce collisions when several jobs run in parallel, particularly on shared CI agents. If an application cannot accept a dynamic port, isolate jobs at the runner level instead of allowing tests to compete for fixed ports.

Database cleanup is another major source of flaky results. Transaction rollback is fast but may not work when the system under test opens a separate connection or processes a message asynchronously. In those cases, truncate known tables, recreate schemas or start a fresh container. The correct choice depends on the boundary being tested.

Treat network failures as test results that need diagnosis, not as reasons to increase every timeout. A short retry may be correct for eventual consistency, but broad retries can hide a broken service, invalid credentials or an incorrect container alias. Report the elapsed wait and the last observed state so the failure points to the real problem.

For organisations subject to the Australian Privacy Act, synthetic customer records should be the default. Do not copy production personal information into local containers merely because it makes fixtures convenient. Mask names, addresses and identifiers, and consider how test artefacts are retained in CI systems hosted in the AWS ap-southeast-2 region or elsewhere.

Making The Test Suite Sustainable

Start with one valuable workflow rather than trying to containerise every dependency at once. A useful first slice could create an order through the REST API, verify its PostgreSQL record and consume the resulting event. Once that path is stable, add a failed payment response, duplicate event delivery and an application restart.

Keep the environment definition close to the code that depends on it. Compose files can serve developers and broad system checks, while Testcontainers classes can give focused integration tests their own lifecycle. Document which layer owns each dependency so that a future change does not accidentally require a shared database or an unavailable third-party endpoint.

Run fast unit and component tests on every commit, then execute the broader container suite in the same pull request pipeline when practical. Parallelise independent test classes, cache Docker images and publish logs only when a job fails. Track duration and flakiness as engineering metrics; a suite that takes forty minutes or fails twice a week will eventually be bypassed.

The concrete next step is to create one OrderRepositoryIT using a pinned PostgreSQL Testcontainer, apply the project’s real migration scripts, and run it in the same CI pipeline as the unit tests.