Seus testes passam. Seu relatório de coverage diz 87%. Mas seu mutation score é 40%, e metade dos seus mutantes ainda está vivo.

Esse 40% não significa que seu código está quebrado. Significa que seus testes estão. O coverage mede quais linhas foram executadas durante uma execução de teste. O mutation testing mede se seus testes notariam se essas linhas começassem a fazer a coisa errada. Um mutation score de 40% significa que 60% dos bugs que poderiam ter sido introduzidos no seu código passariam direto pelo CI.

O Que É, Na Verdade, um Mutante Sobrevivente

Um mutante sobrevivente é um bug pequeno e artificial que seus testes falharam em capturar.

Ferramentas de mutation testing funcionam pegando seu código-fonte e aplicando um conjunto de transformações predefinidas, uma de cada vez. Elas podem inverter um > para >=, mudar um + para -, ou substituir uma condição booleana por true. Cada versão transformada do seu código é um mutante. A ferramenta executa sua test suite contra cada mutante. Se algum teste falhar, o mutante é “morto”. Se todos os testes passarem, o mutante “sobrevive”.

Um mutante sobrevivente significa uma de duas coisas. Ou seus testes não verificam de fato o comportamento que o mutante quebrou, ou o mutante é “equivalente” (a transformação produz código semanticamente idêntico, que é um problema difícil conhecido no mutation testing).

A maioria dos sobreviventes não são equivalentes. A maioria são bugs mortos-vivos.

Um Exemplo Concreto: O Validador de Senha

Aqui está uma função que verifica se uma senha atende aos requisitos da política:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

E aqui está uma test suite que lhe dá 100% de line coverage:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

Espere. isValidPassword('Hello1') retorna true, mas 'Hello1' tem apenas seis caracteres. A primeira verificação deveria rejeitá-la. O teste está errado, mas ele passa porque o próprio teste está afirmando o comportamento errado.

Uma ferramenta de mutation testing como Stryker capturaria isso. Uma de suas mutações inverteria < para <= na verificação de comprimento. Esse mutante sobreviveria porque os testes existentes não verificam de fato o limite em 8 caracteres. Outra mutação poderia deletar o bloco inteiro do primeiro if. Esse mutante também sobreviveria, porque os testes não incluem uma senha de oito caracteres sem letra maiúscula ou dígito. O limite superior de comprimento nunca é testado em combinação com as outras regras.

Aqui está uma test suite que realmente mata esses mutantes:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

Agora o limite em 8 é testado explicitamente. O mutante <= falha porque 'Hello1!@' (8 caracteres) deve ser aceito. O mutante de deleção falha porque 'helloooo' passaria sem a verificação de comprimento.

Como o Mutation Testing Funciona Por Baixo dos Panos

O mutation testing é computacionalmente caro porque executa sua test suite completa uma vez por mutante.

Se sua codebase tem 10.000 linhas e sua ferramenta de mutation gera 3.000 mutantes, são 3.000 execuções da test suite. Implementações acadêmicas iniciais eram essencialmente inutilizáveis em codebases reais por esse motivo. Ferramentas modernas ficaram mais inteligentes.

Stryker, o framework de mutation testing mais amplamente usado para JavaScript e TypeScript, usa várias otimizações:

  1. Escopo de mutante: Stryker executa apenas o subconjunto de testes que poderia alcançar a linha mutada, com base em dados de coverage de uma execução inicial de dry run.

  2. Execução paralela: Mutantes são avaliados em processos worker.

  3. Modo incremental: Stryker faz cache de resultados e só reavalia mutantes para código que mudou desde a última execução.

  4. Checkers: Para linguagens compiladas, Stryker pode verificar mutantes no nível de AST sem recompilar o projeto inteiro.

Mesmo com essas otimizações, uma execução completa de mutation testing em uma codebase grande ainda pode levar 10 a 30 minutos. É por isso que a maioria das equipes executa mutation testing no CI em pull requests ou builds noturnas, não a cada salvamento.

