If you're running multiple services with Testcontainers (databases, Kafka, Redis, LocalStack, etc.), most CI slowdowns come from container startup, image pulls, and unnecessary isolation. A few patterns usually provide the biggest improvements.
- Start containers in parallel
If your services don't depend on each other, start them concurrently rather than sequentially.
For Java:
Startables.deepStart(
postgres,
kafka,
redis,
localstack
).join();
This can significantly reduce startup time when several images need initialization.
- Share containers across test classes
Instead of creating a fresh PostgreSQL instance per test class:
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16");
static {
postgres.start();
}
Reuse it for all integration tests in the same JVM.
If you're using JUnit 5, avoid instance-level containers unless you truly need isolation.
- Split tests by isolation requirements
Not every test needs a clean environment.
A common structure:
- Fast integration tests share one database and clean tables between tests.
- A smaller set of tests creates dedicated containers when full isolation is required.
Cleaning data is usually much faster than restarting containers.
- Pre-pull images in CI
The first run often spends most of its time downloading images.
Before running tests, pull images explicitly:
docker pull postgres:16
docker pull redis:7
docker pull confluentinc/cp-kafka:latest
Many CI systems can also cache Docker layers between runs.
- Pin image versions
Avoid:
new PostgreSQLContainer<>("postgres:latest");
Prefer:
new PostgreSQLContainer<>("postgres:16.4");
Pinned versions improve reproducibility and often increase cache hits.
- Use lighter services where possible
Ask whether every test really needs the production stack.
For example:
- PostgreSQL instead of a full application platform
- Redis instead of a larger cache cluster
- Single-node Kafka instead of a multi-broker setup
Only introduce heavier infrastructure where its behavior is actually under test.
- Minimize application context restarts
In Spring Boot, container startup may not be the bottleneck—the application context can be.
Avoid unnecessary:
@DirtiesContext
- different property sets per test class
- multiple Spring contexts
Sharing one context can save much more time than optimizing containers.
- Run tests in parallel carefully
JUnit 5 parallel execution can improve throughput if:
- tests don't mutate shared state
- databases are isolated by schema or transaction
- shared services are thread-safe
Otherwise, parallelism can introduce flaky tests.
- Reuse containers locally (not usually in CI)
For local development:
testcontainers.reuse.enable=true
and
container.withReuse(true);
This can reduce repeated startup from tens of seconds to nearly instant.
Most ephemeral CI runners shouldn't rely on reuse because each job starts with a clean environment.
- Separate unit and integration tests
A common pipeline is:
- Unit tests: every commit (seconds)
- Testcontainers integration tests: every PR
- Full end-to-end suite: nightly or before release
This keeps feedback fast while still validating the infrastructure interactions.
Typical bottlenecks
In many projects, the biggest wins come from:
| Bottleneck | Improvement |
|---|
| Image downloads | Pre-pull images or cache Docker layers |
| Sequential startup | Startables.deepStart() |
| One container per class | Share containers across the test JVM |
| Spring context reloads | Reuse the application context |
| Large service stack | Only start services required for the tests |
A well-tuned Testcontainers setup with PostgreSQL, Redis, and Kafka often completes integration tests in a few minutes rather than 10–20+, especially when containers are started once, reused within the JVM, and initialized in parallel.