Use cargo-mutants quando você quiser descobrir se seus testes realmente detectam bugs, e não apenas se executam o código. A ferramenta modifica temporariamente pequenos trechos do programa — por exemplo, troca > por >=, remove uma chamada ou substitui um valor retornado — e roda a suíte para verificar se ela falha. Se os testes continuam verdes, o mutante “sobreviveu” e existe uma lacuna a investigar.
Esse processo é chamado de teste de mutação (mutation testing). Ele complementa testes unitários, integração, cobertura e fuzzing com uma pergunta muito prática: se alguém introduzisse este defeito plausível, a suíte impediria o merge?
O fluxo mínimo é instalar a ferramenta, confirmar que cargo test já passa e executar:
cargo install cargo-mutants --locked
cargo test
cargo mutants
Como cada mutante pode exigir uma nova execução dos testes, comece por uma crate, módulo ou arquivo de alto risco. Este guia explica como interpretar o relatório, transformar sobreviventes úteis em testes, evitar métricas vaidosas e integrar cargo-mutants à CI sem tornar cada pull request interminável.
Resposta rápida: quando usar cargo-mutants
| Situação | Recomendação |
|---|---|
| Biblioteca pequena com suíte rápida | Rode o projeto inteiro na CI ou antes do release |
| Workspace grande | Comece pelas crates alteradas ou públicas |
| Regra de negócio crítica | Priorize o módulo, mesmo com cobertura alta |
| Parser ou entrada hostil | Combine mutação com proptest e fuzzing |
| Código gerado | Exclua ou limite a análise, com justificativa |
| Testes lentos ou flaky | Corrija a suíte antes de confiar no resultado |
| Primeiro contato | Rode em um arquivo pequeno e leia cada sobrevivente |
A ferramenta é mais valiosa em código com decisões observáveis: validações, cálculos, permissões, estados, parsers, estruturas de dados e APIs de biblioteca. Ela tende a produzir menos retorno em getters triviais, código gerado ou caminhos que apenas delegam chamadas sem contrato próprio.
O que é teste de mutação
Imagine esta função:
pub fn pode_sacar(saldo: i64, valor: i64) -> bool {
valor > 0 && saldo >= valor
}
Uma suíte superficial pode conter apenas:
#[test]
fn permite_saque_com_saldo_suficiente() {
assert!(pode_sacar(100, 40));
}
O teste executa a função e passa. Uma ferramenta de cobertura provavelmente marcará a linha como coberta. Mesmo assim, vários bugs poderiam entrar sem serem percebidos:
- trocar
saldo >= valorporsaldo > valor; - remover a validação
valor > 0; - retornar sempre
true; - inverter uma condição;
- aceitar um saque exatamente acima do saldo.
Um mecanismo de mutação cria alterações desse tipo, uma de cada vez, recompila quando necessário e executa os testes. O resultado de cada tentativa costuma cair em uma destas categorias conceituais:
- capturado (caught): algum teste falhou; a suíte detectou a mudança;
- sobrevivente (missed/survived): todos os testes passaram;
- timeout: a alteração fez o teste exceder o limite;
- não compilável (unviable): a mutação não gerou um programa Rust válido;
- falha de infraestrutura: build, ambiente ou teste falhou por motivo não relacionado ao comportamento mutado.
Os nomes exatos dos arquivos e estados podem evoluir entre versões. Depois da primeira execução, confira o diretório de saída e use cargo mutants --help para entender a versão instalada.
Por que cobertura de código não basta
Cobertura responde “este código foi executado?”. Mutação responde “os testes perceberam que o comportamento foi alterado?”. São perguntas diferentes.
Considere:
pub fn aplicar_desconto(preco: u64, percentual: u64) -> u64 {
preco - (preco * percentual / 100)
}
#[test]
fn calcula_desconto() {
let resultado = aplicar_desconto(200, 10);
assert!(resultado < 200);
}
A linha está coberta, mas a asserção é fraca. Resultados como 0, 179 ou 199 também satisfazem resultado < 200. Um teste melhor observa o contrato:
#[test]
fn aplica_dez_por_cento_de_desconto() {
assert_eq!(aplicar_desconto(200, 10), 180);
}
#[test]
fn percentual_zero_preserva_o_preco() {
assert_eq!(aplicar_desconto(200, 0), 200);
}
A cobertura com cargo-llvm-cov continua útil para localizar regiões nunca exercitadas. O cargo-mutants aprofunda a análise nas regiões executadas e mostra onde as asserções não distinguem o comportamento correto de uma alteração plausível.
O objetivo não é substituir uma métrica por outra. Use cobertura para encontrar ausência de execução e mutação para encontrar ausência de sensibilidade.
Instalando e fazendo a primeira execução
Antes de instalar qualquer ferramenta adicional, deixe a base verde:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
Depois:
cargo install cargo-mutants --locked
cargo mutants --version
cargo mutants
O --locked no cargo install pede que a instalação respeite o lockfile publicado pela própria ferramenta. Em CI, controle a versão instalada para evitar que uma atualização inesperada altere duração, operadores de mutação ou formato do relatório no meio de uma release.
A primeira execução no repositório inteiro pode demorar. Uma estratégia mais segura é listar a ajuda e escolher um escopo pequeno:
cargo mutants --help
As versões recentes oferecem filtros por pacote, arquivo e nome, além de opções de timeout e paralelismo. Como a interface pode evoluir, não copie flags antigas cegamente para o pipeline. Valide o comando na versão fixada e registre-o em um script do repositório.
Exemplo prático: um mutante que deve ser capturado
Vamos fortalecer o exemplo do saque:
#[derive(Debug, PartialEq)]
pub enum ErroSaque {
ValorInvalido,
SaldoInsuficiente,
}
pub fn sacar(saldo: i64, valor: i64) -> Result<i64, ErroSaque> {
if valor <= 0 {
return Err(ErroSaque::ValorInvalido);
}
if valor > saldo {
return Err(ErroSaque::SaldoInsuficiente);
}
Ok(saldo - valor)
}
Uma suíte orientada ao contrato cobre o caminho feliz e as fronteiras:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desconta_o_valor_do_saldo() {
assert_eq!(sacar(100, 40), Ok(60));
}
#[test]
fn permite_sacar_o_saldo_exato() {
assert_eq!(sacar(100, 100), Ok(0));
}
#[test]
fn rejeita_valor_acima_do_saldo() {
assert_eq!(sacar(100, 101), Err(ErroSaque::SaldoInsuficiente));
}
#[test]
fn rejeita_zero() {
assert_eq!(sacar(100, 0), Err(ErroSaque::ValorInvalido));
}
#[test]
fn rejeita_valor_negativo() {
assert_eq!(sacar(100, -1), Err(ErroSaque::ValorInvalido));
}
}
Agora mudanças como valor > saldo para valor >= saldo, valor <= 0 para valor < 0 ou saldo - valor para outro retorno observável devem fazer pelo menos um teste falhar.
Esse exemplo mostra uma vantagem importante da mutação: ela incentiva testes de fronteira. Casos exatamente iguais a zero, limite máximo, coleção vazia, primeiro item e último item costumam separar uma suíte forte de uma suíte que cobre apenas o caminho feliz.
Como interpretar um mutante sobrevivente
Um sobrevivente é uma pergunta, não um veredito. Faça a triagem nesta ordem.
1. A mudança altera um comportamento público?
Se sim, procure o teste que deveria observar esse contrato. Talvez a função seja executada, mas a asserção verifique apenas is_ok(), tamanho maior que zero ou ausência de panic.
2. Existe um caso de fronteira ausente?
Operadores relacionais são pistas clássicas. Uma troca entre < e <= pede testes exatamente no limite e nos dois lados dele.
3. O mutante é equivalente?
Algumas alterações produzem um programa diferente no texto, mas igual no comportamento possível. Por exemplo, uma condição pode ser redundante devido a um tipo, validação anterior ou invariant garantido pelo construtor. Nenhuma suíte consegue “matar” uma mudança realmente equivalente.
Antes de excluir, documente por que a equivalência é verdadeira. Às vezes a análise revela que o código redundante deveria ser removido em vez de ignorado.
4. O código deveria estar no escopo?
Código gerado, bindings externos, implementação derivada, exemplos didáticos ou adaptadores mecânicos podem gerar muito ruído. Exclusões podem ser legítimas, mas devem ser específicas. Excluir um diretório inteiro porque ele possui dois sobreviventes úteis destrói o sinal.
5. O teste certo seria caro ou frágil?
Um mutante pode exigir reproduzir falha de rede, relógio, concorrência ou banco. Isso não significa que deve ser ignorado automaticamente. Primeiro tente introduzir uma fronteira testável: trait para dependência externa, relógio injetável, banco temporário, servidor fake ou função pura extraída.
Melhorando testes sem acoplar à implementação
O maior risco ao reagir a mutantes é escrever testes que conhecem detalhes internos demais. O teste passa a “matar o mutante”, mas impede refactors saudáveis.
Prefira observar:
- retorno e erro de domínio;
- mudança de estado pública;
- mensagem enviada a uma interface;
- conteúdo persistido;
- resposta HTTP;
- evento produzido;
- invariant que deve permanecer verdadeiro.
Evite afirmar:
- número exato de funções privadas chamadas;
- ordem interna sem relevância pública;
- texto completo de erro técnico quando apenas a categoria importa;
- estrutura temporária usada durante o cálculo;
- detalhe de otimização sem contrato de performance.
Em outras palavras, mate o mutante por meio do contrato, não por vigilância sobre cada linha da implementação.
cargo-mutants, proptest e fuzzing
Testes de mutação ficam ainda melhores quando combinados com geração de entradas.
- Testes por exemplo documentam casos importantes e regressões conhecidas.
- proptest gera valores estruturados e verifica invariantes.
- cargo-fuzz explora entradas de bytes com feedback de cobertura.
- cargo-mutants altera o programa e mede se a suíte percebe.
Suponha uma função que codifica e decodifica um identificador. Uma propriedade de round-trip pode gerar centenas de valores:
proptest! {
#[test]
fn round_trip_preserva_id(id in any::<u64>()) {
let texto = codificar(id);
let recuperado = decodificar(&texto).unwrap();
prop_assert_eq!(recuperado, id);
}
}
Se cargo-mutants alterar parte de codificar ou decodificar, a variedade do proptest aumenta a chance de capturar a mudança sem criar dezenas de fixtures manuais. Para parsers, o guia de proptest e cargo-fuzz mostra como explorar invariantes e entradas hostis.
A relação também funciona no sentido inverso: sobreviventes podem sugerir uma propriedade geral melhor do que um novo caso isolado.
Performance: por que o job pode ficar caro
Se a ferramenta encontrar 300 mutantes e a suíte leva 20 segundos, uma execução estritamente sequencial poderia consumir muito tempo. Na prática, compilação incremental, seleção, paralelismo e encerramento antecipado ajudam, mas o custo continua muito maior do que um único cargo test.
Reduza o tempo com prioridades claras:
- mantenha testes unitários rápidos para lógica pura;
- separe testes lentos que dependem de rede, container ou serviço externo;
- use cargo-nextest no fluxo normal quando ele trouxer isolamento e paralelismo adequados;
- execute por pacote ou módulo em workspaces grandes;
- rode seleção em PR e análise ampla em agenda;
- use cache de compilação de maneira consciente;
- corrija flakiness antes de culpar a ferramenta;
- não aumente timeouts sem investigar.
O cargo-nextest melhora a operação da suíte, mas confirme na documentação da versão do cargo-mutants como selecionar o runner e repassar argumentos. O sccache também pode ajudar em determinados pipelines, embora mutações alterem fontes e reduzam parte do reaproveitamento esperado.
Estratégia de CI para projetos pequenos
Para uma biblioteca pequena e rápida, um job dedicado pode ser direto:
name: mutation-tests
on:
pull_request:
push:
branches: [main]
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Instalar cargo-mutants
run: cargo install cargo-mutants --locked
- name: Confirmar suíte base
run: cargo test --all-features
- name: Executar testes de mutação
run: cargo mutants
A lógica é compatível com GitHub Actions, Gitea Actions e outros sistemas, mas uma esteira de produção deve melhorar este exemplo:
- fixe uma versão conhecida do
cargo-mutants; - use cache sem esconder mudanças de toolchain;
- preserve o relatório como artefato quando necessário;
- defina timeout do job inteiro;
- deixe claro se qualquer sobrevivente bloqueia o merge;
- valide o comando em um fork antes de torná-lo obrigatório.
Consulte o guia de CI/CD em Rust para encaixar mutação junto de formato, Clippy, testes, cobertura e auditoria de dependências.
Estratégia de CI para workspaces grandes
Rodar tudo em toda PR pode ser desperdício. Uma política em camadas costuma funcionar melhor:
| Momento | Escopo | Objetivo |
|---|---|---|
| Pull request | Crates ou arquivos alterados | Feedback rápido sobre a mudança |
Merge em main | Pacotes críticos | Detectar lacunas antes de acumular |
| Job noturno | Workspace ampliado | Encontrar sobreviventes fora do diff |
| Pré-release | Bibliotecas públicas e regras críticas | Aumentar confiança no artefato |
Evite depender apenas do diff. Uma alteração em trait compartilhada, feature ou macro pode afetar crates que parecem não ter mudado. A seleção deve respeitar o grafo do workspace e o risco do componente.
Também não use uma pontuação global como único gate. Um pacote de cálculo financeiro interno e um binário utilitário podem merecer políticas diferentes. Mesmo fora de conteúdo financeiro, regras de autorização, integridade de dados e compatibilidade de API têm impacto muito maior do que formatação de uma mensagem auxiliar.
Mutation score: use com cuidado
Algumas equipes calculam uma pontuação aproximada:
mutantes capturados / mutantes viáveis
Ela ajuda a acompanhar tendência, mas pode ser manipulada ou interpretada mal. Um projeto pode aumentar a pontuação excluindo arquivos difíceis, escrevendo testes acoplados ou removendo código que a ferramenta consegue mutar, sem melhorar a experiência real do usuário.
Uma análise saudável pergunta:
- quais sobreviventes alteram contratos importantes?
- quais módulos concentram lacunas?
- a suíte melhorou nas fronteiras?
- exclusões possuem motivo revisável?
- o tempo de execução continua sustentável?
- regressões descobertas viraram testes claros?
Prefira uma lista pequena de sobreviventes entendidos a uma meta de “100%” obtida com exclusões opacas.
Armadilhas comuns
Rodar antes de estabilizar a suíte
Se cargo test falha aleatoriamente, cada mutante amplifica o ruído. Corrija testes flaky, dependências externas e vazamentos de estado primeiro.
Tratar timeout como mutante capturado
Uma mudança que gera loop ou lentidão pode representar um defeito real, mas também pode expor timeout mal calibrado. Investigue separadamente; não some tudo como sucesso sem entender.
Criar um teste por linha
O objetivo é proteger comportamento. Um bom teste de propriedade ou de fronteira pode capturar vários mutantes mantendo liberdade de refactor.
Excluir todo código difícil
Dificuldade de teste frequentemente indica acoplamento, efeitos colaterais escondidos ou responsabilidades misturadas. Use o relatório como sinal arquitetural antes de adicionar uma exclusão.
Rodar somente localmente
Uma ferramenta que depende da disciplina individual tende a desaparecer. Mesmo que a execução completa seja agendada, mantenha ao menos um comando reproduzível e uma política no repositório.
Confiar em sobreviventes sem ler o diff
O mutante pode ser equivalente, não compilável em outra feature ou irrelevante para o produto. Triagem humana continua necessária.
Checklist de adoção
-
cargo testpassa de forma determinística; - o projeto possui testes de fronteira para regras críticas;
-
cargo-mutantsestá instalado com versão controlada; - a primeira execução usa uma crate ou módulo pequeno;
- sobreviventes são classificados antes de criar exclusões;
- novos testes observam contratos públicos;
- código gerado e integrações externas têm política explícita;
- a CI confirma a suíte base antes da mutação;
- PRs recebem um escopo rápido e jobs agendados cobrem mais;
- relatórios relevantes ficam disponíveis para revisão;
- timeout e flakiness são monitorados;
- cobertura, mutação, proptest e fuzzing têm papéis diferentes;
- nenhuma meta numérica incentiva testes artificiais.
Testes de mutação para carreira e portfólio Rust
Em um projeto de portfólio, cargo-mutants demonstra uma maturidade rara: você não está apenas exibindo endpoints, mas mostrando como verifica a qualidade da verificação. Um README forte pode documentar:
- comando da suíte normal;
- cobertura com
cargo-llvm-cov; - escopo da análise de mutação;
- um sobrevivente real encontrado;
- o teste de fronteira criado para capturá-lo;
- tempo do job e decisões de CI.
Isso rende uma conversa de entrevista melhor do que citar uma porcentagem isolada. Você consegue explicar por que uma linha coberta não era suficiente, como evitou acoplamento à implementação e por que deixou um mutante equivalente documentado.
A habilidade é relevante para vagas Rust em backend, sistemas, fintechs, infraestrutura e segurança, além de ajudar na avaliação de empresas que usam Rust. Times que adotam Rust para confiabilidade precisam de pessoas capazes de testar regras e fronteiras que o sistema de tipos não consegue provar sozinho.
Para comparar culturas de teste, vale conhecer também o ecossistema do Golang Brasil. A comparação entre ferramentas importa menos do que a pergunta comum às duas linguagens: a suíte falha quando o comportamento importante muda?
Perguntas frequentes
O que é cargo-mutants?
É uma ferramenta que altera temporariamente o código Rust, executa os testes e registra se a suíte detectou cada mudança. Ela é usada para avaliar a força das asserções e encontrar comportamentos sem proteção adequada.
Como começar?
cargo install cargo-mutants --locked
cargo test
cargo mutants
Faça a primeira análise em escopo pequeno e consulte cargo mutants --help para os filtros suportados pela versão instalada.
Ele substitui cobertura?
Não. Cobertura mostra execução; mutação mostra sensibilidade a alterações. Use as duas para responder perguntas complementares.
Todo sobrevivente exige um novo teste?
Não. Primeiro verifique se a mudança afeta o contrato, se é equivalente, se o código pertence ao escopo e se existe uma forma estável de observar o comportamento. Alguns sobreviventes pedem teste; outros pedem refactor, remoção de código redundante ou exclusão documentada.
Precisa rodar em toda pull request?
Não necessariamente. Projetos pequenos podem executar tudo. Workspaces grandes podem analisar pacotes alterados na PR e usar jobs noturnos ou pré-release para cobertura mais ampla.
Conclusão
cargo-mutants ajuda a responder uma pergunta que cobertura e contagem de testes não resolvem: a suíte percebe um defeito plausível no comportamento? Ao introduzir alterações controladas e observar quais sobrevivem, a ferramenta revela asserções fracas, fronteiras esquecidas, código difícil de testar e contratos que existem apenas na cabeça da equipe.
Comece pequeno: escolha uma função importante, rode os mutantes, leia cada sobrevivente e escreva testes sobre o comportamento público. Depois transforme o aprendizado em um job proporcional ao tamanho do repositório — rápido na pull request, mais amplo em agenda e rigoroso antes de releases críticos.
Para construir uma estratégia completa, combine testes de mutação com testes em Rust, cargo-nextest, cobertura com cargo-llvm-cov, proptest e fuzzing e CI/CD. O resultado não é apenas uma barra verde: é uma suíte capaz de explicar quais mudanças perigosas ela realmente impede.