JUnit5 & Gradle Parallel Testing Example
Modern software applications often contain hundreds or even thousands of automated tests. While these tests help maintain software quality, execution time can become a bottleneck in Continuous Integration (CI/CD) pipelines. Parallel testing solves this problem by executing multiple tests simultaneously across multiple threads, significantly reducing build and execution time. In this article, we will explore how parallel testing works with Gradle and JUnit 5, understand the underlying concepts, examine common challenges, and build a complete working example.
1. Overview
Test execution speed directly impacts developer productivity. When a project contains a large number of unit and integration tests, waiting for test results can slow down development cycles. Parallel testing allows multiple test classes or test methods to run concurrently using separate threads. Instead of executing tests one after another, the testing framework distributes work across available CPU cores. Benefits of parallel testing include:
- Faster build execution
- Reduced CI/CD pipeline duration
- Better utilization of modern multi-core processors
- Quicker feedback for developers
- Improved scalability of automated testing suites
JUnit 5 provides built-in support for parallel execution, while Gradle acts as the build automation tool responsible for configuring and launching the test process.
1.1 Understanding Gradle
Gradle is a modern build automation and dependency management tool used primarily for Java, Kotlin, Groovy, and Android projects. It combines the flexibility of Apache Ant with the dependency management capabilities of Apache Maven. Gradle uses a Directed Acyclic Graph (DAG) to determine task execution order and executes only the tasks required for a build.
Unlike traditional build tools that execute tasks sequentially, Gradle builds a task graph and optimizes execution by avoiding unnecessary work. This approach improves build performance, especially in large projects containing multiple modules and thousands of test cases.
1.1.1 How Gradle Works?
Gradle executes builds in three phases:
- Initialization Phase – Determines participating projects.
- Configuration Phase – Evaluates build scripts and creates task graphs.
- Execution Phase – Executes selected tasks.
For testing, Gradle integrates with testing frameworks such as JUnit and manages the complete test lifecycle from compilation to report generation.
- Compiles source code.
- Compiles test classes.
- Resolves dependencies.
- Launches JUnit Platform.
- Collects test results.
- Generates reports.
1.2 Understanding JUnit 5
JUnit 5 is the latest generation of the JUnit testing framework. It provides a modular architecture that separates the test execution platform from the test programming model, making it easier to extend and integrate with build tools such as Gradle and Maven. JUnit 5 consists of three major components:
- JUnit Platform
- JUnit Jupiter
- JUnit Vintage
The JUnit Platform serves as the execution engine, Jupiter provides the programming and extension model for writing tests, and Vintage enables the execution of legacy JUnit 3 and JUnit 4 tests. JUnit 5 introduced several enhancements that improve test maintainability, flexibility, and execution performance:
- Parallel test execution
- Dynamic tests
- Nested tests
- Parameterized tests
- Improved extension model
- Conditional execution
1.3 The State Horror
Parallel testing can significantly reduce execution time, but it also increases the risk of issues caused by shared mutable state. A mutable state is any data that can be modified during execution. When multiple test threads access and modify the same resource simultaneously, the outcome may become non-deterministic and difficult to reproduce. Common sources of shared state include static variables, singleton instances, shared caches, databases, temporary files, external services, and application-wide configuration objects. A test that passes consistently in sequential execution may start failing unpredictably when executed in parallel because multiple threads compete for the same resource. These issues typically manifest as:
- Race conditions
- Intermittent failures
- Flaky tests
- Data corruption
- Unpredictable execution order issues
For this reason, test isolation becomes one of the most important design principles when enabling parallel test execution.
1.4 Best Practices
To achieve reliable parallel execution, tests should be designed so that they do not depend on shared state or execution order. Each test should be capable of running independently regardless of how many other tests are executing at the same time.
- Avoid static mutable variables.
- Keep tests independent.
- Use immutable objects.
- Create fresh test data.
- Clean resources after execution.
- Use thread-safe collections.
- Isolate database records.
Following these practices helps eliminate test interference, improves reproducibility, and ensures that parallel execution delivers faster feedback without compromising test reliability.
2. Code Example
2.1 Gradle Configuration
The following Gradle build file enables JUnit 5 support.
plugins {
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(
'org.junit.jupiter:junit-jupiter:5.12.0'
)
}
test {
useJUnitPlatform()
}
The Gradle build configuration applies the java plugin to add Java compilation and testing capabilities to the project. The repositories section specifies mavenCentral() as the source for downloading project dependencies. In the dependencies block, the testImplementation configuration adds JUnit Jupiter version 5.12.0, which provides the APIs and test engine required for writing and executing JUnit 5 tests. Finally, the test task is configured with useJUnitPlatform(), instructing Gradle to run tests using the JUnit Platform instead of older JUnit runners. This configuration enables Gradle to discover, execute, and report JUnit 5 test cases during the build process.
2.2 JUnit Parallel Configuration
Create the following file: src/test/resources/junit-platform.properties and add the following properties to it:
junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.mode.classes.default=concurrent junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=4
The junit-platform.properties file configures JUnit 5 parallel test execution. Setting junit.jupiter.execution.parallel.enabled=true activates parallel execution support, while junit.jupiter.execution.parallel.mode.default=concurrent allows test methods within a class to run concurrently. The property junit.jupiter.execution.parallel.mode.classes.default=concurrent enables parallel execution across different test classes. By specifying junit.jupiter.execution.parallel.config.strategy=fixed, JUnit uses a fixed-size thread pool, and junit.jupiter.execution.parallel.config.fixed.parallelism=4 limits the pool to four worker threads. As a result, JUnit can execute up to four test methods or classes simultaneously, reducing overall test execution time on systems with sufficient processing resources.
2.3 First Test Class
package com.example;
import org.junit.jupiter.api.Test;
public class UserServiceTest {
@Test
void createUser() throws Exception {
System.out.println(
"Create User : "
+ Thread.currentThread().getName());
Thread.sleep(2000);
}
@Test
void updateUser() throws Exception {
System.out.println(
"Update User : "
+ Thread.currentThread().getName());
Thread.sleep(2000);
}
}
The UserServiceTest class is a JUnit 5 test class containing two test methods, createUser() and updateUser(), both annotated with @Test so that JUnit executes them as independent test cases. Each test prints a message along with the name of the thread executing the test using Thread.currentThread().getName(), which helps identify whether the tests are running on the same thread or in parallel. After printing the message, each test pauses for 2 seconds using Thread.sleep(2000) to simulate a time-consuming operation. When JUnit parallel execution is enabled, these tests can run simultaneously on different threads, reducing overall test execution time; otherwise, they execute sequentially on the same thread.
2.4 Second Test Class
package com.example;
import org.junit.jupiter.api.Test;
public class OrderServiceTest {
@Test
void createOrder() throws Exception {
System.out.println(
"Create Order : "
+ Thread.currentThread().getName());
Thread.sleep(2000);
}
@Test
void updateOrder() throws Exception {
System.out.println(
"Update Order : "
+ Thread.currentThread().getName());
Thread.sleep(2000);
}
}
The OrderServiceTest class is a JUnit 5 test class that contains two test methods, createOrder() and updateOrder(), both marked with the @Test annotation so they are automatically executed by the JUnit test framework. Each test prints a message along with the name of the thread executing the test using Thread.currentThread().getName(), making it easy to observe whether the tests are running sequentially or in parallel. After displaying the message, each method pauses execution for 2 seconds using Thread.sleep(2000) to simulate a long-running operation such as database access or service processing. When JUnit parallel test execution is enabled, these test methods may run concurrently on different threads, improving overall test execution performance; otherwise, they execute one after another on the same thread.
2.5 Code Output
Create User : ForkJoinPool-1-worker-1 Update User : ForkJoinPool-1-worker-2 Create Order : ForkJoinPool-1-worker-3 Update Order : ForkJoinPool-1-worker-4 BUILD SUCCESSFUL
The output shows that JUnit executed all four test methods in parallel using different worker threads from the ForkJoinPool. The createUser() test ran on ForkJoinPool-1-worker-1, updateUser() ran on ForkJoinPool-1-worker-2, createOrder() ran on ForkJoinPool-1-worker-3, and updateOrder() ran on ForkJoinPool-1-worker-4. Since each test was assigned a separate worker thread, they were able to execute concurrently rather than waiting for one another to finish. The use of multiple worker threads confirms that JUnit’s parallel execution feature was enabled and that the test framework distributed the workload across the ForkJoinPool thread pool. After all test methods completed successfully, Gradle reported BUILD SUCCESSFUL, indicating that every test passed without any failures or errors.
2.5.1 What Happened?
Instead of executing tests sequentially, JUnit distributed the four test methods across four worker threads.
// Without parallel execution Test 1 -> 2 sec Test 2 -> 2 sec Test 3 -> 2 sec Test 4 -> 2 sec Total = 8 seconds // With parallel execution: All tests execute simultaneously Total ≈ 2 seconds
This demonstrates the significant performance gain that can be achieved through parallel execution.
3. Conclusion
Parallel testing with Gradle and JUnit 5 is one of the simplest ways to reduce test execution time and accelerate CI/CD pipelines. Gradle handles build orchestration and dependency management, while JUnit 5 provides a robust parallel execution engine capable of running test methods and classes concurrently. Although the performance benefits are substantial, developers must remain aware of the “state horror” problem caused by shared mutable state, race conditions, and non-thread-safe resources. Proper test isolation, immutable design, and resource management are essential for successful parallel testing. By combining Gradle’s build capabilities with JUnit 5’s parallel execution features, teams can achieve faster feedback cycles, improved productivity, and scalable automated testing strategies for enterprise applications.

