Testes de Propriedade e Fuzzing em Rust: proptest e cargo-fuzz em 2026

Como aplicar testes baseados em propriedades e fuzzing em Rust com proptest e cargo-fuzz em 2026: strategies, shrinking, seeds reproduzíveis, integração com CI e casos reais em parsers, serialização e lógica de domínio.

Por que testes de propriedade mudam a forma de testar em Rust

Testes unitários clássicos respondem a uma pergunta importante: “dada esta entrada, a saída é esta?”. São diretos, legíveis e essenciais. Mas eles têm uma limitação estrutural — só cobrem as entradas que você imaginou. Bugs reais costumam morar nas entradas que você não imaginou: uma string Unicode com combining marks, um vetor com elementos repetidos, um timestamp no limite de fuso, um JSON com chave duplicada, um número negativo onde o domínio esperava positivo.

Testes baseados em propriedades (property-based testing) invertem a pergunta. Em vez de dizer “para esta entrada, espere esta saída”, você diz “para qualquer entrada válida, esta propriedade deve valer”. O framework gera então centenas ou milhares de casos automaticamente, usando estratégias que descrevem o universo de entradas possíveis. Se alguma entrada quebrar a propriedade, o framework faz o shrinking — reduz o contraexemplo ao menor caso que ainda reproduz a falha, transformando um vetor caótico de 40 elementos em um de 2.

Em Rust, essa abordagem é particularmente poderosa porque o sistema de tipos já elimina muitas categorias de bug. As que sobram — bugs lógicos, de borda, de parsing, de serialização — são exatamente onde property-based testing brilha. Para quem busca vagas Rust e quer evoluir na carreira Rust, dominar essa técnica é um diferencial real: ela aparece em entrevistas de backend, infraestrutura e fintechs, e é marca de engenheiras e engenheiros que sabem ir além do caminho feliz.

proptest: a base de property-based testing em Rust

A crate proptest é o padrão de fato para property-based testing em Rust, inspirada no QuickCheck de Haskell. A diferença central em relação ao QuickCheck original é que o proptest usa strategies — geradores composicionais e determinísticos — em vez de depender de implementações Arbitrary acopladas a tipos. Isso dá controle fino sobre o espaço de entradas e torna a geração reproduzível.

A macro proptest! transforma um bloco declarativo em um teste que roda CASES iterações (por padrão 256). Veja o exemplo canônico: testar que ordenar duas vezes é o mesmo que ordenar uma vez (idempotência do sort).

[dev-dependencies]
proptest = "1.5"
use proptest::prelude::*;

fn ordenar(v: &mut [i32]) {
    v.sort_unstable();
}

proptest! {
    #[test]
    fn ordenar_duas_vezes_equivale_a_uma(ref mut v in prop::collection::vec(-1000i32..1000, 0..100)) {
        let mut esperado = v.clone();
        ordenar(&mut esperado);

        ordenar(v);
        ordenar(v);

        prop_assert_eq!(v, &esperado);
    }
}

Se rodar cargo test, o proptest gera 256 vetores aleatórios e verifica a propriedade para cada um. Se algum quebrar, ele encolhe automaticamente até o menor vetor que falha e escreve a semente no arquivo proptest.regress, ao lado do teste. Na próxima execução, aquele caso específico é retestado junto aos gerados, garantindo que a regressão nunca suma silenciosamente.

Strategies: descrevendo o universo de entradas

O coração do proptest são as strategies, geradores de valores tipados que descrevem quais entradas são válidas para o seu domínio. A função any::<T>() gera qualquer valor do tipo T. Operadores como prop_map, prop_flat_map, prop_filter e o módulo prop::collection permitem composição rica.

Um padrão comum é gerar pares de valores onde o segundo depende do primeiro. Por exemplo, para testar uma função de busca binária, você precisa de um vetor ordenado e um elemento que pode ou não estar presente:

use proptest::prelude::*;

fn busca_binaria(v: &[i32], alvo: i32) -> Option<usize> {
    v.binary_search(&alvo).ok()
}

