Property-based testing com proptest
1. Introdução ao Property-based Testing
O testing tradicional, conhecido como example-based testing, consiste em escrever casos de teste com entradas específicas e verificar saídas esperadas. Por exemplo, testamos soma(2, 2) e esperamos 4. Esse abordagem é intuitiva, mas limitada: ficamos reféns dos nossos próprios vieses e frequentemente deixamos de explorar casos extremos.
O property-based testing inverte essa lógica. Em vez de escolher entradas manualmente, definimos propriedades que devem ser verdadeiras para todas as entradas válidas. O framework gera automaticamente milhares de entradas aleatórias e verifica se a propriedade se mantém.
Vantagens principais:
- Cobertura massiva de casos extremos (zeros, negativos, valores máximos)
- Redução de vieses humanos na escolha de entradas
- Descoberta de bugs que você nem imaginava existirem
No ecossistema Rust, temos duas bibliotecas principais: quickcheck (mais antiga, inspirada em Haskell) e proptest (mais moderna, com melhor shrinking e estratégias mais flexíveis). Este artigo foca em proptest, que se tornou a escolha padrão na comunidade Rust.
2. Primeiros Passos com proptest
Adicione ao seu Cargo.toml:
[dev-dependencies]
proptest = "1.4.0"
Um teste básico com proptest usa a macro proptest! e prop_assert!:
use proptest::prelude::*;
proptest! {
#[test]
fn test_soma_commutativa(a: i32, b: i32) {
prop_assert_eq!(a + b, b + a);
}
}
Execute com cargo test. Se uma propriedade falhar, proptest exibe o contraexemplo mínimo — um processo chamado shrinking. Por exemplo, se testarmos uma função bugada:
fn divide_seguro(a: i32, b: i32) -> i32 {
if b == 0 { 0 } else { a / b }
}
proptest! {
#[test]
fn test_divide_seguro_nao_panica(a: i32, b: i32) {
divide_seguro(a, b);
}
}
Se houver overflow (ex: i32::MIN / -1), o shrinking reduzirá o problema ao menor caso que reproduz o erro.
3. Gerando Entradas de Teste (Strategies)
proptest chama as fontes de dados de strategies. Para tipos primitivos:
proptest! {
#[test]
fn test_com_ranges(x in 0..100i32, y in -50..50i32) {
prop_assert!(x + y >= -50 && x + y < 150);
}
}
Para coleções, use prop::collection:
use proptest::collection::vec;
proptest! {
#[test]
fn test_vetor_nao_vazio(v in vec(1..10i32, 1..100)) {
prop_assert!(!v.is_empty());
prop_assert!(v.iter().all(|&x| x >= 1 && x < 10));
}
}
Estratégias customizadas com prop_compose!:
#[derive(Debug, Clone)]
struct Pessoa {
nome: String,
idade: u8,
}
prop_compose! {
fn pessoa_estrategia()(nome in "[a-z]{3,10}", idade in 0..120u8) -> Pessoa {
Pessoa { nome, idade }
}
}
proptest! {
#[test]
fn test_pessoa_valida(p in pessoa_estrategia()) {
prop_assert!(!p.nome.is_empty());
prop_assert!(p.idade <= 120);
}
}
4. Escrevendo Propriedades Robuster
Propriedades comuns em testes baseados em propriedades:
- Idempotência: aplicar a função duas vezes produz o mesmo resultado
- Invariantes: certas características se mantêm (ex: tamanho de lista após filtro)
- Simetria: ordem de operações não altera resultado
// Idempotência de ordenação
fn ordenar(mut v: Vec<i32>) -> Vec<i32> {
v.sort();
v
}
proptest! {
#[test]
fn test_ordenacao_idempotente(mut v in vec(any::<i32>(), 0..100)) {
let v1 = ordenar(v.clone());
let v2 = ordenar(v1.clone());
prop_assert_eq!(v1, v2);
}
}
Use prop_assume! para pré-condições:
proptest! {
#[test]
fn test_divisao_por_nao_zero(a: i32, b: i32) {
prop_assume!(b != 0);
prop_assert!(a / b != 0 || a == 0);
}
}
5. Técnicas Avançadas de Geração
Para estruturas recursivas (como árvores), use prop_recursive!:
#[derive(Clone, Debug)]
enum Arvore {
Folha(i32),
No(Box<Arvore>, Box<Arvore>),
}
fn arvore_estrategia() -> impl Strategy<Value = Arvore> {
prop::recursive::recursive(
any::<i32>().prop_map(Arvore::Folha),
2, // profundidade máxima
10, // tamanho máximo
|inner| {
(inner.clone(), inner.clone())
.prop_map(|(esq, dir)| Arvore::No(Box::new(esq), Box::new(dir)))
},
)
}
Controlando distribuição com prop_oneof! e pesos:
fn estrategia_com_pesos() -> impl Strategy<Value = i32> {
prop_oneof![
3 => 0..10i32, // 30% de chance
1 => -100..0i32, // 10% de chance
6 => any::<i32>(), // 60% de chance
]
}
6. Integração com o Ecossistema de Testes
proptest funciona perfeitamente com #[tokio::test]:
#[cfg(test)]
mod tests {
use proptest::prelude::*;
proptest! {
#[test]
fn test_async(x in 0..100i32) {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
prop_assert!(x >= 0);
});
}
}
}
Para mocking, combine com mockall:
#[cfg(test)]
mod tests {
use mockall::automock;
use proptest::prelude::*;
#[automock]
trait Servico {
fn processar(&self, valor: i32) -> i32;
}
proptest! {
#[test]
fn test_com_mock(x in 0..100i32) {
let mut mock = MockServico::new();
mock.expect_processar()
.with(mockall::predicate::eq(x))
.returning(|v| v * 2);
prop_assert_eq!(mock.processar(x), x * 2);
}
}
}
7. Boas Práticas e Debugging
Configure o comportamento com ProptestConfig:
use proptest::test_runner::Config;
proptest! {
#![proptest_config(Config {
cases: 10000, // número de casos
max_shrink_iters: 1000, // iterações de shrinking
timeout: Some(5), // timeout em segundos
.. Config::default()
})]
#[test]
fn test_com_config(x in 0..1000i32) {
prop_assert!(x < 1001);
}
}
Para logging detalhado durante shrinking:
proptest! {
#[test]
fn test_com_debug(x in any::<i32>()) {
let resultado = alguma_funcao(x);
prop_assert_ne!(resultado, i32::MAX, "Falhou para x = {}", x);
}
}
8. Exemplo Prático: Testando um Algoritmo de Ordenação
Vamos implementar um algoritmo de ordenação simples (Bubble Sort) e testá-lo com property-based testing:
fn bubble_sort<T: Ord>(v: &mut [T]) {
let n = v.len();
for i in 0..n {
for j in 0..n - i - 1 {
if v[j] > v[j + 1] {
v.swap(j, j + 1);
}
}
}
}
proptest! {
#[test]
fn test_bubble_sort_propriedades(mut v in vec(any::<i32>(), 0..100)) {
let original = v.clone();
bubble_sort(&mut v);
// Propriedade 1: Tamanho preservado
prop_assert_eq!(v.len(), original.len());
// Propriedade 2: Elementos preservados (mesmo multiset)
let mut sorted_original = original.clone();
sorted_original.sort();
prop_assert_eq!(v, sorted_original);
// Propriedade 3: Ordenação é idempotente
let mut v2 = v.clone();
bubble_sort(&mut v2);
prop_assert_eq!(v, v2);
// Propriedade 4: Cada elemento é <= ao próximo
for i in 0..v.len().saturating_sub(1) {
prop_assert!(v[i] <= v[i + 1],
"Falha na ordenação: v[{}] = {} > v[{}] = {}",
i, v[i], i + 1, v[i + 1]);
}
}
}
Ao executar, proptest testará com milhares de vetores aleatórios, incluindo vetores vazios, com um elemento, duplicados, valores extremos (i32::MIN, i32::MAX), e tudo mais. Se houver um bug, o shrinking encontrará o menor caso que o reproduz.
Este exemplo demonstra como property-based testing força você a pensar em invariantes em vez de casos específicos, resultando em código mais robusto e testes mais significativos.
Referências
- Documentação oficial do proptest — Referência completa da API, incluindo todas as strategies e macros disponíveis
- Property Testing with proptest (Rust Blog) — Artigo oficial do time Rust sobre property-based testing com proptest
- proptest Book — Guia detalhado com exemplos práticos e explicações sobre shrinking e strategies avançadas
- Rust Design Patterns: Property-based Testing — Padrões de design para testing baseado em propriedades em Rust
- QuickCheck vs proptest: A Comparison — Comparação prática entre as duas principais bibliotecas de property-based testing em Rust
- Testing Rust Code with proptest (LogRocket) — Tutorial passo a passo com exemplos reais de aplicação