Building Production-Grade Integration Tests with Testcontainers and Spring

Integration testing in Spring projects often starts with good intentions and ends with fragile compromises: in-memory databases, mocked brokers, and profiles that behave nothing like production. Testcontainers changes that equation by making real infrastructure cheap, disposable, and automatable. When combined correctly with the Spring Framework, especially Spring Boot, it enables integration tests that are both realistic and fast enough to run continuously.

In this article I talk about what developers can actively do to improve their projects when adopting Testcontainers. The emphasis is not on “how to start,” but on how to structure, optimize, and scale its usage across a mature Spring codebase.


Lifecycle Management & Context Caching

The first mistake teams make with Testcontainers is treating containers as test-local resources. Doing so defeats Spring’s strongest feature: ApplicationContext caching.

The problem

If each test class starts and stops its own container, Spring sees a different environment every time and invalidates the cached context. This leads to:

The solution: static, shared containers

Containers should usually be:

@Testcontainers
public abstract class AbstractIntegrationTest {

  @Container
  static PostgreSQLContainer<?> postgres =
      new PostgreSQLContainer<>("postgres:16-alpine")
          .withDatabaseName("testdb")
          .withUsername("test")
          .withPassword("test");
}

Spring will reuse the same ApplicationContext as long as the environment properties remain stable.

Dynamic property registration

Instead of hardcoding properties, register them dynamically:

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

Developer takeaway: Design your test infrastructure for context reuse first. Containers are infrastructure, not test data.


Integration with Spring Boot Test Infrastructure

Spring Boot’s test infrastructure already solves 80% of integration testing problems, Testcontainers should plug into it, not replace it.

Prefer @SpringBootTest over slicing too early

For infrastructure-heavy tests (databases, messaging, caches), use:

@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIT extends AbstractIntegrationTest {
  // real wiring, real infrastructure
}

Avoid premature test slicing (@DataJpaTest, @WebMvcTest) when your goal is validating system behavior across layers.

Replace @TestPropertySource

With Testcontainers, static property files often become liabilities. Use:

@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
  new PostgreSQLContainer<>("postgres:16-alpine");

Spring Boot will auto-configure the datasource without explicit property mapping.

Developer takeaway: Let Spring Boot do the wiring. Testcontainers should provide resources, not configuration logic.


Database Containers & Migration Strategy

Databases are where integration tests most commonly diverge from production, and where Testcontainers delivers the highest return.

Always test against the real engine

If production uses PostgreSQL, do not test with H2 or HSQLDB. Subtle differences in:

will surface in production otherwise.

Migrations are part of the test contract

Database migrations should:

Using Flyway:

spring:
  flyway:
    enabled: true
    locations: classpath:db/migration

Testcontainers ensures a clean database every time, so migrations become deterministic.

Seed data intentionally

Avoid large SQL dumps. Instead:

@Sql("/sql/seed-orders.sql")
  class OrderRepositoryIT extends AbstractIntegrationTest {
}

Developer takeaway: If migrations are not tested continuously, they are untested code.


Messaging & External Dependencies (Kafka, Redis, etc.)

Modern Spring systems rarely depend on a single database. Messaging systems, caches, and external services are just as critical, and just as testable.

Kafka with Testcontainers

@Container
static KafkaContainer kafka =
  new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

Register properties:

@DynamicPropertySource
static void kafkaProps(DynamicPropertyRegistry registry) {
  registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}

Now your @KafkaListener beans are wired against a real broker.

Redis for caching and locks

@Container
static GenericContainer<?> redis =
  new GenericContainer<>("redis:7-alpine")
    .withExposedPorts(6379);

Avoid embedded Redis or mock caches. Cache invalidation bugs are logic bugs.

Compose dependencies explicitly

For complex setups, consider DockerComposeContainer or multiple containers in a shared base class.

Developer takeaway: If production depends on it, tests should too—especially messaging systems.


CI/CD & Developer Experience Optimization

Testcontainers succeeds or fails based on how well it integrates into daily development and CI pipelines.

Enable container reuse locally

For developers, container startup time matters.

  1. Enable reuse:
testcontainers.reuse.enable=true
  1. Mark containers as reusable:
.withReuse(true)

This dramatically speeds up local test execution.

Parallelize safely

Testcontainers supports parallel test execution if:

Avoid global mutable state in tests.

CI considerations

Most CI failures related to Testcontainers are environmental, not test-related. Make infrastructure logs visible.

Make tests runnable by default

A developer should be able to run:

./mvnw test

No profiles. No flags. No “read the wiki.”

Developer takeaway: Integration tests only add value if developers actually run them.


Final Opinionated Guidance

Testcontainers is not just a testing library, it is a design constraint that nudges teams toward production realism. Used poorly, it slows test suites and frustrates developers. Used well, it becomes invisible infrastructure that quietly prevents entire classes of bugs.

The winning strategy is consistent:

If your integration tests still lie to you, the problem is not Testcontainers, it is how you are using it.