proptest! {
    #[test]
    fn busca_binaria_encontra_ou_indica_ausencia(
        mut v in prop::collection::vec(any::<i32>(), 0..200).prop_map(|mut xs| {
            xs.sort_unstable();
            xs.dedup();
            xs
        }),
        indice in 0usize..200,
    ) {
        let alvo = if v.is_empty() {
            0
        } else {
            v[indice % v.len()]
        };

        match busca_binaria(&v, alvo) {
            Some(pos) => prop_assert_eq!(v[pos], alvo),
            None => prop_assert!(v.iter().all(|&x| x != alvo)),
        }
    }
}

Esse teste cobre, de forma automática, casos que exemplos manuais raramente alcançam: vetor vazio, vetor com um elemento, elemento no início, no fim, duplicatas adjacentes, elemento ausente. Cada execução com semente diferente explora um canto diferente do espaço de entrada.

Shrinking: por que o contraexemplo fica mínimo

Quando uma propriedade falha, o que importa não é apenas “falhou”, mas “falhou por quê”. Um contraexemplo de 12 campos aninhados é quase impossível de diagnosticar. O shrinking resolve isso: após detectar a falha, o framework reduz cada componente da entrada — inteiros em direção a zero, strings em direção a vazio, coleções em direção a um elemento — e mantém só o que ainda reproduz o bug.

O resultado é que a falha aparece como “entrada vec![-1, 0] quebra o invariant”, em vez de “entrada vec![8372, -19, 44, ...] quebra”. Isso reduz o tempo entre “teste vermelho” e “fix deployable” de horas para minutos.

O shrinking do proptest é determinístico e composicional: cada strategy sabe como encolher seus valores, e strategies compostas reduzem cada parte de forma coordenada. Por isso vale a pena escrever strategies específicas para o seu domínio, em vez de usar any::<T>() para tudo — o shrinking fica muito mais útil.

Quando proptest não chega: entra o cargo-fuzz

proptest é excelente para lógica de alto nível. Mas existem domínios onde a entrada é fundamentalmente binária e não estruturada: parsers de formato de arquivo, decodificadores de protocolo, desserializadores que consomem bytes vindos da rede, validadores de token. Para esses, o relevante não é gerar entradas “válidas e composicionais”, mas jogar bytes arbitrários na fronteira e ver se algo quebra, trava, entra em loop ou viola um invariant de segurança.

É onde entra o cargo-fuzz, que empacota o libFuzzer do LLVM com instrumentação de cobertura. Diferente do proptest, que gera entradas independentes, o cargo-fuzz usa feedback de cobertura para evoluir entradas em direção a caminhos de código ainda não explorados. É fuzzing orientado por cobertura.

Instale e inicialize:

cargo install cargo-fuzz
cargo fuzz add decodifica_payload

Isso cria um target em fuzz/fuzz_targets/decodifica_payload.rs:

#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    // nunca panicar; deixar o bug aparecer como bug ou assertion
    if let Ok(decodificado) = meu_parser::decodifica(data) {
        // invariant: decodificado.len() <= data.len()
        assert!(decodificado.len() <= data.len());
    }
});

Rode com:

cargo fuzz run decodifica_payload -- -max_total_time=120

O libFuzzer vai gerar entradas, medir cobertura, mutar e evoluir. Entradas que disparam panics, overflows, asserts ou loops infinitos são salvas em fuzz/artifacts/. Para um servidor que recebe JSON de clientes não confiáveis, meia hora de fuzzing costuma revelar bordas que testes manuais de meses ignoraram.

Combinando proptest e cargo-fuzz

Os dois não competem, formam um pipeline. Um padrão maduro em bases de código Rust de qualidade:

  1. proptest cobre invariantes da lógica de domínio: funções puras, transformações, parsers de texto, serialização round-trip com Serde. Rápido, integrado ao cargo test, roda em todo CI.
  2. cargo-fuzz cobre a fronteira de bytes não confiáveis: formatos binários, protocolos de rede, decodificadores de imagem, parsers de configuração. Mais lento, roda em job separado ou máquina dedicada, às vezes por horas.

