Parallel Test Execution in JUnit 6: Configuration and Pitfalls

Running tests sequentially is safe but slow. A test suite that takes 10 minutes to run sequentially can often complete in 2–3 minutes with parallel execution. JUnit 6 has first-class support for parallel test execution — but it requires careful configuration to avoid flaky tests caused by shared state. This guide covers every configuration option, pitfall, and real-world pattern you need to enable parallel execution confidently.

How JUnit 6 Parallel Execution Works

JUnit 6 parallel execution is disabled by default. When enabled, JUnit can run tests at two levels:

  • Class-level parallelism — multiple test classes run concurrently in different threads
  • Method-level parallelism — multiple test methods within the same class run concurrently

These are configured independently, giving you fine-grained control over the degree of parallelism.

Step 1: Enable Parallel Execution

Create or edit src/test/resources/junit-platform.properties:

# Enable parallel execution (disabled by default)
junit.jupiter.execution.parallel.enabled=true

# Default execution mode for all tests
# 'concurrent' = run in parallel with other tests at the same level
# 'same_thread' = run in the same thread as parent (sequential)
junit.jupiter.execution.parallel.mode.default=concurrent

# Default execution mode for top-level test classes
# concurrent = classes run in parallel
# same_thread = classes run sequentially (but methods within might be concurrent)
junit.jupiter.execution.parallel.mode.classes.default=concurrent

Step 2: Configure the Thread Pool Strategy

# Strategy: 'dynamic' uses (availableProcessors * factor) threads
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

# Strategy: 'fixed' uses an exact thread count
# junit.jupiter.execution.parallel.config.strategy=fixed
# junit.jupiter.execution.parallel.config.fixed.parallelism=4

# Strategy: 'custom' uses your own ParallelExecutionConfigurationStrategy
# junit.jupiter.execution.parallel.config.strategy=custom
# junit.jupiter.execution.parallel.config.custom.class=com.example.MyStrategy

Step 3: Control Parallelism Per Test with @Execution

Override the global setting for specific classes or methods using the @Execution annotation:

import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;

// This class runs its methods CONCURRENTLY (parallel within the class)
@Execution(ExecutionMode.CONCURRENT)
class FastStatelessCalculatorTest {

    // Both methods run in parallel — safe because Calculator is stateless
    @Test
    void additionTest() {
        assertEquals(5, new Calculator().add(2, 3));
    }

    @Test
    void multiplicationTest() {
        assertEquals(6, new Calculator().multiply(2, 3));
    }
}

// This class forces sequential execution even if global mode is concurrent
// Use for tests that share state or external resources
@Execution(ExecutionMode.SAME_THREAD)
class StatefulDatabaseTest {

    // Methods run one after another — safe because they share DB state
    @Test
    void insertUser() { /* inserts a row */ }

    @Test
    void queryUser() { /* reads the row inserted above */ }
}

Step 4: Synchronise Shared Resources with @ResourceLock

@ResourceLock prevents concurrent access to a named shared resource. Tests annotated with the same resource name will not run concurrently with each other:

import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.api.parallel.ResourceAccessMode;

class SystemPropertyTest {

    // READ lock: multiple readers can run concurrently
    @Test
    @ResourceLock(value = "system.properties", mode = ResourceAccessMode.READ)
    void readingSystemPropertyIsThreadSafe() {
        assertNotNull(System.getProperty("user.home"));
    }

    // READ_WRITE lock: exclusive access — no other test using this resource runs concurrently
    @Test
    @ResourceLock(value = "system.properties", mode = ResourceAccessMode.READ_WRITE)
    void settingSystemPropertyAffectsGlobalState() {
        String originalValue = System.getProperty("my.test.flag");
        try {
            System.setProperty("my.test.flag", "true");
            assertEquals("true", System.getProperty("my.test.flag"));
        } finally {
            // Always restore system property to avoid polluting other tests
            if (originalValue == null) {
                System.clearProperty("my.test.flag");
            } else {
                System.setProperty("my.test.flag", originalValue);
            }
        }
    }
}

