Pular para o conteúdo

Teste de mutação explicado

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

A cobertura de linha informa que uma linha foi executada durante a suíte de testes. Ela não informa se algum teste falharia caso aquela linha estivesse errada. O teste de mutação fecha essa lacuna ao quebrar o código deliberadamente e verificar se os testes percebem. Esta página explica o que significa um escore de mutação e como o NextPDF o usa como diagnóstico, não como troféu.

A cobertura é uma das métricas de teste mais confiadas e também uma das mais enganosas. Um teste que chama um método e não verifica nada executa todas as linhas dele: cobertura perfeita, detecção zero. A literatura normativa é explícita: a ordenação entre critérios de cobertura não indica a capacidade deles de expor defeitos. Essa capacidade é a propriedade que ela chama de eficácia de teste (ISO/IEC/IEEE 29119-4, §C.2.4). Um percentual de cobertura e uma garantia de detecção de defeitos são afirmações diferentes.

Para um mecanismo de PDF, isso não é teórico. Uma verificação de byte-range de assinatura, um deslocamento de referência cruzada, um ramo de codificação — os testes podem “cobrir” totalmente todos eles sem nunca verificar o valor que importa. Uma suíte verde apoiada em testes fracos é pior do que uma lacuna honesta, porque desencoraja ativamente qualquer investigação.

  • Teste de mutação faz milhares de pequenas alterações deliberadas (mutantes) no código-fonte — troca um < por <=, um + por -, um valor de return — e reexecuta os testes contra cada uma delas.
  • Se um teste falha diante de um mutante, o mutante é morto: algum teste de fato verificou aquele comportamento. Se todos os testes continuam passando, o mutante escapou: o comportamento foi executado, mas nunca verificado.
  • O Mutation Score Indicator (MSI) é, grosso modo, a proporção de mutantes mortos sobre o total de mutantes não equivalentes. Ele mede se os testes detectam alterações, não se executam o código.
  • Alguns mutantes são equivalentes — não conseguem alterar o comportamento observável, então nenhum teste consegue matá-los. Contá-los como falhas é desonesto. O NextPDF os prova e registra em um livro-razão, em vez de descartá-los informalmente.
  • O NextPDF usa o MSI para encontrar e fortalecer testes fracos. É um portão de diagnóstico na integração contínua, não um número de marketing.

A mutação é executada no mecanismo com o mutador Infection. Ele é configurado sobre a árvore de código-fonte de produção, com as famílias de mutadores aritmética, booleana, de limite condicional, de igualdade, de valor de retorno e de remoção habilitadas — exatamente os operadores que expõem a lógica “executada, mas não verificada”. O fluxo é mecânico:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
O ciclo de teste de mutação que o NextPDF executa: parta de testes verdes, gere um mutante, reexecute os testes que o cobrem e classifique o mutante como morto (um teste o pegou), escapado (uma lacuna de cobertura-mas-sem-verificação a corrigir) ou comprovadamente equivalente (nenhum teste consegue matá-lo; registrado no livro-razão, não computado contra o escore).

Duas decisões de projeto tornam o número confiável. Primeiro, o escore é conectado como um portão. A integração contínua impõe um MSI mínimo (e um covered-MSI mínimo) e executa uma variante restrita ao diff sobre as linhas alteradas. Com isso, uma alteração que adiciona código, mas não verificações reais, é pega na revisão, não descoberta depois. Segundo, o NextPDF não descarta silenciosamente mutantes inconvenientes. Mutantes que são genuinamente semanticamente equivalentes — por exemplo, !== versus != quando a tipagem estrita garante que ambos os operandos compartilham um tipo — são registrados em um livro-razão de mutação com um teste explícito de prova de equivalência. Assim, a contagem de escapados reflete lacunas reais, não escrituração contábil. PHPStan Level 10 mais strict_types mais propriedades tipadas é o que torna sólidas essas provas de equivalência.

Evidence: Test-backed O teste de mutação é configurado no mecanismo, nos diretórios de código-fonte de produção, com as famílias de mutadores que revelam comportamento habilitadas. Ele é imposto como um portão de integração contínua com um MSI mínimo e uma variante restrita ao diff. É uma verificação de build, não uma consideração tardia.

