The problem: tests that pass on H2 and fail in production

A team writes repository tests against an in-memory H2 database in PostgreSQL compatibility mode. Everything is green. Then a release goes out and a query using jsonb, a partial index, or ON CONFLICT DO UPDATE fails, because H2 does not implement the same SQL dialect, the same locking behaviour or the same type system as PostgreSQL. The test suite proved that the code works on a database nobody runs in production.

The usual reaction is to say integration tests are "slow and flaky" and to mock the repository. That removes the exact risk you wanted to cover: the mapping between your code and the real database. A better answer is to keep the tests but give them the real thing, started on demand.

The core idea

Testcontainers is a Java library that starts throwaway Docker containers from test code. For a Spring Boot service the typical use is: before the tests run, start a postgres container, point the application at it, run Flyway or Liquibase migrations, and remove the container when the JVM exits. Your tests exercise the same engine, the same migrations and the same SQL as production. The only requirement is a Docker-compatible runtime on the machine that runs the tests.

This does not replace unit tests. It covers the part where bugs hide in the seams: SQL, transactions, constraints, serialization, and migrations.

Wiring it up with @ServiceConnection

Since Spring Boot 3.1 you do not need to copy the JDBC URL into properties by hand. The @ServiceConnection annotation lets Spring Boot create the connection details from the container.

@SpringBootTest@Testcontainersclass OrderRepositoryIT {    @Container    @ServiceConnection    static PostgreSQLContainer<?> postgres =        new PostgreSQLContainer<>("postgres:16-alpine");    @Autowired OrderRepository orders;    @Test    void savesAndFindsByCustomer() {        orders.save(new Order("c-1", "NEW"));        assertThat(orders.findByCustomerId("c-1")).hasSize(1);    }}

Pin the image tag to the major version you run in production. postgres:latest makes tests change behaviour without any commit in your repository.

If your service uses Flyway, migrations run automatically on startup, so the schema in the test is the schema you ship. That is one of the most valuable side effects: a broken migration fails the build.

Sharing one container across test classes

Starting a container per test class is the most common reason these suites become slow. A container start costs real seconds, and multiplying that by many classes hurts. Two practical options exist.

The first is a shared base configuration. Declare the container as a bean in a @TestConfiguration and import it from every test, so Spring's context cache reuses one context and one container.

@TestConfiguration(proxyBeanMethods = false)class TestcontainersConfig {    @Bean    @ServiceConnection    PostgreSQLContainer<?> postgres() {        return new PostgreSQLContainer<>("postgres:16-alpine");    }}

Then annotate tests with @Import(TestcontainersConfig.class). The same configuration also lets you run the application locally with a throwaway database through a test main method that uses SpringApplication.from(...).with(...), which is pleasant for local development.

The second option is the reuse feature, which keeps a container alive between JVM runs on a developer machine. It needs to be enabled explicitly in the local Testcontainers properties file and is meant for local speed only. Do not rely on it in CI, where each run should start clean.

Test isolation

A shared database means tests can affect each other. Choose a strategy on purpose.

  1. Transactional rollback. Annotate the test with @Transactional so each test rolls back. This is cheap and works for repository and service tests. It does not work if the code under test starts new transactions with REQUIRES_NEW, or if you call the application over HTTP, because the server thread has its own transaction.
  2. Explicit cleanup. Truncate tables in @BeforeEach with a single TRUNCATE ... RESTART IDENTITY CASCADE. It is simple and works for every kind of test, at a small cost per test.
  3. Unique data. Generate unique identifiers per test (a random customer id, for example) and assert only on your own data. This is the best option for tests that run in parallel.

Mixing these without a rule leads to order-dependent failures that appear only on the CI server. Pick one default for the project and write it in the test base class.

Speed tips

  • Reuse the Spring context. Every distinct combination of @MockBean, properties and profiles creates a new context. Group tests that need the same setup.
  • Keep the schema small and the seed data minimal. Load only what the test needs.
  • Use alpine or other slim images where they are officially published, and make sure the image is already cached on CI runners.
  • Do not start Kafka, Redis and PostgreSQL in every test. Start only what the test touches, and put heavy multi-container scenarios in a separate, smaller suite.
  • Run unit tests and integration tests as separate tasks, for example with the Maven Failsafe plugin and an *IT naming convention, so developers can run the quick part often.

CI considerations

The CI agent must have access to a Docker daemon. On hosted runners it is usually available. In a container-based CI where jobs themselves run inside containers, you need either a mounted Docker socket or a sidecar daemon, and both have security consequences: access to the Docker socket is effectively root on the host. Decide this together with whoever owns the CI platform.

Pull images through a registry mirror or cache when your CI has one, so a registry outage or rate limit does not fail the build. Set a sensible startup timeout, and print container logs on failure; a failed readiness check with no logs is hard to debug.

Common mistakes

  • Using latest tags and getting surprise upgrades.
  • Relying on @Transactional rollback while the code under test opens its own transaction.
  • Leaving test data behind and asserting on global counts such as "table has exactly 3 rows".
  • Making every test a @SpringBootTest when a @DataJpaTest slice with the container would start faster and test the same thing.
  • Hardcoding a port. Use the mapped port from the container; Testcontainers picks a random free one on purpose.
  • Skipping the migrations by using hibernate.ddl-auto=create. Then the test schema differs from the real one.

Trade-offs and when not to use it

Container-based tests are slower than pure unit tests and need Docker. For domain logic with no I/O, plain unit tests are better. For code whose only dependency is a third-party HTTP API, a stub server is a better fit than a container. And if your team cannot run Docker on developer laptops, solve that first, because a test suite that only some people can run slowly stops being used.

Checklist

  • The container image tag matches the production major version.
  • Schema comes from the real migrations, not from auto-generation.
  • One container is shared across the suite where possible.
  • There is one documented isolation strategy.
  • Integration tests run as a separate task from unit tests.
  • CI has Docker access and prints container logs on failure.

Where to go next