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-maven1.30.0; pitest-junit5-plugin1.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.