Um exemplo concreto de round-trip com Serde e proptest:

proptest! {
    #[test]
    fn serde_roundtrip_preserva_registro(
        nome in "[a-zá-ú ]{1,40}",
        idade in 0u8..120,
        saldo in -1_000_000i64..1_000_000,
    ) {
        let reg = Registro { nome, idade, saldo };
        let json = serde_json::to_string(&reg).unwrap();
        let volta: Registro = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(reg, volta);
    }
}

Esse teste, com 256 iterações, já teria pegado bugs clássicos de serialização de inteiros negativos, de perda de precisão decimal e de problemas com acentos em JSON mal configurado. Com custo quase zero.

Integração com CI: determinismo sem perder exploração

Um receio comum é “proptest falha aleatoriamente no CI”. O problema é real quando mal configurado, mas tem solução simples. Três práticas resolvem:

Primeiro, fixe a semente no CI. O proptest aceita PROPTEST_CASES e PROPTEST_MAX_SHRINK_ITERS como variáveis de ambiente, e a macro proptest! aceita configuração inline (#cfg(proptest)). Para CI, reduza casos a um número previsível e mantenha a exploração completa local ou em job noturno.

Segundo, versione o arquivo proptest.regress. Ele contém as entradas que já falharam e deve ser comitado. Assim, mesmo com sementes diferentes, as regressões conhecidas sempre são retestadas. Esse arquivo é a memória do sistema sobre bordas descobertas.

Terceiro, rode fuzzing em job separado, com tempo limitado, e armazene o corpus (fuzz/corpus/) em artefato. O corpus é o acúmulo de entradas interessantes que o libFuzzer descobriu; reusá-lo acelera futuras sessões.

Quando property-based testing vale o investimento

Nem todo código precisa de proptest. Uma função soma(a, b) -> a + b não. Property-based testing paga o custo de escrita quando o domínio tem: muitas bordas, invariantes não triviais, parsing de entrada externa, serialização/desserialização, estruturas de dados (árvores, grafos, filas), aritmética com limites, ou manipulação de texto Unicode.

Para medir se valerá a pena, uma heurística útil: se você consegue enunciar a propriedade em uma frase (“a saída é sempre ordenada”, “decodificar o que codifiquei devolve o original”, “o tamanho nunca diminui após inserir”), então proptest encaixa. Se a única coisa que você consegue afirmar é “para esta entrada, a saída é aquela”, fique com teste de exemplo.

Em projetos com benchmarking estruturado e testes de estratégia já maduros, adicionar proptest é o próximo degrau de qualidade. Ele fecha o ciclo: criterion mede desempenho, testes unitários verificam contrato, proptest explora bordas e cargo-fuzz stressa a fronteira hostil. Junto, transformam “funciona nos meus testes” em “funciona sob entrada que ninguém imaginou”.

Conclusão

Testes baseados em propriedades e fuzzing em Rust, com proptest e cargo-fuzz, são as ferramentas que separam código que “passa nos testes” de código que sobrevive a entrada hostil. O custo de adotá-las é baixo — uma dev-dependency, uma macro, um target de fuzz — e o retorno aparece em bugs que jamais teriam sido encontrados manualmente, em contraexemplos já minimizados prontos para fix, e em confiança real para operar em produção.

Para quem está construindo portfólio ou se preparando para entrevistas, dominar esse ferramental é diferencial concreto. Comece pelo round-trip de serialização, adicione invariantes às suas estruturas de dados, e reserve uma hora de fuzzing para qualquer parser que consuma bytes externos. O ecossistema brasileiro de empresas que usam Rust — fintechs, logtechs, plataformas de dados — valoriza exatamente esse perfil de engenharia que entende qualidade não como cobertura percentual, mas como resistência ao inesperado.


Leia também:

Veja também nossos sites parceiros: