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 cores threads)
  • Test methods within a class run sequentially
  • dynamic.factor=1 with 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