Os Trade-Offs de Que Ninguém Fala

O mutation testing não é de graça, e nem sempre é a ferramenta certa.

O problema do mutante equivalente é a maior limitação teórica. Algumas mutações não mudam o comportamento observável. Considere:

const timeout = 1000 * 60;

Uma mutação que muda isso para 1000 * 61 é semanticamente diferente. Mas uma mutação que muda para 60 * 1000 é equivalente. Nenhum teste pode matá-la porque o valor é idêntico. Distinguir mutantes equivalentes de sobreviventes genuínos é indecidível no caso geral. Ferramentas modernas usam heurísticas para pular casos óbvios, mas você ainda verá alguns.

Performance é real. Em um projeto TypeScript de médio porte, Stryker pode gerar 2.000 mutantes e levar 15 minutos para avaliá-los. São 15 minutos de tempo de CI a cada execução se você habilitar para pull requests. Equipes tipicamente começam com um threshold (digamos, falhar o build se o mutation score cair abaixo de 60%) e executam análise completa noturnamente.

Falsa confiança corta dos dois lados. Um mutation score de 100% não significa que seu código não tem bugs. Significa que nenhum bug que corresponda aos operadores de mutação da ferramenta teria passado despercebido. O mutation testing não pode inventar bugs que ele não sabe como criar. Ele não captura erros lógicos em seus requisitos, race conditions que não consegue simular, ou falhas de integração entre limites de serviço.

Como Realmente Começar a Usar Mutation Testing

Se você está escrevendo JavaScript ou TypeScript, Stryker é o ponto de partida.

Instale-o:

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

Crie stryker.config.mjs:

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

Execute:

npx stryker run

Comece olhando para o relatório HTML, não para o score. O relatório mostra cada mutante sobrevivente inline com seu código-fonte. Leia os dez primeiros sobreviventes. Para cada um, pergunte: um bug real nesse local causaria um problema em produção? Se sim, escreva um teste que o capture. Se não, considere se o código está superengenhado.

Não persiga 100%. Em uma codebase madura, 70-80% é um score forte. Abaixo de 50%, você provavelmente tem testes que executam código sem afirmar nada significativo. Acima de 90%, você provavelmente está atingindo retornos decrescentes e um crescente imposto de mutantes equivalentes.

O Que Fazer Com Seus 40%

Um mutation score de 40% é um presente. Ele diz exatamente onde seus testes são decorativos.

Escolha os três arquivos com mais mutantes sobreviventes. Leia cada sobrevivente e pergunte qual asserção está faltando. Frequentemente a correção é simples: você chamou uma função em um teste mas nunca verificou o valor de retorno. Ou você passou dados por um parser mas nunca verificou a saída parseada. Ou você testou o happy path três vezes com inputs diferentes mas nunca testou o branch de erro.

Os mutantes não são ruído. Eles são uma lista ranqueada dos lugares mais prováveis onde um bug não testado pode se esconder. Comece pelo topo.


FAQ

Qual a diferença entre code coverage e mutation testing? O code coverage mede quais linhas foram executadas. O mutation testing mede se seus testes falhariam se essas linhas contivessem um bug. 100% de coverage com 40% de mutation score significa que você executou cada linha, mas seus testes não notariam se a maioria delas estivesse errada.

O mutation testing pode encontrar bugs no meu código existente? Não. O mutation testing avalia seus testes, não seu código-fonte. Ele diz onde seus testes são insuficientes. Ele não diz se seu código está correto, apenas se seus testes capturariam certas classes de erros.

Quais linguagens têm boas ferramentas de mutation testing? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut) e Rust (cargo-mutants) têm ferramentas maduras. O ecossistema varia em performance e operadores de mutação suportados.

O mutation testing deveria substituir o code coverage? Não. O coverage é barato e rápido. Use-o para feedback rápido durante o desenvolvimento. Use o mutation testing como um quality gate periódico para encontrar os pontos cegos que o coverage não consegue ver.