When working with Testcontainers, developers often encounter the trade-off between test isolation and execution speed. Spinning up a fresh container for every test guarantees consistency but can dramatically slow down the suite. On the other hand, reusing containers saves time but introduces challenges in managing state. This is where reusable containers and lifecycle management become essential techniques.
In this tutorial, we’ll explore how to optimize container usage in JUnit 5 test suites by reusing containers, controlling their lifecycle, and ensuring reliable, production-like integration tests.
Why Lifecycle Management Matters
Containers provide an isolated, disposable environment for integration testing. But without lifecycle control, test runs may become:
- Slow — starting databases or message brokers repeatedly adds minutes to builds.
- Unreliable — improper cleanup can leak state across tests.
- Costly in CI/CD — running multiple containers unnecessarily increases resource usage.
By reusing containers intelligently, we achieve a balance between speed and reliability, making test pipelines both fast and trustworthy.
Reusable Containers in Testcontainers
Testcontainers provides two approaches for reusing containers:
1. Class-Level Containers with @Testcontainers
and @Container
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ReusableContainerTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("user")
.withPassword("pass");
@Test
void testDatabaseConnection() {
System.out.println("JDBC URL: " + postgres.getJdbcUrl());
// Your test logic here
}
}
How it works:
- The
@Container
annotation ensures the container starts once before all tests. - Since the container is declared
static
, it will not restart for each test method.
This drastically reduces test execution time.
2. Reuse Across Test Classes with Singleton Pattern
For larger test suites, it’s common to share a container across multiple test classes.
public class Containers {
private static final PostgreSQLContainer<?> POSTGRES_CONTAINER =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("shared_db")
.withUsername("user")
.withPassword("pass");
static {
POSTGRES_CONTAINER.start();
}
public static PostgreSQLContainer<?> getPostgresContainer() {
return POSTGRES_CONTAINER;
}
}
Usage in tests:
class OrderRepositoryTest {
private final PostgreSQLContainer<?> postgres = Containers.getPostgresContainer();
@Test
void testSaveOrder() {
String url = postgres.getJdbcUrl();
// Database test logic
}
}
Advanced Lifecycle Management
Reuse Flag (testcontainers.reuse.enable=true
)
Testcontainers supports container reuse between JVM runs when enabled in ~/.testcontainers.properties
:
testcontainers.reuse.enable=true
And marking containers as reusable:
new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true);
This allows the same container to be shared across multiple test executions, even in CI pipelines.
⚠️ Use with caution: persistent containers may leak state if not cleaned properly.
Managing Startup and Teardown
- Before All Tests → Start containers once (e.g., database, Kafka).
- After All Tests → Clean up resources or reset state.
- Per Test Method → Use lightweight mocks (e.g., WireMock) instead of spinning full containers.
Best Practices
- Prefer per-class containers for most cases to balance isolation and speed.
- Use singleton containers when integration tests rely on expensive startup times (databases, message brokers).
- Enable reuse only in trusted environments (developer laptops, controlled CI).
- Combine Testcontainers with mocks (Mockito, WireMock) to avoid over-reliance on external services.
- Reset state between tests to prevent flaky results.
- Use Docker Compose support for complex environments.
- Integrate with CI/CD pipelines (GitHub Actions, Jenkins) to ensure containers are reproducible.
Example: Spring Boot + PostgreSQL with Reusable Containers
@SpringBootTest
@Testcontainers
class SpringBootPostgresTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("appdb")
.withUsername("appuser")
.withPassword("apppass");
@Autowired
private DataSource dataSource;
@Test
void testDataSourceIsConnected() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
assertNotNull(conn);
}
}
}
Here, the PostgreSQL container runs once for the entire suite, reducing runtime significantly.
Version Tracker
- JUnit 4 → JUnit 5: Testcontainers switched from JUnit rules to
@Testcontainers
and@Container
annotations. - Mockito: Added support for mocking static and final methods.
- Testcontainers: Introduced reusable containers and Docker Compose integration.
Conclusion
Managing container lifecycle is one of the most powerful ways to speed up integration testing while keeping it reliable. By carefully choosing between per-test, per-class, and reusable containers, developers can achieve fast feedback loops without sacrificing accuracy. When combined with mocking frameworks like Mockito and tools like WireMock, Testcontainers becomes a cornerstone of modern Java testing.
Key Takeaways
- Use per-class containers for fast, isolated tests.
- Apply singleton containers for expensive setups like databases.
- Leverage reuse flags cautiously in CI/CD.
- Combine Testcontainers + Mockito + WireMock for comprehensive test coverage.
- Always reset state to avoid flaky results.
FAQ
1. What is the difference between per-test and per-class containers?
Per-test containers start for every method (slower but isolated), while per-class containers start once for the test class (faster).
2. Can I reuse containers across different test classes?
Yes, by using a singleton pattern or enabling Testcontainers reuse.
3. Does container reuse work in CI/CD pipelines?
Yes, but it should be configured carefully to avoid state leakage.
4. How do I enable container reuse globally?
Set testcontainers.reuse.enable=true
in ~/.testcontainers.properties
.
5. What are the risks of reusing containers?
State leakage, flaky tests, and security issues in shared environments.
6. Can I combine Testcontainers with WireMock?
Yes, for hybrid testing where some dependencies run in containers and others are mocked.
7. How does lifecycle management improve CI/CD speed?
By reducing redundant container startups, saving build minutes.
8. What is the best strategy for microservices testing?
Use Testcontainers for core dependencies (databases, brokers) and mocks for external APIs.
9. Do reusable containers replace mocks?
No, mocks and containers complement each other. Use mocks for logic, containers for real integration.
10. How do I debug slow Testcontainers startups?
Enable verbose logs and check Docker resource allocation.