Todos os artigos

// Knowledge.log — 技術記事

Mutation testing em Java: faça o teste falhar pelo motivo certo

Configure o PIT no Maven, investigue mutantes sobreviventes e crie um gate de CI gradual com Java 25 e JUnit 6.

Uma suíte verde pode executar todas as linhas relevantes e ainda deixar uma mudança de comportamento passar. Cobertura responde se o código foi percorrido; mutation testing pergunta se os testes perceberiam uma alteração deliberada nesse código.

A diferença aparece rápido em condições de fronteira. Um teste chama a regra de frete pesado, mas verifica apenas se o valor retornado é maior ou igual ao frete básico. A linha foi coberta. A regra, nem tanto.

Vamos montar um módulo Java SE pequeno, executar o PIT duas vezes e ler os recibos reais. No primeiro ciclo, dois dos seis mutantes sobrevivem mesmo com 100% de cobertura de linha nas classes alteradas. Depois de reforçar as fronteiras, os seis são mortos. Isso mede a força desta suíte contra estes mutantes; não prova que a aplicação inteira está correta. Seria conveniente, mas o computador ainda não oferece esse pacote.

Versões usadas

O exemplo foi executado em Linux amd64 com:

  • Temurin JDK 25.0.4;
  • Maven 3.9.16;
  • PIT e pitest-maven 1.30.0;
  • pitest-junit5-plugin 1.2.3;
  • JUnit 6.1.3;
  • Maven Compiler Plugin 3.16.0;
  • Maven Surefire Plugin 3.6.0.

O JDK 25 é LTS segundo o roadmap oficial do Java. O Maven 3.9.16 era a versão estável recomendada na página oficial de download, enquanto o Maven 4 permanecia em preview. O PIT 1.30.0 inclui ajustes de mutadores para Java 25, conforme as notas da versão.

A documentação do plugin ainda usa o nome “JUnit 5”, mas a combinação com JUnit 6.1.3 foi executada com sucesso: o PIT adicionou o junit-platform-launcher 6.1.3, descobriu os dois testes Jupiter e concluiu a análise. Portanto, não há downgrade para JUnit 5 neste exemplo. O guia atual do JUnit exige Java 17 ou superior, requisito já atendido pelo JDK 25.

Crie o módulo Maven

Comece com a estrutura mínima:

mkdir -p pit-shipping/src/main/java/academy/devdojo/shipping
mkdir -p pit-shipping/src/test/java/academy/devdojo/shipping
cd pit-shipping
java -version
mvn --version

Use este pom.xml. As versões estão fixadas, o escopo da mutação fica no pacote do exemplo e os relatórios HTML e XML vão para um caminho estável, sem diretório timestampado.

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>academy.devdojo</groupId>
  <artifactId>pit-shipping</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>6.1.3</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-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
      <plugin>
        <groupId>org.pitest</groupId>
        <artifactId>pitest-maven</artifactId>
        <version>1.30.0</version>
        <dependencies>
          <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>1.2.3</version>
          </dependency>
        </dependencies>
        <configuration>
          <targetClasses>
            <param>academy.devdojo.shipping.*</param>
          </targetClasses>
          <targetTests>
            <param>academy.devdojo.shipping.*</param>
          </targetTests>
          <outputFormats>
            <param>HTML</param>
            <param>XML</param>
          </outputFormats>
          <timestampedReports>false</timestampedReports>
          <failWhenNoMutations>true</failWhenNoMutations>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <profiles>
    <profile>
      <id>pitest-ci</id>
      <build>
        <plugins>
          <plugin>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-maven</artifactId>
            <configuration>
              <mutationThreshold>80</mutationThreshold>
            </configuration>
          </plugin>
        </plugins>
      </build>
    </profile>
  </profiles>
</project>

O plugin de integração com JUnit fica como dependência do plugin PIT, não como dependência de teste da aplicação. A documentação do pitest-junit5-plugin também exige PIT 1.19.4 ou superior para a versão 1.2.3; estamos em 1.30.0.

Uma regra pequena e um teste verde demais

Crie src/main/java/academy/devdojo/shipping/ShippingFee.java:

package academy.devdojo.shipping;
public final class ShippingFee {
  public int cents(int weightGrams) {
    if (weightGrams < 0) throw new IllegalArgumentException("weightGrams");
    int base = 500;
    if (weightGrams >= 1000) base += 250;
    return base;
  }
}

Agora crie src/test/java/academy/devdojo/shipping/ShippingFeeTest.java com a primeira versão:

package academy.devdojo.shipping;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ShippingFeeTest {
  private final ShippingFee fee = new ShippingFee();

  @Test
  void lightPackage() {
    assertEquals(500, fee.cents(100));
  }

  @Test
  void heavyPackage() {
    assertTrue(fee.cents(1500) >= 500);
  }
}

Execute os testes e o goal oficial mutationCoverage:

mvn -B -ntp test org.pitest:pitest-maven:1.30.0:mutationCoverage

Os dois testes passam. O PIT também conclui com sucesso porque ainda não há um threshold configurado na execução normal. O resultado real foi:

Line Coverage (for mutated classes only): 5/5 (100%)
Generated 6 mutations Killed 4 (67%)
Mutations with no coverage 0. Test strength 67%

Cobertura de linha em 100%, mas dois mutantes sobreviveram. O relatório XML registrou ambos como ConditionalsBoundaryMutator:

