Todos os artigos

// Knowledge.log — 技術記事

JUnit 5 em paralelo: o teste que só falha quando o Surefire usa mais de uma thread

Com o paralelo do Jupiter ligado, dois testes que dividem um Path estático falham. Reproduza a corrida e corrija com @ResourceLock, SAME_THREAD ou @TempDir.

A suíte passa no notebook, na IDE e no mvn test de sempre. Aí alguém liga a execução paralela do Jupiter no CI e uma classe que nunca deu problema começa a falhar com uma mensagem que parece impossível: o teste escreveu APPLE e leu BANANA.

Quase sempre a causa é um estado compartilhado que ninguém declarou. Pode ser um campo static, um arquivo num caminho fixo ou um diretório temporário reaproveitado. Na execução sequencial, cada método termina antes de o próximo começar, e o compartilhamento fica escondido. Com duas threads, ele aparece.

Este roteiro reproduz a corrida com dois métodos que escrevem no mesmo Path e mostra o que de fato liga o paralelo e o que só parece ligar. No fim vêm três correções, todas executadas: travar o recurso, forçar SAME_THREAD ou tirar o estático. O forkCount do Surefire, que costuma levar a fama de botão do paralelo, resolve outro problema.

Versões usadas

O estável atual é o JUnit 6.1.3 (Platform + Jupiter + Vintage). O JUnit 5 virou JUnit 6 numa troca de major, mas as APIs de paralelo continuam em org.junit.jupiter: as annotations ficam em org.junit.jupiter.api.parallel e as propriedades mantêm o prefixo junit.jupiter.execution.parallel. Então tudo aqui vale também para quem ainda chama de JUnit 5.

Conjunto consultado em 30/09/2026:

  • JUnit BOM / Jupiter 6.1.3, que exige Java 17 ou superior em runtime (User Guide 6.1.3).
  • Maven Surefire 3.6.0, com mínimo de JDK 8 e Maven 3.6.3 (plugin-info).
  • Temurin 25.0.4 e Maven 3.9.12 na máquina onde os experimentos rodaram.

O POM do experimento é enxuto:

<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 é o agregador: traz a API, o engine e o módulo de testes parametrizados. @Execution e @ResourceLock estão na API, então nenhum artefato extra é necessário. O projeto é Jupiter puro, sem Spring. Se o seu teste instável envolve @Transactional ou RANDOM_PORT, o problema é outro e está em Transactional no teste de integração.

Duas propriedades, não uma

O paralelo do Jupiter é opt-in. Por padrão, os testes rodam "sequentially in a single thread", e junit.jupiter.execution.parallel.enabled começa em false (página de execução paralela).

A frase seguinte da mesma página é a que mais gente pula:

> "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."

Com enabled=true, o Jupiter passa a poder usar mais de uma thread. Só que o modo padrão de cada nó da árvore de testes continua same_thread. Para os métodos rodarem juntos de verdade, também é preciso junit.jupiter.execution.parallel.mode.default=concurrent, ou @Execution(ExecutionMode.CONCURRENT) na classe ou no método.

Ligar só enabled=true é a configuração mais tranquilizadora que existe. O log não reclama, a suíte continua verde e nada rodou em paralelo.

O par documentado para métodos concorrentes é este:

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

Existe ainda junit.jupiter.execution.parallel.mode.classes.default, que controla só as classes de nível mais alto. Se você não definir, ele copia o valor de mode.default. Com o bloco acima, então, classes e métodos ficam concorrentes.

Essas chaves chegam ao Jupiter de três jeitos (parâmetros de configuração). O primeiro é um junit-platform.properties na raiz do classpath (no Maven, src/test/resources). O segundo são system properties da JVM (-D...). O terceiro é o configurationParameters do Surefire, que a página JUnit Platform do Surefire mostra assim:

<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>

Os experimentos abaixo passam as duas chaves por -D na linha de comando, para o mesmo projeto rodar ora sequencial, ora concorrente.

A corrida: dois métodos, um Path

O estado compartilhado mora numa classe auxiliar: um String estático e um arquivo com caminho fixo no diretório temporário do sistema.

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() {}
}

A classe de teste tem dois métodos. Cada um grava o próprio valor, lê de volta e confere, 50 mil vezes. O loop só serve para alargar a janela em que os dois podem se cruzar.

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);
    }
  }
}

Isoladamente, cada método está correto. O problema é que os dois usam o mesmo arquivo e o mesmo campo.

Cenário A, sequencial (o padrão):

mvn -B -Dtest=SharedPathRaceTest test