Complete Configuration Example

# src/test/resources/junit-platform.properties

# Enable parallel execution
junit.jupiter.execution.parallel.enabled=true

# Run test CLASSES concurrently
junit.jupiter.execution.parallel.mode.classes.default=concurrent

# Run test METHODS sequentially within each class (safer default)
junit.jupiter.execution.parallel.mode.default=same_thread

# Use dynamic thread count: (CPU cores * 1.5)
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.5

# Safety net: abort any test taking longer than 60 seconds
junit.jupiter.execution.timeout.default=60s

Common Pitfalls and How to Fix Them

ProblemRoot CauseFix
Tests pass alone but fail in parallelShared mutable static stateRemove static state; use instance fields with @BeforeEach
Database constraint violationsTwo tests insert same unique keyUse unique test data per test; use @ResourceLock
System property pollutionOne test sets a property, another reads itUse @ResourceLock with READ_WRITE mode
Port conflicts in server testsTwo tests start server on same portUse random port selection; Testcontainers handles this automatically
ThreadLocal data leaksThreadLocal not cleared after testClear ThreadLocal in @AfterEach
Flaky ordering-dependent testsTests rely on execution orderMake each test independent; use @BeforeEach for fresh state

Measuring the Speed-Up

# Run tests sequentially (baseline)
mvn test -Djunit.jupiter.execution.parallel.enabled=false
# [INFO] Tests run: 120, Time elapsed: 45.2 s

# Run tests in parallel (concurrent classes, same-thread methods)
mvn test -Djunit.jupiter.execution.parallel.enabled=true
# [INFO] Tests run: 120, Time elapsed: 12.8 s

# Speed-up: 45.2 / 12.8 = 3.5x faster on an 8-core machine

Frequently Asked Questions (FAQs)

Q1: Is it safe to enable parallel execution on existing test suites?

Start conservatively: enable class-level parallelism only (mode.classes.default=concurrent, mode.default=same_thread). This runs classes concurrently but methods within each class sequentially. Most test suites are safe at this level. Once tests pass, gradually enable method-level parallelism for classes you are confident are stateless. Use @Execution(SAME_THREAD) on any class that shares external state.

Q2: How many threads should I use?

For CPU-bound tests (pure computation), set the thread count to match your available processors. For I/O-bound tests (database, HTTP calls), a higher count (2–3x processors) is often faster because threads spend time waiting. The dynamic strategy with a factor between 1.0 and 2.0 is a good starting point for mixed workloads. Profile your specific suite to find the optimal value.

Q3: Does parallel execution work with Spring Boot tests?

Yes, but with care. Spring Boot’s ApplicationContext caching means multiple parallel test classes may share the same context. This is generally thread-safe for read-only tests but can cause issues if tests modify the context (e.g., using @MockBean differently per class). Use @Execution(SAME_THREAD) on classes that modify Spring beans. See JUnit 6 with Spring Boot for detailed guidance.

Q4: How do I disable parallel execution for a single test class?

Annotate the class with @Execution(ExecutionMode.SAME_THREAD). This forces all methods in that class to run sequentially in the same thread, regardless of the global configuration. This is the recommended approach for tests that use shared external resources, system properties, or static fields.

Q5: Can I use parallel execution with @TestFactory dynamic tests?

Yes. @TestFactory methods participate in parallel execution at the method level. The generated DynamicTest instances run in the same thread as the factory method by default. To make individual dynamic tests run in parallel, the factory method itself must be marked @Execution(CONCURRENT) and each dynamic test’s executable will be scheduled on the parallel executor.

See Also

Conclusion

Parallel test execution is one of the highest-return configuration changes you can make to a mature JUnit 6 project. Start with class-level concurrency, guard shared resources with @ResourceLock, and use @Execution(SAME_THREAD) to opt individual classes out. Done correctly, you can cut a 10-minute test suite down to 2–3 minutes without changing a single test assertion.

Next: JUnit 6 Internals: How the Test Engine Works — understand the engine lifecycle, test descriptor trees, and how to use the Launcher API programmatically.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.