Seus Testes Passam. Seu Código Ainda Está Errado.
Você tem 100% de cobertura de linhas. Cada branch é executada. Cada função é chamada. Aí alguém muda um + para - na sua lógica de precificação, executa os testes e todos passam.
Isso não é um problema teórico. É o que acontece quando seus testes executam o código, mas na verdade não verificam o comportamento. Cobertura mede quais linhas são executadas, não quais saídas são verificadas. Mutation testing fecha essa lacuna introduzindo pequenos bugs de propósito e verificando se seus testes os capturam.
A questão para times de Rust não é se mutation testing é uma boa ideia. É se cargo-mutants, a ferramenta dominante no ecossistema, é prática dado os tempos de compilação e o sistema de tipos do Rust. A resposta é sim, com ressalvas que importam.
O Que Mutation Testing Realmente Faz
Mutation testing é simples em conceito. A ferramenta faz uma mudança mínima no seu código-fonte, executa sua test suite e verifica se algo falha.
Se a test suite falha, o mutante é “morto” (killed). Isso é o que você quer. Significa que seus testes notaram o bug.
Se a test suite passa, o mutante “sobrevive” (survives). Isso significa que seus testes executaram o código mutado e não notaram que algo estava errado. Você tem um teste fraco.
Mutações comuns incluem substituir operadores aritméticos (+ vira -), trocar operadores de comparação (> vira >=), substituir literais booleanos (true vira false) e deletar function calling que retornam valores. Cada mudança é pequena o suficiente para que um humano a reconheça como um bug. A test suite também deveria reconhecê-la.
Como o cargo-mutants Funciona em Código Rust
cargo-mutants é uma ferramenta de mutation testing construída especificamente para Rust. Ela não exige que você anote seus testes ou mude seu sistema de build. Você instala e executa.
cargo install cargo-mutants
cargo mutants
A ferramenta varre seus arquivos-fonte, gera mutantes aplicando regras de transformação à AST e executa cargo test para cada um. Ela rastreia quais mutantes sobrevivem e imprime um relatório.
Aqui está uma função com um teste que parece sólido, mas não é:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// Executamos a função. Cobertura é 100%.
// Mas nunca fizemos assert no resultado.
}
}
cargo mutants vai gerar um mutante que muda o * para / ou substitui 1.0 - rate por 1.0 + rate. O teste ainda passará porque ele nunca verifica result. O mutante sobrevivente sinaliza o problema.
Um teste real que mata o mutante parece com isso:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
Agora todo mutante aritmético falha porque as asserções capturam a saída errada.
Como a Saída Se Parece
Execute cargo mutants e você obtém um resumo:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Mutantes perdidos (missed) são os que sobreviveram. cargo mutants escreve cada um em mutants.out/ com o diff e o caminho do arquivo. Você lê o diff e adiciona a asserção que faltava.
Timeouts acontecem quando um mutante causa um loop infinito. cargo-mutants detecta isso e marca como morto por timeout, o que conta como sucesso.
Mutantes inviáveis (unviable) são mudanças que não compilam. O sistema de tipos do Rust as rejeita antes mesmo dos testes rodarem.
O Sistema de Tipos do Rust É uma Faca de Dois Gumes
Em JavaScript ou Python, ferramentas de mutation testing podem substituir quase qualquer operador e o código ainda executa. Ele apenas produz resultados errados. Em Rust, muitas mutações são capturadas pelo compiler antes mesmo dos testes rodarem.
Substitua + por - em inteiros sem sinal e você pode obter um overflow, mas o código compila. Substitua > por < em um contexto genérico e o compiler pode rejeitá-lo se os trait bounds não suportarem a comparação. Delete uma function calling que retorna um valor que o chamador espera, e o compiler dá erro.
Isso significa que cargo-mutants gera menos mutantes viáveis do que ferramentas equivalentes em outras linguagens. Um projeto Python pode ver 200 mutantes para um module. Um projeto Rust pode ver 40. Os mutantes que de fato compilam são aqueles que poderiam escapar para produção. O sistema de tipos filtra o ruído.
A contrapartida é o tempo de compilação. Cada mutante viável dispara um rebuild. Um projeto com test suite de cinco minutos pode gastar uma hora executando cargo mutants.
O Custo de Tempo de Compilação É Real
Essa é a principal razão pela qual times hesitam. Mutation testing é embaraçosamente paralelo em teoria. Cada mutante é independente. Na prática, o sistema de build do Rust não paraleliza de forma limpa entre dezenas de invocações do compiler sobre a mesma árvore de código-fonte.
cargo-mutants tem uma flag --jobs, mas I/O de disco e lock do grafo de crates viram gargalos. Em um runner de CI típico com dois cores, o job escala mal.
Você pode mitigar isso. Use --in-place para evitar copiar a árvore de código-fonte para cada mutante. Use --file ou --exclude para mirar em modules específicos. Execute mutation testing noturnamente ou semanalmente, não a cada push.
O Que o cargo-mutants Deixa Passar
Nenhuma ferramenta de mutation testing captura tudo. cargo-mutants tem limitações específicas que você deve conhecer.
Ela não muta expansões de macro. Se sua lógica crítica vive dentro de uma macro, a ferramenta vê a invocação, não o código gerado.
Ela não entende equivalência semântica. Alguns mutantes produzem comportamento diferente, mas ainda correto para todas as entradas válidas. Um + 0 redundante pode sobreviver porque os testes não se importam, mesmo que a mutação não seja um bug real. Você precisa triar esses manualmente.
Quando Mutation Testing Vale o Custo
Você não precisa executar cargo mutants a cada commit. Você precisa dele quando sua test suite é grande o suficiente para que você não confie mais nas próprias asserções.
Execute quando um module crítico tem alta cobertura, mas você mesmo assim enviou bugs nele, ou quando um refactor mudou a lógica de formas sutis e você quer confiança de que as asserções são apertadas.
Não execute quando sua test suite já é instável ou seus tempos de compilação são o gargalo de que todos reclamam. Conserte os fundamentos primeiro.
Adicionando ao CI Sem Quebrar o Pipeline
A configuração prática é um job agendado, não uma gate em cada pull request.
Aqui está um workflow do GitHub Actions que executa semanalmente:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
A flag --in-place mantém o uso de disco razoável. rust-cache corta o tempo de build inicial. O gatilho agendado evita bloquear desenvolvedores. Faça upload do relatório como artifact para que você possa revisar mutantes sobreviventes sem rolar pelos logs de CI.
Comece com Um module
Você não precisa mutar toda sua codebase. Escolha um module com lógica crítica para o negócio e um histórico de bugs. Execute cargo mutants --file src/pricing.rs. Leia o relatório. Conserte o teste mais fraco.
A primeira execução é sempre a pior. Você vai encontrar testes que executam o código, mas não asserem nada. Vai encontrar branches cobertas por testes que não verificam o resultado do branch. Vai se perguntar como esses testes alguma vez pareceram adequados.
Esse é o ponto. Mutation testing não encontra bugs no seu código. Ele encontra bugs nos seus testes. Em Rust, onde o compiler já captura os erros óbvios, esse é exatamente o feedback loop que você precisa.
Perguntas Frequentes
O que é mutation testing?
Mutation testing avalia sua test suite introduzindo pequenos bugs deliberados no seu código-fonte. Se seus testes falham, o mutante é “morto” (killed). Se seus testes passam, o mutante “sobrevive” (survives) e você tem uma lacuna.
Como mutation testing difere de code coverage?
Cobertura mede quais linhas foram executadas. Mutation testing mede se seus testes detectariam saída errada dessas linhas. Um teste pode ter 100% de cobertura e capturar zero mutantes.
Mutation testing é lento para todos os projetos Rust?
O custo escala com tempo de compilação e quantidade de testes. Bibliotecas pequenas podem terminar em minutos. Projetos grandes de workspace levam significativamente mais tempo. Use --file e --exclude para escopar execuções a modules específicos.
Posso ignorar mutantes falso-positivos?
Sim. cargo-mutants suporta um arquivo de configuração mutants.toml para excluir arquivos, funções ou tipos específicos de mutação. Use isso com parcimônia para não mascarar lacunas reais de teste.