Parallel Test Execution with Testcontainers
A test suite with 50 integration test classes runs sequentially in 10 minutes. Configured for parallel execution across 4 threads, it completes in 3 minutes. Parallel test execution with Testcontainers requires careful data isolation — tests running simultaneously against the same database will step on each other’s data without it. This article covers JUnit 5 parallel configuration, container sharing strategies, and data isolation techniques.
What You’ll Learn
- JUnit 5 parallel execution configuration with
junit-platform.properties - When to use shared containers vs separate containers per thread
- Schema-based isolation for parallel database tests
- Transaction rollback as a data isolation strategy
- Unique prefix strategies for Kafka topics and Redis keys
- Maven Surefire and Gradle parallel fork configuration
JUnit 5 Parallel Execution
JUnit 5 supports parallel test execution through junit-platform.properties:
# src/test/resources/junit-platform.properties
# Enable parallel execution
junit.jupiter.execution.parallel.enabled=true
# Strategy: fixed thread count or dynamic (based on CPU cores)
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1
# Default: test CLASSES run in parallel, test METHODS within a class run sequentially
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrent
With this configuration:
- Test classes run in parallel (up to
dynamic.factor × CPU coresthreads) - Test methods within a class run sequentially
dynamic.factor=1with 4 CPU cores = 4 concurrent test classes
The Data Isolation Problem
Without isolation:
Thread 1 (OrderRepositoryTest): Thread 2 (ProductRepositoryTest):
- INSERT order (customer-1) - INSERT product (SKU-001)
- INSERT order (customer-2) - SELECT count(*) FROM orders ← finds 2 rows!
- SELECT count(*) FROM orders - Test fails: expected 0, got 2
- Test passes (correctly finds 2)
Both threads share the same database and interfere with each other’s data.
Strategy 1: Transaction Rollback (Simplest)
@Transactional on @SpringBootTest rolls back after each test. Since each test’s changes are within a transaction that never commits, parallel tests do not see each other’s data.
@SpringBootTest
@Transactional // each test is rolled back
class OrderRepositoryTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersByStatus() {
orderRepository.save(new Order("c1", OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
// These rows only exist within this transaction
// The parallel test running in Thread 2 cannot see uncommitted data
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(orders).hasSize(1);
}
}
Limitation: Transaction rollback does not work for tests that need to verify behavior after commit — for example, verifying that a database trigger fired, or that a separate transaction can see the committed data.
Strategy 2: Unique Test Data Prefixes
Each test class uses a unique prefix for its test data. This avoids interference without requiring transactions:
@SpringBootTest
class OrderRepositoryTest extends AbstractIntegrationTest {
// Unique prefix for this test class
private static final String TEST_PREFIX = "order-test-" + UUID.randomUUID();
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanup() {
orderRepository.deleteByCustomerIdStartingWith(TEST_PREFIX);
}
@Test
void shouldFindOrdersByCustomer() {
String customerId = TEST_PREFIX + "-customer-1";
orderRepository.save(new Order(customerId, OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
orderRepository.save(new Order(customerId, OrderStatus.CONFIRMED, BigDecimal.valueOf(75.00)));
// Query using the unique prefix — not affected by other parallel tests
List<Order> orders = orderRepository.findByCustomerId(customerId);
assertThat(orders).hasSize(2);
}
}
This works for any shared container. The downside is that you cannot test count() across all records — but that is rarely necessary.
Strategy 3: Separate Schemas per Test Class
Each test class runs against its own PostgreSQL schema. Schemas provide complete isolation without transactions:
public abstract class AbstractParallelTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
POSTGRES.start();
}
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
// Each subclass overrides getSchemaName()
registry.add("spring.datasource.url",
() -> POSTGRES.getJdbcUrl() + "?currentSchema=" + getSchemaName());
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
protected static String getSchemaName() {
return "public"; // override in subclasses for isolation
}
}
@SpringBootTest
class OrderRepositoryTest extends AbstractParallelTest {
@Override
protected static String getSchemaName() {
return "order_test";
}
@BeforeAll
static void createSchema(@Autowired JdbcTemplate jdbc) {
jdbc.execute("CREATE SCHEMA IF NOT EXISTS order_test");
jdbc.execute("SET search_path TO order_test");
}
}
Schema-based isolation is clean but requires schema creation in @BeforeAll and Flyway/Hibernate configuration for schema-specific migration.
Parallel Kafka Test Isolation
Kafka tests face a different isolation challenge: consumer groups and topics persist between tests.
Unique Consumer Groups
public abstract class AbstractKafkaTest {
static final KafkaContainer KAFKA;
static {
KAFKA = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
KAFKA.start();
}
@DynamicPropertySource
static void configureKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
// Unique consumer group per test class prevents offset sharing
registry.add("spring.kafka.consumer.group-id",
() -> "test-" + getClass().getSimpleName() + "-" + UUID.randomUUID());
}
}
Unique Topic Names
// Each test class uses its own topic
private static final String TOPIC = "order-events-" + UUID.randomUUID();
@BeforeAll
static void createTopic(@Autowired KafkaAdmin kafkaAdmin) throws Exception {
try (AdminClient admin = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
admin.createTopics(List.of(new NewTopic(TOPIC, 1, (short) 1))).all().get();
}
}
Parallel Redis Test Isolation
Redis has no schema concept. Use key prefixes:
public abstract class AbstractRedisTest {
static final GenericContainer<?> REDIS;
static {
REDIS = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));
REDIS.start();
}
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
// Use class-specific key prefix to isolate parallel tests
registry.add("spring.cache.redis.key-prefix",
() -> getClass().getSimpleName() + "::");
}
}
Maven Surefire Parallel Configuration
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Fork per CPU core -->
<forkCount>1C</forkCount>
<reuseForks>true</reuseForks>
<!-- JUnit 5 parallel config takes over within each fork -->
</configuration>
</plugin>
forkCount=1C means one fork per CPU core. Combined with JUnit 5’s in-process parallelism, this gives you both process-level and thread-level parallelism.
Gradle Parallel Configuration
tasks.test {
useJUnitPlatform()
maxParallelForks = Runtime.getRuntime().availableProcessors().div(2).takeIf { it > 0 } ?: 1
jvmArgs("-XX:+UseG1GC") // GC tuning for parallel test JVMs
}
@Execution Annotation for Fine-Grained Control
JUnit 5’s @Execution controls parallelism at the class or method level:
// Force this test class to run sequentially (even when global parallel is on)
@Execution(ExecutionMode.SAME_THREAD)
@SpringBootTest
class DatabaseMigrationTest extends AbstractIntegrationTest {
// Migration tests should not run in parallel with other tests
}
// Force this test class to run concurrently
@Execution(ExecutionMode.CONCURRENT)
@SpringBootTest
class OrderReadTest extends AbstractIntegrationTest {
// Read-only tests are safe to parallelize
}
Common Pitfalls
Parallel tests without data isolation. This is the most common problem. Tests randomly fail depending on execution order. Always implement one of the isolation strategies before enabling parallel execution.
Too many parallel forks with container startup. If you configure 8 parallel forks and each fork starts its own containers, you may start 8 PostgreSQL containers simultaneously. Use the singleton base class pattern to share containers across parallel forks.
Race conditions in @BeforeAll. If multiple test classes extend the same base class with a @BeforeAll that modifies shared state (like creating a Kafka topic), multiple threads may try to create the same topic simultaneously. Use synchronized or create topics in the container startup, not in test lifecycle methods.
Summary
JUnit 5 parallel execution combined with Testcontainers dramatically reduces integration test suite run time. Use transaction rollback or unique data prefixes for simple isolation. Use schema-based isolation when you need full database separation. Use unique consumer groups and topic names for Kafka test isolation. Enable parallelism gradually — start with dynamic.factor=0.5 and increase as you verify test stability.
The final article covers CI/CD configuration, development-time container setup, and production patterns.
Next: Testcontainers in CI/CD — GitHub Actions, Docker, and Production Patterns