All articles

// Knowledge.log — 技術記事

JUnit 5 in parallel: the test that only fails with more than one thread

With Jupiter parallel on, two tests sharing a static Path fail. Reproduce the race and fix it with @ResourceLock, SAME_THREAD or @TempDir.

The suite passes on the laptop, in the IDE, and on every plain mvn test. Then someone turns on Jupiter's parallel execution in CI, and a class that has never caused trouble starts failing with a message that looks impossible: the test wrote APPLE and read back BANANA.

The cause is almost always shared state that nobody declared: a static field, a file at a fixed path, a temporary directory that gets reused. When tests run sequentially, each method finishes before the next one starts, so the sharing stays hidden. Add a second thread and it shows up.

This walkthrough reproduces the race with two methods writing to the same Path, and it separates the setting that actually turns parallelism on from the one that only looks like it does. Then come three fixes, all of them executed: lock the resource, force SAME_THREAD, or get rid of the static. Surefire's forkCount, which usually gets credit as the parallel switch, turns out to solve a different problem.

Versions used

The current stable release is JUnit 6.1.3 (Platform + Jupiter + Vintage). JUnit 5 became JUnit 6 with a major version bump, but the parallel APIs still live under org.junit.jupiter. The annotations are in org.junit.jupiter.api.parallel, and the properties keep the junit.jupiter.execution.parallel prefix. Everything here applies if you still call it JUnit 5.

Versions checked on 30 September 2026:

  • JUnit BOM / Jupiter 6.1.3, which requires Java 17 or later at runtime (User Guide 6.1.3).
  • Maven Surefire 3.6.0, with a minimum of JDK 8 and Maven 3.6.3 (plugin-info).
  • Temurin 25.0.4 and Maven 3.9.12 on the machine that ran the experiments.

The experiment's POM is small:

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.compiler.release>25</maven.compiler.release>
  <junit.version>6.1.3</junit.version>