ShippingFee.java:4  SURVIVED  changed conditional boundary
ShippingFee.java:6  SURVIVED  changed conditional boundary

Na linha 4, o mutador deslocou a fronteira da validação de peso negativo. Como o teste mais leve usa 100, ele não distingue < 0 de <= 0. Na linha 6, a mesma família mexeu na fronteira dos 1000 gramas. O teste usa 1500 e aceita qualquer retorno >= 500; ele passa sem confirmar nem o limite nem os 750 centavos esperados.

O teste passou pelo ramo certo e voltou sem reclamar. Um passeio turístico pelo if, basicamente.

Antes de alterar o teste, preserve o recibo fraco. A segunda execução reutiliza target/pit-reports; copie o diretório agora para não perder a comparação.

mkdir -p evidence
cp src/test/java/academy/devdojo/shipping/ShippingFeeTest.java \
  evidence/ShippingFeeTest-weak.java
cp -R target/pit-reports evidence/pit-weak

O HTML em target/pit-reports/index.html serve para investigação humana. O XML em target/pit-reports/mutations.xml é melhor como artefato de CI e para automação. A documentação Maven do PIT descreve esses formatos e as opções de escopo.

Reforce as fronteiras, não a quantidade de testes

Substitua o teste pela versão abaixo:

package academy.devdojo.shipping;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ShippingFeeTest {
  private final ShippingFee fee = new ShippingFee();

  @Test
  void lightPackage() {
    assertEquals(500, fee.cents(100));
  }

  @Test
  void boundaries() {
    assertEquals(500, fee.cents(0));
    assertEquals(500, fee.cents(999));
    assertEquals(750, fee.cents(1000));
    assertEquals(750, fee.cents(1001));
    assertThrows(IllegalArgumentException.class, () -> fee.cents(-1));
  }
}

O caso 0 é importante. Testar apenas -1 comprova a exceção no lado inválido, mas ainda deixa passar uma mutação que transforma < 0 em <= 0. Para a regra de peso, 999, 1000 e 1001 fixam os dois lados e o ponto exato da mudança.

Rode novamente e preserve o segundo relatório:

mvn -B -ntp test org.pitest:pitest-maven:1.30.0:mutationCoverage
cp src/test/java/academy/devdojo/shipping/ShippingFeeTest.java \
  evidence/ShippingFeeTest-strong.java
cp -R target/pit-reports evidence/pit-strong

A execução real manteve dois testes Jupiter e mudou o resultado para seis mutantes mortos de seis gerados. Os dois mutantes de fronteira passaram a ser mortos pelo método boundaries(). Não houve mutante sem cobertura, timeout ou erro de execução.

O ganho aqui não veio de perseguir mais linhas. Veio de afirmar o comportamento observável nos pontos em que o operador poderia mudar sem que a suíte percebesse.

Um gate de CI gradual e com escopo

Não coloque PIT em todo mvn test de um monólito e não comece exigindo 100% de mutation score. A FAQ do PIT lembra que mutantes equivalentes existem, e o conjunto ALL é desencorajado. O score também não é prova de correção: ele é a proporção de mutantes gerados que a suíte matou.

O profile pitest-ci do POM exige 80% apenas quando ativado. O pacote continua limitado por targetClasses e targetTests, e os mutadores permanecem no conjunto padrão. Execute assim:

mvn -B -ntp -Ppitest-ci \
  test org.pitest:pitest-maven:1.30.0:mutationCoverage

Esse gate foi verificado nas duas variantes do projeto. Com o teste fraco, o Maven terminou com código 1 e informou Mutation score of 67 is below threshold of 80. Com o teste forte, terminou com código 0 e BUILD SUCCESS.

O valor 80 não é recomendação universal. Neste módulo curto, ele separa de propósito a variante que deixa duas fronteiras sobreviverem. Em um projeto real, rode primeiro sem threshold, revise sobreviventes e mutantes equivalentes, restrinja o primeiro gate a um módulo ou pacote que o time consegue manter e adote um piso próximo do baseline aceito. Depois, aumente quando as correções forem revisáveis — não quando alguém descobrir que YAML também aceita números ambiciosos.

Publique como artefatos de CI, no mínimo:

target/pit-reports/index.html
target/pit-reports/mutations.xml

Guarde também o comando, as versões de Java e Maven e o commit analisado. O XML distingue KILLED, SURVIVED, NO_COVERAGE e TIMED_OUT; são diagnósticos diferentes. SURVIVED significa que o código mutado foi coberto e os testes continuaram verdes. NO_COVERAGE aponta código mutado sem teste cobrindo-o. Timeout merece investigação própria e não deve ser tratado automaticamente como loop infinito.

A lista oficial de mutadores do PIT ajuda a traduzir cada sobrevivente para uma pergunta sobre a regra. No exemplo, a pergunta foi simples: “o teste fixa os dois lados e o ponto exato da fronteira?”. Essa leitura é mais útil do que olhar apenas o percentual final.

Próximo passo

Escolha um pacote pequeno com regras determinísticas, execute o PIT sem threshold e preserve o primeiro HTML/XML. Corrija um sobrevivente que represente comportamento relevante, rode novamente e só então proponha um gate compatível com o baseline revisado.

Use o relatório para orientar uma asserção, esclarecer uma regra ou ajustar o escopo do gate. O percentual isolado não substitui essa revisão.

javatestingjunitmavenpitest

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

Conhecimento só conta quando vira prática.

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

Explorar mais artigos