Evidence: Test-backed O problema dos mutantes equivalentes é tratado de forma honesta. Mutantes semanticamente equivalentes são classificados e respaldados por testes dedicados de prova de equivalência em um livro-razão de mutação, com a solidez de cada prova apoiada em PHPStan Level 10 mais tipagem estrita. A contagem de escapados representa, portanto, comportamento real não detectado, não ruído impossível de matar inflado em um escore de aparência pior.

Evidence: Standard-backed A mutação é uma técnica reconhecida, não uma invenção do NextPDF. Spec: ISO/IEC/IEEE 29119-4, §B.2.4 descreve a aplicação de mutações genéricas a elementos de uma especificação para derivar mutações específicas para teste. A técnica é necessária justamente porque a mesma norma afirma que a ordenação por subsunção dos critérios de cobertura não os ordena por capacidade de expor defeitos (ISO/IEC/IEEE 29119-4, §C.2.4).

Evidence: Standard-backed A cobertura em si é bem definida e limitada. Spec: PHPUnit distingue cobertura de linha, de ramo e de caminho. A cobertura de linha registra apenas que uma linha executável foi executada. Conhecer a definição é o que torna evidente sua insuficiência.

O ponto não é o comando — é o que um mutante escapado diz a você:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

Esse mutante escapado é toda a proposta de valor. Ele localizou uma verificação real, específica e ausente que um relatório de cobertura havia classificado como totalmente testada.

O equívoco principal é achar que o escore de mutação é uma nota a maximizar. Um MSI muito alto obtido escrevendo testes para matar mutantes é tão vazio quanto uma cobertura alta obtida chamando métodos sem verificar nada. A métrica foi manipulada e não mede mais a detecção. O NextPDF usa o MSI para encontrar testes fracos. A entrega é uma verificação melhor; exibir o número não é o objetivo.

O segundo equívoco é achar que todo mutante sobrevivente é um defeito nos testes. Alguns mutantes são genuinamente equivalentes e não podem ser mortos por nenhum teste, porque não alteram o comportamento observável. Tratá-los como falhas produz um escore desonesto e artificialmente baixo e ensina as pessoas a ignorar o relatório. A resposta do NextPDF é provar a equivalência explicitamente e registrá-la no livro-razão, não suprimi-la silenciosamente nem fingir que o número é pior do que realmente é.

O teste de mutação mede se os testes detectam alterações injetadas. Ele não prova que o código está correto. Ele não mede desempenho nem conformidade. Ele não consegue matar um mutante verdadeiramente equivalente. O escore de mutação atual, o limite de MSI mínimo em vigor, o número de equivalentes registrados no livro-razão e qualquer número de cobertura são sinais de qualidade vivos, gerados a partir de artefatos de integração contínua e publicados com o build. Eles ficam deliberadamente ausentes aqui, porque um número colado na prosa envelhece e vira uma pequena mentira. O único fato estável que esta página afirma é PHPStan Level 10, e isso é uma propriedade de configuração que sustenta as provas de equivalência, não uma medição.

A seleção de mutadores, os limites e a política do livro-razão pertencem à configuração de mutação do mecanismo e podem evoluir. Essa configuração é a autoridade caso algum dia discorde desta página. Esta página não faz nenhuma afirmação sobre a eficácia de teste de qualquer outra biblioteca.

  • Mutante — uma única pequena alteração deliberada no código-fonte, usada para testar se a suíte de testes notaria essa alteração.
  • Mutante morto — um mutante que fez pelo menos um teste falhar; o comportamento foi genuinamente verificado.
  • Mutante escapado — um mutante que deixou todos os testes passando. O comportamento foi executado, mas nunca verificado — um ponto fraco a corrigir.
  • Mutante equivalente — um mutante que não consegue alterar o comportamento observável, então nenhum teste consegue matá-lo. O NextPDF os prova e registra no livro-razão.
  • MSI (Mutation Score Indicator) — grosso modo, os mutantes mortos divididos pelo total de mutantes não equivalentes; uma medida de detecção, não de execução.
  • Cobertura de linha — uma métrica que registra apenas que uma linha executável foi executada durante a suíte; definida pelo PHPUnit e insuficiente por si só.