</properties>
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>${junit.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.0</version>
    </plugin>
  </plugins>
</build>

junit-jupiter is the aggregator. It brings in the API, the engine, and the parameterized-tests module. @Execution and @ResourceLock are part of the API, so you don't need any extra artifact. The project is plain Jupiter with no Spring. If your flaky test involves @Transactional or RANDOM_PORT, you have a different problem, and it's covered in Transactional integration tests.

Two properties, not one

Jupiter's parallel execution is opt-in. By default, tests run "sequentially in a single thread", and junit.jupiter.execution.parallel.enabled starts out as false (parallel execution page).

A lot of people skip the next sentence on that page:

> "Please note that enabling this property is only the first step required to execute tests in parallel. If enabled, test classes and methods will still be executed sequentially by default."

enabled=true means Jupiter may use more than one thread. The default mode for each node in the test tree is still same_thread, though. To get methods running at the same time, you also need junit.jupiter.execution.parallel.mode.default=concurrent, or @Execution(ExecutionMode.CONCURRENT) on the class or method.

Setting only enabled=true is about the most reassuring configuration you can have. Nothing complains in the log, the suite stays green, and nothing ran in parallel.

This is the documented pair for concurrent methods:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent

There is also junit.jupiter.execution.parallel.mode.classes.default, which controls only top-level classes. If you don't set it, it takes the value of mode.default. With the block above, both classes and methods run concurrently.

These keys reach Jupiter through three channels (configuration parameters). One is a junit-platform.properties file at the root of the classpath, which in Maven means src/test/resources. Another is JVM system properties (-D...). The third is Surefire's configurationParameters, shown like this on Surefire's JUnit Platform page:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.6.0</version>
  <configuration>
    <properties>
      <configurationParameters>
        junit.jupiter.execution.parallel.enabled = true
        junit.jupiter.execution.parallel.mode.default = concurrent
      </configurationParameters>
    </properties>
  </configuration>
</plugin>

The experiments below pass both keys as -D flags on the command line, so the same project can run sequentially or concurrently.

The race: two methods, one Path

The shared state lives in a helper class: a static String and a file at a fixed path in the system temp directory.

final class SharedHolder {
  static volatile String value;
  static final Path FILE = Path.of(System.getProperty("java.io.tmpdir"), "devdojo-junit-parallel-shared.txt");

  private SharedHolder() {}
}

The test class has two methods. Each one writes its own value, reads it back, and checks it, 50,000 times. The loop is only there to widen the window in which the two can cross.

class SharedPathRaceTest {

  @Test
  void writesApple() throws Exception {
    for (int i = 0; i < 50_000; i++) {
      Files.writeString(SharedHolder.FILE, "APPLE");
      SharedHolder.value = "APPLE";
      assertEquals("APPLE", Files.readString(SharedHolder.FILE));
      assertEquals("APPLE", SharedHolder.value);
    }
  }

  @Test
  void writesBanana() throws Exception {
    for (int i = 0; i < 50_000; i++) {
      Files.writeString(SharedHolder.FILE, "BANANA");
      SharedHolder.value = "BANANA";
      assertEquals("BANANA", Files.readString(SharedHolder.FILE));
      assertEquals("BANANA", SharedHolder.value);
    }
  }
}

Taken alone, each method is correct. The trouble is that both use the same file and the same field.

Scenario A, sequential (the default):

mvn -B -Dtest=SharedPathRaceTest test

Result: 2 tests, 0 failures, exit 0. One method runs to completion before the other starts, so neither ever reads the other's value.

Scenario B, same class with both properties:

mvn -B -Dtest=SharedPathRaceTest \
  -Djunit.jupiter.execution.parallel.enabled=true \
  -Djunit.jupiter.execution.parallel.mode.default=concurrent test

Result: 2 tests, 1 failure, exit 1. The relevant part of the log:

[ERROR] academy.devdojo.junit.parallel.SharedPathRaceTest.writesApple -- Time elapsed: 0.057 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <APPLE> but was: <BANANA>
	at academy.devdojo.junit.parallel.SharedPathRaceTest.writesApple(SharedPathRaceTest.java:15)

Line 15 is assertEquals("APPLE", Files.readString(SharedHolder.FILE));. writesApple wrote APPLE, and before it could read the file back, writesBanana overwrote it from the other thread. In this run only writesApple got caught in the collision. Which method loses depends on thread scheduling, and that's why this kind of failure shows up in CI only "every now and then".

CI sees that exit 1. If your pipeline also checks Surefire's XML report, the Surefire receipt for coding agents article shows how to use it as proof that the tests actually ran.

One detail catches people during debugging: running only the method that failed reproduces nothing. With the same properties, -Dtest=SharedPathRaceTest#writesApple gave 1 test and exit 0, because only one node is left in the tree and there's nothing for it to race against. To investigate the race, run the whole class with -Dtest=SharedPathRaceTest.

The #method syntax worked here, but Surefire's single-test page documents it only for JUnit 4.x and TestNG. For Jupiter, the JUnit Platform page documents the whole-class form.

Three fixes, all with parallel on

Each fix is its own class with the same two methods, run with both properties from scenario B. The snippets below show only what differs from SharedPathRaceTest.

@ResourceLock with your own key

@ResourceLock says that a test uses a shared resource that needs synchronized access. The built-in keys in Resources (SYSTEM_PROPERTIES, SYSTEM_OUT, SYSTEM_ERR, LOCALE, TIME_ZONE) describe JVM-level resources. None of them covers a file you own, so the file needs its own key. Any String works, as long as both sides use the same one:

class ResourceLockedRaceTest {

  @Test
  @ResourceLock("academy.devdojo.shared-path")
  void writesApple() throws Exception {
    // same body as SharedPathRaceTest
  }

  @Test
  @ResourceLock("academy.devdojo.shared-path")
  void writesBanana() throws Exception {
    // same body as SharedPathRaceTest
  }
}

The default lock mode is READ_WRITE. According to the javadoc, the annotated element runs "while no other test class or test method that uses the shared resource is being executed", so two methods with the same key take turns. Result: 2 tests, 0 failures, exit 0. If the two methods used different keys, nothing would be serialized. The lock only works when everything that touches the resource uses the same key.

What this option has over the others is that the key works across classes. Any other test in the suite that touches the same file with the same key waits its turn too.

@Execution(SAME_THREAD) on the class

@Execution(ExecutionMode.SAME_THREAD)
class SameThreadRaceTest {
  // same body as SharedPathRaceTest
}

@Execution lives in org.junit.jupiter.api.parallel and can go on a class or a method. On a class, it applies to that class's methods. On a method, it overrides the class setting. It only has an effect when parallel execution is enabled. Here the two methods run on the class's thread, one after the other. Result: 2 tests, 0 failures, exit 0.

The limitation is scope. SAME_THREAD settles the contention between methods of this class, but it doesn't stop another class running concurrently from writing to the same file at the same time. In the experiment, each class ran alone via -Dtest. In a full suite, ResourceLockedRaceTest and SameThreadRaceTest both use the same SharedHolder.FILE, and only a shared lock key would coordinate them.

That isn't what @Isolated is for, either. That annotation isolates the entire class from every other test in the run, "without any other tests running at the same time". It's a bigger hammer that serializes the suite around that one class.

@TempDir per method, no static

The third way out is to stop sharing:

class NoStaticStateTest {

  @Test
  void writesApple(@TempDir Path dir) throws Exception {
    Path file = dir.resolve("out.txt");
    for (int i = 0; i < 5_000; i++) {
      Files.writeString(file, "APPLE");
      assertEquals("APPLE", Files.readString(file));
    }
  }

  @Test
  void writesBanana(@TempDir Path dir) throws Exception {
    Path file = dir.resolve("out.txt");
    for (int i = 0; i < 5_000; i++) {
      Files.writeString(file, "BANANA");
      assertEquals("BANANA", Files.readString(file));
    }
  }
}

Each method gets its own temp directory, and the static String is gone. With no common resource there's nothing to lock, and the two methods really do run in parallel. Result: 2 tests, 0 failures, exit 0. This class does 5,000 iterations instead of 50,000, so its timing isn't comparable to the others.

What each run showed

ScenarioClassJupiter parallelTestsFailuresExit
ASharedPathRaceTestoff (default)200
BSharedPathRaceTestenabled + concurrent211
C lockResourceLockedRaceTestenabled + concurrent200
C same_threadSameThreadRaceTestenabled + concurrent200
C no staticNoStaticStateTestenabled + concurrent200
DForkATest, ForkBTestoff, forkCount=2200
single methodSharedPathRaceTest#writesAppleenabled + concurrent100

The times Surefire printed (0.906 s for A, 0.665 s for B, and so on) are the wall-clock time of that particular run. They aren't a benchmark, and this table doesn't measure any speedup.

forkCount is a different lever

forkCount often gets treated as the "run in parallel" switch, as if one number made the whole suite concurrent. It controls something else: forkCount "defines the maximum number of JVM processes that maven-surefire-plugin will spawn concurrently", according to Surefire's fork and parallel execution page. The default is forkCount=1 with reuseForks=true, meaning one JVM for all the tests in the module.

Scenario D used two classes, ForkATest and ForkBTest, that share a static String. They ran with -DforkCount=2 and without Jupiter's parallel execution. Result: 2 tests, 0 failures, exit 0. Each class went to a different JVM, and a static field doesn't cross process boundaries, so there was nothing to fight over.

Surefire's parallel and threadCount parameters are also a separate mechanism. They serve the JUnit 4 provider (ParentRunner) and start threads inside the same JVM. In the parallel section of the JUnit Platform page, Surefire literally says "From JUnit Platform does not support running tests in parallel". The sentence is cut off on the site itself, but the point comes through: that lever doesn't apply to Jupiter, and anyone who sets parallel=methods on a Jupiter suite ends up waiting for concurrency that never arrives. Jupiter's parallelism goes through the properties above (configurationParameters, -D, or junit-platform.properties) and happens inside the JVM that Surefire started.

Pitfalls

  • CI and local machines with different settings. If parallel execution only exists as a -D flag in the CI script, the IDE and your local mvn test never see those keys. The laptop suite stays green because it never read the configuration, while CI, where the merge gets decided, goes red. A versioned junit-platform.properties in src/test/resources makes both sides read the same file.
  • @TempDir on a static field is a shared directory. The extensions guide says so plainly and recommends an instance field or parameter injection "so that each test method uses a separate directory".
  • forkCount doesn't protect a file on disk. It separates statics because it separates JVMs, but two forks writing to the same filesystem path are still fighting over the same file.

When parallel still pays off

The guide presents parallel execution as opt-in, and it mentions speed only as an example of why you might want it. In practice it pays off for classes that share nothing: no mutable static field, no fixed path on disk, no system property changed in the middle of a test. For those classes, CONCURRENT actually runs concurrently.

Every READ_WRITE @ResourceLock, every SAME_THREAD, and every @Isolated sends part of the suite back to the queue. If half of your classes need a lock, parallel execution has become sequential execution with extra configuration. At that point, go back and remove the shared state instead of piling on more annotations.

Recommendation and next step

DevDojo would enable Jupiter's parallel execution on a suite where most classes already use a per-method @TempDir and have no mutable static state. For the rest, here's the order of preference. First, remove the static wherever you can. When the resource really is shared (a file, a port, a fixed directory), use @ResourceLock with a named key. Save SAME_THREAD for conflicts that stay between methods of the same class. The signal to back off is when the number of locks grows faster than the number of concurrent classes.

The next step is short. Put both properties in a versioned junit-platform.properties that matches what CI uses. Then pick one of your classes that you suspect shares a file or a static, and run mvn -Dtest=SharedPathRaceTest test locally with that class's name in place of SharedPathRaceTest. If it fails like scenario B, either remove the static or lock the resource with an explicit key, then run it again with the same properties before you open the PR.

javatestingjunit

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles