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:
- 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. - 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(®).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:
- Estratégias de Testes em Rust: Boas Práticas
- Tratamento de Erros com thiserror e anyhow
- Validação de Dados em Rust: validator, garde e Serde
- Proptest — Property-Based Testing
- Criterion — Benchmarking Estatístico
- Serde — Serialização em Rust
Veja também nossos sites parceiros:
- Go Brasil — comparando fuzzing e property-based testing entre Rust e Go
- Python Brasil Dev — do Hypothesis em Python ao proptest em Rust
- Kotlin Brasil Dev — estratégias de teste em ecossistemas distintos