Resultado: 2 testes, 0 falhas, exit 0. Um método roda inteiro e só depois o outro começa, então nunca há leitura do valor alheio.

Cenário B, mesma classe com as duas propriedades:

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

Resultado: 2 testes, 1 falha, exit 1. O trecho do 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)

A linha 15 é assertEquals("APPLE", Files.readString(SharedHolder.FILE));. O writesApple gravou APPLE e, antes da leitura, o writesBanana sobrescreveu o arquivo na outra thread. Nesta execução, só o writesApple pegou o cruzamento. Qual dos dois perde depende do escalonamento das threads, e é por isso que esse tipo de falha aparece "de vez em quando" no CI.

O CI enxerga esse exit 1. Se o seu pipeline também confere o relatório XML do Surefire, o Recibo Surefire no coding agent mostra como usá-lo como prova de execução.

Um detalhe que engana na hora de depurar: rodar só o método que falhou não reproduz nada. Com as mesmas propriedades, -Dtest=SharedPathRaceTest#writesApple deu 1 teste e exit 0, porque sobra um único nó na árvore e ele não tem com quem disputar. Para investigar a corrida, rode a classe inteira com -Dtest=SharedPathRaceTest.

A sintaxe #method funcionou aqui, mas a página de teste único do Surefire só a documenta para JUnit 4.x e TestNG. Para o Jupiter, a página JUnit Platform documenta a forma com a classe inteira.

Três correções, todas com o paralelo ligado

Cada correção é uma classe separada com os mesmos dois métodos, rodada com as duas propriedades do cenário B. Os blocos abaixo mostram só o que muda em relação a SharedPathRaceTest.

@ResourceLock com uma chave sua

@ResourceLock declara que o teste usa um recurso compartilhado que precisa de acesso sincronizado. As chaves prontas em Resources (SYSTEM_PROPERTIES, SYSTEM_OUT, SYSTEM_ERR, LOCALE, TIME_ZONE) descrevem recursos da JVM. Nenhuma delas cobre um arquivo seu, então o arquivo precisa de uma chave própria. Pode ser qualquer String, desde que seja a mesma nos dois lados:

class ResourceLockedRaceTest {

  @Test
  @ResourceLock("academy.devdojo.shared-path")
  void writesApple() throws Exception {
    // mesmo corpo de SharedPathRaceTest
  }

  @Test
  @ResourceLock("academy.devdojo.shared-path")
  void writesBanana() throws Exception {
    // mesmo corpo de SharedPathRaceTest
  }
}

O modo padrão do lock é READ_WRITE. Pelo javadoc, o elemento anotado roda "while no other test class or test method that uses the shared resource is being executed". Dois métodos com a mesma chave, portanto, se revezam. Resultado: 2 testes, 0 falhas, exit 0. Com chaves diferentes nos dois métodos, nada seria serializado: o lock só vale se todo mundo que toca o recurso usar a mesma chave.

A vantagem sobre as outras opções é que a chave vale entre classes. Qualquer outro teste da suíte que toque o mesmo arquivo com a mesma chave também entra na fila.

@Execution(SAME_THREAD) na classe

@Execution(ExecutionMode.SAME_THREAD)
class SameThreadRaceTest {
  // mesmo corpo de SharedPathRaceTest
}

@Execution fica em org.junit.jupiter.api.parallel e aceita classe ou método. Na classe, vale para os métodos dela. Num método, sobrescreve o valor da classe. Só tem efeito com o paralelo habilitado. Aqui, os dois métodos rodam na thread da classe, um depois do outro. Resultado: 2 testes, 0 falhas, exit 0.

O limite é o escopo. SAME_THREAD resolve a disputa entre os métodos desta classe, mas não impede que outra classe concorrente escreva no mesmo arquivo ao mesmo tempo. No experimento, cada classe rodou sozinha via -Dtest. Numa suíte completa, ResourceLockedRaceTest e SameThreadRaceTest usam o mesmo SharedHolder.FILE, e só uma chave de lock compartilhada coordenaria as duas.

Esse também não é o papel de @Isolated. A annotation isola a classe inteira de qualquer outro teste da execução, "without any other tests running at the same time". É um martelo maior, que serializa a suíte em volta daquela classe.

@TempDir por método, sem estático

A terceira saída é parar de compartilhar:

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));
    }
  }
}

Cada método recebe o próprio diretório temporário, e o String estático sumiu. Sem recurso comum, não há o que travar, e os dois métodos rodam de fato em paralelo. Resultado: 2 testes, 0 falhas, exit 0. Esta classe faz 5 mil iterações em vez de 50 mil, então o tempo dela não é comparável com o das outras.

