# Debug JUnit Parallel-Execution Flakes

> A test that passes alone and fails in the suite, with the failure moving between classes, is an ownership bug that parallel execution revealed rather than caused. This guide covers pinning the configuration so the flake reproduces, the documented reason your fork-join pool can run more tests at once than the parallelism you configured, a probe that shows which threads two failing tests were on, when the answer is @TempDir rather than a lock, what @ResourceLock's READ and READ_WRITE modes actually schedule, and the check that tells Jupiter's threads apart from Gradle's forked JVMs.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-28T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes

- Tags: junit, java, jvm, parallel-testing, flaky-tests, gradle

**Quick answer:** Pin the configuration before you debug anything: `strategy = fixed` with `fixed.parallelism = 2`, so the run is reproducible. Then separate the two layers: turn `junit.jupiter.execution.parallel.enabled` off and rerun. If the failure survives, it is Gradle's forked processes, not Jupiter's threads, and no `@ResourceLock` can help. Only after that does it pay to ask which resource two tests share, and the answer is usually a file path, a port, or a JVM-global that nothing declared.

Concurrency bugs in a test suite rarely arrive with a distinctive exception. The signature is the shape of the report:

- The test passes when you run it alone, in the IDE, every time.

- It fails in the full run, sometimes.

- Across ten runs the failure moves: `ExportReportTest` on Tuesday, `ArchiveCleanupTest` on Wednesday, and the two have nothing in common in the source.

The failure itself is whatever the collision happened to produce. `java.net.BindException: Address already in use`. `org.opentest4j.AssertionFailedError: expected: <3> but was: <5>`. A file that was there a line ago. None of those names the actual defect, which is that two tests both believed they owned something.

Parallel execution did not create that. It made an existing ownership assumption observable, which is why the fix is never "turn it off": sequential execution restores a specific interleaving, not correctness, and the same collision returns the moment someone reorders a class.

**The parallelism you configure is a target, not a limit.** Jupiter says so directly: it "does not guarantee that the number of threads used to execute test will not exceed the configured parallelism". Under the default fork-join executor, blocking JDK calls in your tests or production code can raise the number running at once, so a connection pool or a port allocator sized to that figure is sized to a promise nobody made. Every property and default named here was checked against the JUnit 6.1.3 user guide and the Gradle 9.7.1 manual on 2026-09-07.

## Pin the configuration before you pin the bug

A flake you cannot reproduce on demand is a flake you will fix by coincidence. The first move is to remove the variable that makes each run different, which is the thread count.

Jupiter's default is the `dynamic` strategy with a factor of 1.0, so "the desired parallelism will be equal to the number of available processors/cores". That is a different number on your laptop, your colleague's laptop and the CI runner. Replace it with a fixed, small number in `src/test/resources/junit-platform.properties`:

```
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 = 2
junit.jupiter.execution.parallel.config.fixed.max-pool-size = 2
```

Two is deliberate. Most collisions need only two writers, and with two threads the interleaving space is small enough that a handful of repeats reproduces it. The `max-pool-size` line is not redundant. The reason it is there is the next section.

## The thread count you configured is not a ceiling

This is the fact that makes several parallel-flake investigations go in circles, and the guide states it as a standalone warning:

> By default, JUnit Jupiter does not guarantee that the number of threads used to execute test will not exceed the configured parallelism. For example, when using one of the synchronization mechanisms described in the next section, the executor service implementation may spawn additional threads to ensure execution continues with sufficient parallelism.

The default executor is `fork_join_pool`, and the guide is equally direct about what that costs: "in some cases, usages of `ForkJoinPool` in test or production code or calls to blocking JDK APIs may cause the number of concurrently executing tests to increase."

So a suite configured for parallelism 4, whose tests call a blocking HTTP client or a `CompletableFuture` that parks, can have far more than four tests in flight. A connection pool sized to the configured parallelism runs dry. A fixture that allocates one port per expected thread collides. And every calculation you did on the whiteboard was correct for a number the runtime never promised.

Two mitigations, in order of how much you should trust them. Set the maximum pool size explicitly: `fixed.max-pool-size`, or `dynamic.max-pool-size-factor` for the dynamic strategy, whose default is otherwise "256 plus the value of `junit.jupiter.execution.parallel.config.dynamic.factor` multiplied by the number of available processors/cores". That number is worth reading twice: on an eight-core machine the default ceiling is 264 threads, not 8.

Or switch executors, which addresses the cause rather than the symptom:

```
junit.jupiter.execution.parallel.config.executor-service = worker_thread_pool
```

That one "does not create additional threads if test or production code uses `ForkJoinPool` or calls a blocking API in the JDK". It is also marked experimental, which is the trade: a stable thread count from a feature the JUnit team may still change.

## Check first whether anything ran concurrently at all

Before hunting a race, confirm there was one. Enabling parallelism is a two-step opt-in and the second step is easy to miss: setting `enabled = true` alone changes nothing, because "if enabled, test classes and methods will still be executed sequentially by default."

There are also two documented exemptions that catch people who did set both properties. The default mode is not applied to test classes using `Lifecycle.PER_CLASS`, nor to those with a `MethodOrderer`; in both cases "test methods in such test classes are only executed concurrently if the `@Execution(CONCURRENT)` annotation is present on the test class or method." A class you believed was in the parallel run may have been sequential all along. More dangerously, the reverse happens too: someone adds `@Execution(CONCURRENT)` to a `PER_CLASS` class to speed it up, and every instance field on it becomes shared mutable state at once.

The cheapest probe is to print the executing thread. Send it to standard error, because JUnit's own capture of standard output and error has to be enabled separately when running in parallel, and read it from a Gradle run at `--info`, where test standard error is logged:

```
@BeforeEach
void announceThread(TestInfo info) {
    System.err.printf("%-26s %s%n",
            Thread.currentThread().getName(),
            info.getTestMethod().map(Method::getName).orElse(info.getDisplayName()));
}
```

```
ForkJoinPool-1-worker-1    writesTheDailyExport
ForkJoinPool-1-worker-2    archivesYesterdaysExport
ForkJoinPool-1-worker-3    purgesExpiredExports      <- three, with parallelism = 2
```

One distinct name across the whole run means nothing is concurrent and the bug is elsewhere. More distinct names than your configured parallelism means the pool grew, and you are in the previous section. The right number of names, with two of them next to each other in the failure, means you have found the pair to look at.

## Two tests, one path

The most common shared resource in a JVM suite is a file, and the most common reason is a constant:

```
// Both of these compile, both pass alone, and together they race.
private static final Path EXPORT = Path.of("build/reports/export.csv");
```

The report directory is stable across the run, so the second test truncates what the first is reading. Sequentially it is invisible, because each test overwrites the previous one's leftovers before using them.

The fix is not a lock. It is to stop sharing the path, and Jupiter's `@TempDir` extension exists for exactly this and is registered by default. Inject it as a parameter and every test gets its own directory:

```
@Test
void writesTheDailyExport(@TempDir Path workDir) throws IOException {
    Path export = workDir.resolve("export.csv");

    new ExportJob(export).run(ordersFor(LocalDate.of(2026, 9, 7)));

    assertEquals(List.of("id,total", "ord_1,18.00"), Files.readAllLines(export));
}
```

Three details decide whether that actually isolates anything. A `static` `@TempDir` field is shared across every method in the class. The guide recommends "an instance field or constructor injection so that each test method uses a separate directory", so a static one reproduces the bug you just fixed. Multiple annotated parameters give multiple distinct directories, which is what a copy-from-source-to-target test wants. And the default cleanup mode is `ALWAYS`, with the default `Standard` deletion strategy retrying "with permission resets on failure" and, for paths it still cannot remove, scheduling deletion on JVM exit and failing the test. That last behaviour is a feature during a race investigation: a test that leaves a file open shows up as a deletion failure rather than as silent leakage into the next run.

When the resource is not a file but a port, the same principle applies with a different mechanism: bind to port 0 and read back the assigned port, rather than agreeing on 8080 in a constant. A fixed port in a test fixture is a shared global with extra steps.

## The globals a lock actually belongs on

Some state cannot be given per-test copies, because there is genuinely one of it per JVM: system properties, the default locale, the default time zone, `System.out` and `System.err`. For those, declare the sharing and let the engine schedule around it.

```
@Execution(CONCURRENT)
class ExportFormatting {

    @Test
    @ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ)
    void formatsTimestampsInTheAmbientZone() { }

    @Test
    @ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ_WRITE)
    void formatsTimestampsInUtcWhenForced() { }
}
```

The access modes are what make this cheaper than a mutex: "two tests that require `READ` access to a shared resource may run in parallel with each other but not while any other test that requires `READ_WRITE` access to the same shared resource is running." The predefined names are `SYSTEM_PROPERTIES`, `SYSTEM_OUT`, `SYSTEM_ERR`, `LOCALE` and `TIME_ZONE`, and any other string you invent identifies a resource of your own.

Two properties of the mechanism are worth knowing before you rely on it. The lock covers the whole lifecycle, not just the method body: "if a test method is annotated with a `@ResourceLock` annotation, the 'lock' will be acquired before any `@BeforeEach` methods are executed and released after all `@AfterEach` methods have been executed". So setup that mutates the resource is covered, which is where the mutation usually is.

And a class-level lock is coarser than it looks. One `READ_WRITE` method in a class whose other tests only read will serialise the entire class. The `target = CHILDREN` attribute is the escape hatch: declaring `@ResourceLock(value = "a", mode = READ, target = CHILDREN)` on the class "has the same semantics as adding an annotation with the same value and mode to each test method and nested test class declared in this class", so the readers stay concurrent and only the writer serialises.

When a class genuinely cannot tolerate anything else running, because it swaps a global registry or measures timing, `@Isolated` is the honest declaration. Tests in an isolated class "are executed sequentially without any other tests running at the same time", and being explicit about that costs you less than a suite-wide lock that nobody can later reason about.

## Jupiter's threads are not Gradle's processes

There are two independent parallelism settings in a Gradle build, and mixing them up sends investigations down the wrong path for hours.

Gradle "executes tests in a separate ('forked') JVM", and `maxParallelForks`, default 1, controls how many such JVMs run at once. Jupiter's configuration controls threads *within* one of them. A `@ResourceLock` is a lock inside a single JVM and has no visibility of a second process, so a collision between forks is completely untouched by every mechanism in the previous section.

That gives you the sharpest discriminating check available. Set `junit.jupiter.execution.parallel.enabled = false` and rerun with `maxParallelForks` unchanged. A failure that persists is cross-process, and the shared resource is something outside the JVM: a database, a port, a directory, a cloud bucket.

For those, Gradle hands you an identifier rather than a lock. Each test process carries a unique `org.gradle.test.worker` property, which the manual recommends "for filenames and other resource identifiers to prevent the kind of conflict we just mentioned":

```
// One schema per Gradle test worker, so two forks never share rows.
static String workerSchema() {
    return "test_w" + System.getProperty("org.gradle.test.worker", "0");
}
```

The related setting is `forkEvery`, default 0, which caps how many test classes run in one process before it is replaced. It is "mainly used as a way to manage leaky tests or frameworks that have static state that can't be cleared or reset between tests". It is a containment tool, not a fix, and the manual warns that a low non-zero value "can severely hurt the performance of the tests". Use it to confirm a diagnosis: if `forkEvery = 1` makes the flake disappear, the leak is static state surviving between classes, and you now know what to go looking for.

## The container that was never meant to be shared

If your integration tests use Testcontainers and you have just enabled parallel execution, stop and read this before debugging anything else. The Testcontainers JUnit 5 integration says so twice on its own page:

> This extension has only been tested with sequential test execution. Using it with parallel test execution is unsupported and may have unintended side effects.

That is not a warning about your containers being slow. The `@Testcontainers` extension drives container lifecycle from field declarations. A `static` `@Container` is started once and shared across the class's methods, an instance one is started and stopped for every method. Under concurrent execution the second form means several tests each starting and stopping their own database while the others are using theirs.

The practical resolution is to keep container-backed classes out of the concurrent set. Tag them, or run them in a separate suite, and if they must live alongside unit tests then mark them `@Execution(SAME_THREAD)` explicitly rather than relying on nobody flipping the default later.

## Apply this now

Take the flakiest class in your suite and run it under a pinned configuration: fixed strategy, parallelism 2, max pool size 2, and the thread-name probe above. Rerun until it fails, and capture the two test names that were on different threads when it did. That pair is the finding, not the stack trace, which only says what broke.

With the pair in hand, write down the one thing both touch. If it is a path or a port, delete the constant and inject `@TempDir` or bind to port 0. If it is a JVM global, add `@ResourceLock` with the narrowest mode that is true. If disabling Jupiter's parallelism does not help, it is cross-process and the identifier you need is `org.gradle.test.worker`.

Leave the fixed configuration checked in afterwards, at a parallelism your CI machine can actually sustain. A suite whose thread count changes with the hardware is a suite whose next race will reproduce on one developer's machine and nowhere else.

## Questions about diagnosing concurrent test failures

### Is a retry plugin a legitimate stopgap?

As a reporting measure, briefly, and only with the truth preserved. Gradle's JUnit XML writer supports `mergeReruns`, which records a passing-after-failing test as `<flakyFailure>` rather than `<failure>`, so a CI server that understands the format flags flakiness instead of hiding it. That is meaningfully different from a retry that reports green. What it does not do is bound the risk: the same collision in production has no retry, and the ownership bug the retry is papering over is the same bug.

### Why did this start after a refactor that touched neither test?

Because scheduling changed. Adding or removing a class changes which tests are adjacent in time, and a latent collision only manifests when its two participants overlap. This is also why "the last commit that touched these files" is a misleading place to start: the commit that exposed the race is rarely the commit that wrote it. Look for the shared resource, not the shared history.

### Can one class be concurrent while one of its methods is not?

Yes, and it is the right shape more often than people expect. `@Execution` applies to the annotated element "and its subelements", so a class marked `CONCURRENT` with one method marked `SAME_THREAD` is a supported and documented combination. Prefer it to demoting the whole class, because the annotation then records which single test was the problem.

### How do I tell a race from an ordering dependency?

Run the suspect classes sequentially but in a different order. An ordering dependency, where test B only passes because test A left a row behind, fails deterministically under the reversed order and passes under the original. A race passes under both orders and fails intermittently under neither. They need different fixes, so it is worth the extra run: one is a missing setup, the other is a missing boundary.

### Should parallel execution be on locally as well as in CI?

On in both, with the same fixed configuration in both. A suite that runs sequentially on developer machines and concurrently in CI guarantees that every new race is discovered by the slowest feedback loop you own, usually on someone else's pull request. The cost of running two threads locally is small; the cost of learning about the collision an hour later is not.

## Primary references

- [Configuring parallel execution — JUnit 6.1.3 user guide](https://docs.junit.org/current/writing-tests/parallel-execution.html). That parallel execution is opt-in and two-stage, since with only `enabled = true` "test classes and methods will still be executed sequentially by default"; the `SAME_THREAD` and `CONCURRENT` modes and that `@Execution` covers "the annotated element and its subelements"; the documented exemptions for `Lifecycle.PER_CLASS` classes and those using a `MethodOrderer`; the `fork_join_pool` default and its stated interaction with `ForkJoinPool` usage and blocking JDK calls, with `worker_thread_pool` as the experimental alternative; the `dynamic`/`fixed`/`custom` strategies, the dynamic factor of 1.0 and the max-pool-size defaults of "256 plus" the configured parallelism; the standalone warning that "parallelism alone does not imply maximum number of concurrent threads"; `@ResourceLock` with the `Resources` constants, `READ`/`READ_WRITE` semantics, the guarantee that the lock spans `@BeforeEach` to `@AfterEach`, and the `target = CHILDREN` attribute; and `@Isolated`. Also the note that capturing standard output and error must be enabled separately.

- [Built-in extensions including @TempDir — JUnit 6.1.3 user guide](https://docs.junit.org/current/writing-tests/built-in-extensions.html). That the `@TempDir` extension is registered by default, that multiple annotated parameters yield multiple distinct directories, that a `static` field shares one directory across the class while "for better isolation, you should use an instance field or constructor injection so that each test method uses a separate directory", the `NEVER`/`ON_SUCCESS`/`ALWAYS` cleanup modes with `ALWAYS` as the default, and the `Standard` deletion strategy which retries with permission resets, schedules stubborn paths for deletion on JVM exit and fails the test.

- [Gradle 9.7.1 — forked test processes, workers and JUnit XML](https://docs.gradle.org/current/userguide/java_testing.html). That Gradle runs tests in a forked JVM; `maxParallelForks` with its default of 1 and the warning that "tests that interact with the filesystem are particularly prone to conflict"; the unique `org.gradle.test.worker` property recommended "for filenames and other resource identifiers"; `forkEvery` with its default of 0, its purpose for "frameworks that have static state that can't be cleared or reset between tests" and the performance warning attached to low values; that test standard output and standard error are logged at the INFO level; and the `mergeReruns` JUnit XML option that reports a retried pass as `<flakyFailure>`. Gradle 9.7.1.

- [Testcontainers for Java — Jupiter / JUnit 5](https://java.testcontainers.org/test_framework_integration/junit_5/). That static `@Container` fields are shared across a class's test methods while instance fields are started and stopped per method, and the limitation quoted in the body of this article, that the extension has been tested only against sequential runs and that concurrent use is unsupported with possible unintended side effects. Testcontainers 2.0.5.

## Continue reading on AutomationTester.in

- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Test Python Retry Logic Without Sleeping](https://automationtester.in/blog/automation-tutorials/test-python-retry-backoff-without-sleeping)
- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)

Source: [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes) by Shashank Rawlani.
