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