O que cada execução mostrou

CenárioClasseParalelo JupiterTestesFalhasExit
ASharedPathRaceTestdesligado (padrão)200
BSharedPathRaceTestenabled + concurrent211
C lockResourceLockedRaceTestenabled + concurrent200
C same_threadSameThreadRaceTestenabled + concurrent200
C sem estáticoNoStaticStateTestenabled + concurrent200
DForkATest, ForkBTestdesligado, forkCount=2200
um métodoSharedPathRaceTest#writesAppleenabled + concurrent100

Os tempos que o Surefire imprimiu (0,906 s no A, 0,665 s no B e assim por diante) são o relógio de parede daquela execução. Não são benchmark, e esta tabela não mede ganho de velocidade.

forkCount é outra alavanca

É comum ver forkCount tratado como o botão de "rodar em paralelo". Ele controla outra coisa: forkCount "defines the maximum number of JVM processes that maven-surefire-plugin will spawn concurrently", segundo a página de fork e execução paralela do Surefire. O padrão é forkCount=1 com reuseForks=true, ou seja, uma JVM para todos os testes do módulo.

O cenário D usou duas classes, ForkATest e ForkBTest, que dividem um String estático. Elas rodaram com -DforkCount=2 e sem o paralelo do Jupiter. Resultado: 2 testes, 0 falhas, exit 0. Cada classe foi para uma JVM diferente, e um campo estático não atravessa processos. Por isso não houve o que disputar.

Os parâmetros parallel e threadCount do Surefire também são outra coisa. Eles servem ao provider do JUnit 4 (o ParentRunner) e disparam threads dentro da mesma JVM. Na página JUnit Platform, a seção de paralelo do Surefire diz, literalmente, "From JUnit Platform does not support running tests in parallel". A frase está truncada no próprio site, mas o recado é claro: essa alavanca não serve para o Jupiter, e quem liga parallel=methods numa suíte Jupiter fica esperando uma concorrência que não chega. O paralelo do Jupiter passa pelas propriedades acima (configurationParameters, -D ou junit-platform.properties) e acontece dentro da JVM que o Surefire abriu.

Armadilhas

  • CI e máquina local com configurações diferentes. Se o paralelo só existe como -D no script do CI, a IDE e o mvn test local nunca viram essas chaves. A suíte fica verde onde você olha e vermelha onde o merge é decidido. Um junit-platform.properties versionado em src/test/resources faz os dois lados lerem o mesmo arquivo.
  • @TempDir em campo estático é um diretório compartilhado. O guia de extensões diz isso com todas as letras e recomenda campo de instância ou injeção por parâmetro "so that each test method uses a separate directory".
  • forkCount não protege arquivo em disco. Ele separa estáticos porque separa JVMs, mas dois forks que escrevem no mesmo caminho do sistema de arquivos continuam disputando o mesmo arquivo.

Quando o paralelo ainda vale a pena

O guia apresenta a execução paralela como opt-in, e o ganho de velocidade aparece só como exemplo de motivação. Na prática, ela compensa em classes que não compartilham nada: sem campo estático mutável, sem caminho fixo em disco, sem system property alterada no meio do teste. Nesses casos, CONCURRENT roda de verdade.

Cada @ResourceLock em READ_WRITE, cada SAME_THREAD e cada @Isolated devolve um pedaço da suíte para a fila. Se metade das classes precisa de lock, o paralelo virou uma execução sequencial com mais configuração, e é hora de voltar ao estado compartilhado em vez de acumular annotations.

Recomendação e próximo passo

A DevDojo ligaria o paralelo do Jupiter numa suíte em que a maioria das classes já usa @TempDir por método e não tem estático mutável. Para o resto, a ordem de preferência é esta. Primeiro, tirar o estático sempre que der. Quando o recurso é compartilhado de verdade (um arquivo, uma porta, um diretório fixo), usar @ResourceLock com uma chave nomeada. SAME_THREAD fica para quando o conflito é só entre métodos da mesma classe. O sinal para recuar é o número de locks crescer mais rápido que o número de classes concorrentes.

O próximo passo é curto. Coloque as duas propriedades num junit-platform.properties versionado, igual ao que o CI usa, e rode localmente mvn -Dtest=SharedPathRaceTest test numa classe sua que suspeite dividir arquivo ou estático. Se ela falhar como no cenário B, escolha entre remover o estático e travar o recurso com uma chave explícita, e rode de novo com as mesmas propriedades antes de abrir o PR.

javatestingjunit

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos