When it comes to speeding up your test suite, the natural instinct is to "parallelize everything." But as I recently discovered while optimizing a suite of 500+ unit tests, not all parallelism is created equal.
The two main knobs we can turn in Gradle are maxParallelForks and JUnit 5's parallel execution properties:
test {
useJUnitPlatform()
maxParallelForks = 1
systemProperties = [
'junit.jupiter.execution.parallel.enabled': 'true',
'junit.jupiter.execution.parallel.mode.default': 'concurrent'
]
}
Here are the measurements I got while playing with these settings for 500+ unit tests:
| maxParallelForks | JUnit parallel mode | Test execution time, sec |
|---|---|---|
| 1 | off | 16 |
| 2 | off | 15 |
| Runtime.runtime.availableProcessors().intdiv(2) | off | 22 |
| 1 | on | 9 |
| 2 | on | 13 |
Wait, why did maxParallelForks = 2 only save 1 second, while using half of the available cores for forks jumped to 22 seconds? Why did JUnit's parallel mode shave off nearly 50% of the time? And why did combining them make things slower?
The Two Faces of Parallelism
To understand these results, we need to look at how these two mechanisms work under the hood.
Gradle's
maxParallelForks(process-level):
Gradle achieves parallelism by spawning separate worker JVM processes. Each fork is a brand-new JVM. Starting a JVM is expensive - it takes time to boot, allocates its own memory, and requires inter-process communication with the Gradle daemon.JUnit 5 Parallel Mode (thread-level):
JUnit 5 can run tests in parallel within a single JVM using a thread pool. Threads are lightweight, share the same memory, and have virtually zero startup overhead compared to a full JVM process.
Explaining the Results
- 1 Fork vs 2 Forks vs N Forks (16s vs 15s vs 22s): for 500+ lightweight unit tests, the overhead of starting and managing additional JVM processes is significant. When we increased the number of forks to half of the available processors, the startup tax and resource contention between all those JVMs became so high that it was actually slower than running everything in a single process!
- 1 Fork + JUnit Parallel (9s): this is the sweet spot. We pay for only one JVM startup, but we still utilize all available CPU cores via threads. The overhead is minimal, and the gain is maximum.
- 2 Forks + JUnit Parallel (13s): here, we pay for two JVM startups again. While each JVM runs its tests in parallel, the total overhead of two processes plus the contention for resources makes it slower than just using one highly-threaded JVM.
Conclusion
If your tests are heavy (e.g., integration tests starting Spring context, Docker containers, or heavy DB migrations), Gradle Forks might be better because they provide process-level isolation.
However, for a large number of fast unit tests, JUnit 5 parallel mode is the clear winner. It gives you the multi-core boost without the heavy process-level tax.
P.S. Note that we are not addressing test data isolation here. We assume tests are independent; if they aren't, they should be refactored before running in parallel. But that’s a topic for another day.
Dream your code, code your dream.
Top comments (0)