Building Production-Grade Integration Tests with Testcontainers and Spring
05 Mar 2026Integration 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:
- Slow test suites
- Repeated container startups
- Inconsistent configuration
The solution: static, shared containers
Containers should usually be:
- Static
- Started once per test suite
- Declared outside individual test methods
@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:
@DynamicPropertySource- or Spring Boot 3.1+
@ServiceConnection
@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:
- SQL dialect
- Index behavior
- JSON support
- Transaction isolation
will surface in production otherwise.
Migrations are part of the test contract
Database migrations should:
- Run automatically on test startup
- Fail fast if broken
- Never be skipped in tests
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:
- Use migration scripts for schema
- Use small, explicit fixtures for test data
@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.
- Enable reuse:
testcontainers.reuse.enable=true
- Mark containers as reusable:
.withReuse(true)
This dramatically speeds up local test execution.
Parallelize safely
Testcontainers supports parallel test execution if:
- Containers are shared
- Test data is isolated per test
Avoid global mutable state in tests.
CI considerations
- Use Docker-in-Docker or remote Docker daemons
- Cache container images aggressively
- Fail fast on infrastructure startup issues
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:
- Share containers
- Let Spring manage wiring
- Test real dependencies
- Optimize for developer feedback loops
If your integration tests still lie to you, the problem is not Testcontainers, it is how you are using it.