Seus unit tests Só Cobrem os Casos que Você Lembrou
Você escreveu uma função reverse. Testou com [1, 2, 3] e [4, 5, 6, 7]. Passou. Enviou para produção.
Um usuário passa um slice de um único elemento. Sua função o ignora silenciosamente. Ele abre uma issue. Você encara o arquivo de testes e se pergunta como não notou algo tão óbvio.
Você não notou porque testes baseados em exemplos só pegam bugs que você antecipa. Cada assert_eq! na sua test suite é um palpite sobre onde a falha pode estar escondida. Testes baseados em propriedades substituem esses palpites por dados aleatórios e invariantes matemáticas. Eles vão encontrar o bug de elemento único. Também vão encontrar o bug de vector vazio, o caso limite de overflow de inteiro e o caso extremo de Unicode que você não sabia que existia.
Entradas Aleatórias e Invariantes, Não Exemplos e Expectativas
Testes baseados em propriedades invertem a lógica. Em vez de escolher entradas e afirmar saídas exatas, você define uma propriedade que deve valer para todas as entradas, e o framework de testes gera centenas de entradas aleatórias tentando quebrá-la.
O exemplo clássico é reverter uma lista. A propriedade não é “o reverso de [1, 2, 3] é [3, 2, 1]”. A propriedade é “reverter uma lista duas vezes retorna a lista original”. Essa invariante vale para toda lista possível, então o framework joga listas aleatórias nela até que algo quebre ou o limite de iterações seja atingido.
Rust tem duas bibliotecas maduras para isso. quickcheck foi a primeira opção popular, portada de Haskell. proptest é a abordagem mais recente com um recurso decisivo: shrinking automático. Quando o proptest encontra uma entrada que falha, ele não apenas imprime um vector de cem elementos e te deixa debugando. Ele simplifica a entrada até encontrar o contraexemplo mínimo possível. Essa é a diferença entre ler a saída de um teste falho e realmente entender o bug.
Como o Shrinking Encontra o Bug de Verdade
O shrinking é o ingrediente secreto. Digamos que o proptest gere um vector aleatório de 47 inteiros e sua propriedade falhe. O bug não é o 47º elemento. O bug provavelmente tem a ver com vectors vazios, mudanças de sinal ou valores duplicados.
O proptest sabe disso. Ele tenta remover elementos. Tenta zerar valores. Tenta deixar o vector cada vez menor. Se o teste ainda falhar com uma entrada menor, ele continua reduzindo. Para quando toda variação mais simples passa. O resultado é o contraexemplo mínimo.
Isso não é um “bom ter”. É o motivo pelo qual testes baseados em propriedades são usáveis na prática. Sem shrinking, você volta a debugar um palheiro. Com ele, você recebe uma agulha.
Um Bug Real, Um Teste Real
Aqui está uma função reverse com um bug sutil. Veja se consegue identificá-lo antes que o teste o faça.
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
O range começa em 1 em vez de 0. Para qualquer lista não vazia, o primeiro elemento é silenciosamente ignorado. Um unit test com [1, 2, 3] poderia passar se o autor apenas verificou que a saída parecia invertida, e não que tinha o tamanho certo. Mas a propriedade pega o bug instantaneamente.
Adicione proptest ao seu Cargo.toml:
[dev-dependencies]
proptest = "1.6"
Então escreva a propriedade:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
Execute cargo test e a saída conta a história:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
O proptest reduziu um vector aleatório que falhava até um único elemento, [0]. A primeira chamada retorna []. A segunda chamada retorna []. A propriedade exige [] == [0], o que falha. O bug fica óbvio quando a entrada é mínima.
Você também pode testar propriedades estruturais diretamente:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
Isso falha com a mesma entrada mínima, mas dá um erro ainda mais claro: o tamanho da saída é 0, não 1.
Por Que Você Ainda Precisa de unit tests Normais
Testes baseados em propriedades não substituem testes baseados em exemplos. Eles os complementam.
unit tests documentam intenção. Quando um desenvolvedor novo lê que reverse(&[1, 2, 3]) deve ser igual a [3, 2, 1], ele entende o que a função deve fazer. Um teste de propriedade diz “essa invariante vale”, mas não te diz como a saída se parece em um caso normal.
Propriedades também são mais difíceis de escrever do que exemplos. state machines, side effects e I/O não se mapeiam facilmente para pure functions. O proptest tem estratégias para tipos complexos, mas conectá-las exige mais reflexão do que algumas chamadas a assert_eq!.
Há também o custo de tempo. Um teste de propriedade pode executar mil iterações. Em uma base de código grande, isso acumula. Você executa testes de propriedade no CI, não a cada tecla durante o desenvolvimento.
Testes Aleatórios Ainda Podem Ser Reprodutíveis
Aleatoriedade deixa as pessoas nervosas. Se um teste falha apenas às vezes, é o código ou o teste?
O proptest lida com isso persistindo seeds que falharam. Quando uma propriedade falha, ele salva a seed e a entrada reduzida em um arquivo em proptest-regressions/. A próxima execução reproduz exatamente aquele caso antes de gerar novos dados aleatórios. Um teste de propriedade instável é quase sempre uma propriedade com bug, não um framework instável.
Se precisar de determinismo estrito, você pode fixar a seed na configuração do teste. A maioria das equipes não se incomoda. O arquivo de regressão é suficiente.
Onde Testes de Propriedade Valem a Pena
Testes baseados em propriedades valem o overhead quando seu código tem invariantes matemáticas claras e um grande espaço de entradas.
Ordenação é o exemplo clássico. As propriedades são simples: a saída está ordenada, é uma permutação da entrada e tem o mesmo tamanho. Uma ordenação com bug pode passar em exemplos escritos manualmente, mas falhar em dados aleatórios com elementos duplicados.
Parsers e serializadores são outro ponto doce. Propriedades de round-trip como parse(serialize(x)) == x pegam bugs de encoding que só aparecem com sequências específicas de bytes.
Qualquer coisa com aritmética, coleções ou transições de estado se beneficia. Qualquer coisa com APIs externas, timing estrito ou julgamento humano, não.
Comece com Uma Propriedade
Você não precisa reescrever sua test suite. Escolha uma pure function com uma invariante clara. Adicione proptest às dev-dependencies. Escreva uma única propriedade. Execute, veja passar, depois introduza um bug sutil e veja-o reduzir a falha a algo que você consegue debugar em segundos.
Quando você vir um teste aleatório encontrar um bug pelo qual você nunca teria escrito um exemplo, vai começar a recorrer a propriedades com mais frequência. Não para todo teste. Apenas para os que importam.