MÓDULO 02 · CONCEITO 05 DE 8

Property-based testing

Quando exemplos não bastam — testar invariantes em vez de casos específicos.

Tempo de leitura ~22 min Pré-requisito BDD & Given/When/Then Próximo Mutation testing

Quase todo teste que você já escreveu é example-based: você escolhe alguns inputs específicos, calcula manualmente o output esperado, e verifica que o código produz aquele output. add(2, 3) == 5. parse("hello") == ["hello"]. format(date) == "2025-04-28". Esse modelo cobre 90% dos casos práticos e é completamente válido — mas tem um buraco silencioso: você só testa o que pensou em testar.

A pergunta incômoda é: e os casos que você não pensou? O input vazio. O número negativo. O string com Unicode. A lista de um milhão de elementos. A ordem inversa. A entrada simétrica. Cada um desses pode ter bug que seu teste-de-exemplos nunca tocou. Property-based testing é o método que ataca exatamente esse problema: em vez de você pensar nos casos, a biblioteca gera milhares de casos aleatórios e verifica se uma propriedade matemática se mantém em todos. Quando encontra contraexemplo, automaticamente reduz para o menor input que ainda quebra (shrinking) — entregando diagnóstico cirúrgico.

O que é uma propriedade

Uma propriedade é uma afirmação que deveria ser verdade para todos os inputs válidos, não para um caso específico. Em matemática, isso é cotidiano: comutatividade, associatividade, idempotência, simetria. Em código, traduz para asserts que valem universalmente:

Cada uma dessas afirmações é generalização que cobre infinitos casos específicos. Property-based testing testa-as automaticamente: o framework gera inputs aleatórios, executa, verifica. Se quebrar, te mostra o input minimizado.

O exemplo canônico — sort

Considere a função sort. Em testes de exemplos, você escreveria algo como:

// Example-based
def test_sort_empty(): assert sort([]) == []
def test_sort_single(): assert sort([5]) == [5]
def test_sort_already_sorted(): assert sort([1,2,3]) == [1,2,3]
def test_sort_reverse(): assert sort([3,2,1]) == [1,2,3]
def test_sort_duplicates(): assert sort([2,1,2,1]) == [1,1,2,2]

Cobertura razoável. Mas e o caso de cinco elementos com pattern específico? E a lista com 100 elementos? E negativos misturados? Você pode escrever mais casos, mas vai sempre estar atrás de criatividade.

Em property-based, você expressa o que sort deveria fazer, e a biblioteca verifica em milhares de listas geradas aleatoriamente:

# Python com Hypothesis
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
    assert sort(sort(xs)) == sort(xs)

@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
    assert len(sort(xs)) == len(xs)

@given(st.lists(st.integers()))
def test_sort_preserves_elements(xs):
    assert sorted(sort(xs)) == sorted(xs)  # mesmos elementos

@given(st.lists(st.integers()))
def test_sort_is_ordered(xs):
    result = sort(xs)
    for i in range(len(result) - 1):
        assert result[i] <= result[i + 1]

Quatro propriedades juntas caracterizam sort completamente — qualquer função que satisfaça as quatro é um sort correto. Hypothesis gera milhares de listas, executa todas as quatro propriedades em cada, e te avisa se alguma falhar.

Shrinking — diagnóstico cirúrgico

O que torna property-based realmente poderoso não é só a geração; é o shrinking. Quando o framework encontra um input que falha, ele automaticamente tenta inputs menores ou mais simples até achar o mínimo caso que ainda reproduz o bug.

Suponha que sua sort tem bug com listas de exatamente 7 elementos com duplicatas em posição específica. O framework pode encontrar isso com input gigante (lista de 50 elementos com a estrutura ofensiva). Shrinking então tenta cortar pela metade, depois pela metade de novo, até ter o menor exemplo: talvez [2, 1, 2, 1, 2, 1, 2].

O shrunk é o que vai aparecer no relatório de falha:

Falsifying example: test_sort_is_ordered(
    xs=[2, 1, 2, 1, 2, 1, 2]
)
AssertionError: 2 <= 1 (at index 5)

Esse é diagnóstico que normalmente exige horas de debug. O framework faz em segundos. Para quem nunca usou, é experiência transformadora.

Os tipos de propriedades

Scott Wlaschin (em F# for Fun and Profit) e John Hughes (em How to Specify It!) catalogam padrões recorrentes de propriedades. Conhecer os arquétipos ajuda a descobrir propriedades no seu próprio código.

Diferentes caminhos, mesmo destino

Duas formas de calcular a mesma coisa devem dar resultado idêntico. Útil para verificar otimizações:

# A função otimizada deve concordar com a ingênua
@given(st.lists(st.integers()))
def test_fast_sum_matches_naive(xs):
    assert fast_sum(xs) == sum(xs)

Inverso

Operações que vêm em pares (encode/decode, parse/serialize, compress/decompress) devem se cancelar:

@given(st.text())
def test_encode_decode_roundtrip(s):
    assert decode(encode(s)) == s

@given(json_strategy)
def test_parse_serialize_roundtrip(obj):
    assert parse(serialize(obj)) == obj

Idempotência

Aplicar a operação várias vezes tem mesmo efeito que aplicar uma:

@given(st.text())
def test_normalize_idempotent(s):
    once = normalize(s)
    twice = normalize(normalize(s))
    assert once == twice

Invariante

Propriedade que se mantém antes e depois da operação:

@given(st.lists(st.integers()))
def test_filter_preserves_size_or_smaller(xs):
    assert len(filter_positive(xs)) <= len(xs)

Comutatividade / associatividade

Para operações matemáticas:

@given(st.integers(), st.integers())
def test_addition_commutes(a, b):
    assert add(a, b) == add(b, a)

@given(st.integers(), st.integers(), st.integers())
def test_addition_associates(a, b, c):
    assert add(add(a, b), c) == add(a, add(b, c))

Modelo de referência

Compare seu código com uma implementação simples e óbvia:

# Um Map customizado deveria se comportar como dict
@given(st.dictionaries(st.text(), st.integers()))
def test_my_map_matches_dict(d):
    my_map = MyMap()
    for k, v in d.items():
        my_map.put(k, v)
    for k, v in d.items():
        assert my_map.get(k) == v

O mesmo nas três linguagens

C# — FsCheck
// FsCheck — porte do QuickCheck para .NET
using FsCheck;
using FsCheck.Xunit;

public class CalculatorProperties {
    [Property]
    public bool Add_IsCommutative(int a, int b) {
        return Calculator.Add(a, b) == Calculator.Add(b, a);
    }

    [Property]
    public Property Reverse_Twice_IsOriginal() {
        return Prop.ForAll<int[]>(xs =>
            xs.Reverse().Reverse().SequenceEqual(xs));
    }

    [Property]
    public Property Sort_PreservesElements() {
        return Prop.ForAll<int[]>(xs => {
            var sorted = MySort.Sort(xs);
            return sorted.OrderBy(x => x).SequenceEqual(
                xs.OrderBy(x => x));
        });
    }
}

FsCheck integra com xUnit via FsCheck.Xunit. Suporta shrinking automático e geradores customizados (Arbitrary). CsCheck é alternativa popular.

Python — Hypothesis
# Hypothesis — referência da indústria, profundamente
# integrado ao pytest

from hypothesis import given, strategies as st, settings

@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs):
    assert my_sort(my_sort(xs)) == my_sort(xs)

@given(st.text(), st.text())
def test_concat_preserves_length(a, b):
    assert len(a + b) == len(a) + len(b)

# Shrinking automático + database de falhas
# Quando falha, salva o exemplo e roda primeiro nas próximas runs
@settings(max_examples=500)
@given(st.dictionaries(st.text(min_size=1), st.integers()))
def test_serialization_roundtrip(d):
    assert json.loads(json.dumps(d)) == d

Hypothesis é referência absoluta da indústria. Tem "database" de exemplos falhos que persiste — bug encontrado uma vez fica guardado e é re-testado em runs futuras automaticamente.

Go — gopter / rapid
// rapid é a opção moderna em Go (Pgavlin/rapid)
import (
    "testing"
    "pgregory.net/rapid"
)

func TestSortIsIdempotent(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        xs := rapid.SliceOf(rapid.Int()).Draw(t, "xs")
        once := mySort(xs)
        twice := mySort(once)
        if !slicesEqual(once, twice) {
            t.Fatalf("sort not idempotent: %v vs %v", once, twice)
        }
    })
}

func TestReverseTwice(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        xs := rapid.SliceOf(rapid.Int()).Draw(t, "xs")
        twice := reverse(reverse(xs))
        if !slicesEqual(xs, twice) {
            t.Fatalf("reverse twice != original")
        }
    })
}

Go tem dois ecossistemas: gopter (mais antigo, port direto de QuickCheck) e rapid (moderno, integrado ao testing stdlib). Rapid é a recomendação atual.

Onde property-based brilha

O custo de propriedades é maior que de exemplos — você precisa pensar em invariantes, não em casos. Em troca, a cobertura é massiva. Vale especialmente em:

Onde property-based é difícil

Não é silver bullet. Limitações reais:

Encontrar a propriedade pode ser tão difícil quanto resolver o problema

Para algumas funções, a única propriedade que você consegue articular é "deveria fazer o que faz" — circular. Casos como "calcular imposto brasileiro" — a propriedade é o conjunto de regras, e expressar isso como propriedade é re-implementar. Aqui exemplos cabem melhor.

Lentidão

Cada propriedade roda 100-1000 vezes por padrão. Suíte com 50 propriedades multiplica em 5000-50000 execuções. Em testes que tocam I/O ou são pesados, fica inviável.

Geração realista

Inputs aleatórios podem ser irrealisticamente extremos ("\x00\xff\u200b" em vez de "hello"). Para bugs que só aparecem em certos padrões, você precisa de geradores customizados — o que adiciona complexidade.

Falsos positivos por descuido

Se sua propriedade está mal-articulada, framework encontra contraexemplo legítimo que você considera "esquisito". Você ajusta a propriedade para excluir, encontra outro, ajusta de novo. Pode acabar escrevendo propriedade tão restritiva que não testa nada útil.

heurística do sênior

Property-based não substitui example-based — complementa. Use exemplos para casos canônicos (especialmente borda) e propriedades para o regime contínuo. Onde você só tem 3 exemplos no teste, considere se existe propriedade — frequentemente existe e cobre milhares de casos equivalentes.

Exemplo de cadeia que pega bug real

Para mostrar o impacto, considere um exemplo concreto. Suponha que você tem uma função parsePhone(s: string) -> Phone que tira espaços, hífens, parênteses e valida se o que sobra é número.

def parse_phone(s: str) -> str | None:
    cleaned = s.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
    if cleaned.isdigit() and 10 <= len(cleaned) <= 11:
        return cleaned
    return None

Testes de exemplo passariam: parse_phone("(11) 98765-4321") retorna "11987654321". Bom.

Agora a propriedade:

@given(st.text())
def test_parse_phone_doesnt_crash(s):
    result = parse_phone(s)
    # Propriedade fraca: nunca crashar com qualquer input
    assert result is None or result.isdigit()

Hypothesis gera milhares de strings aleatórias. Em alguma, você vai descobrir que uma string com Unicode "dígitos" estranhos (numerais arábicos persas, por exemplo) passam por isdigit() em Python mas não são ASCII digits. Resultado: seu sistema aceita "número" que não é número, e quando outro código tentar tratar como int, crash.

Você nunca pensaria em testar com numeral persa. O framework testa tudo.

Como introduzir gradualmente

Não precisa ser tudo ou nada. Caminho prático:

  1. Comece em um módulo "matemático": cálculos, parsers, estruturas. Esses são onde propriedades são mais naturais.
  2. Adicione propriedades junto com exemplos: não substitua. Mantenha exemplos para os casos canônicos; adicione propriedade para o regime geral.
  3. Capture bugs encontrados: quando uma propriedade falha e você corrige o bug, mantenha o exemplo encontrado no teste como caso fixo. Hypothesis faz isso automaticamente via database; em outras linguagens, manual.
  4. Geradores customizados quando necessário: para domínios complexos (e-commerce, finanças), invista em geradores que criam dados realistas. Strategy em Hypothesis, Arbitrary em FsCheck.

Como praticar

  1. Encontre 3 propriedades em código que você já tem. Pegue um repositório seu. Identifique funções com propriedades naturais (sort, parse, encode, normalize). Escreva uma propriedade para cada. Veja se passam.
  2. Use shrinking deliberadamente. Introduza um bug sutil em uma função sortable. Veja o framework encontrar e shrinkar para o menor exemplo. Note como o output minimal é diagnóstico.
  3. Implemente parser com property-based. Escreva um parser de algo simples (CSV, JSON-lite). Use propriedade parse(serialize(x)) == x como guarda principal.

Referências para aprofundar

  1. livro Property-Based Testing with PropEr, Erlang, and Elixir — Fred Hebert (2019). Embora focado em Erlang, é uma das melhores explicações conceituais. Princípios universais.
  2. livro Domain Modeling Made Functional — Scott Wlaschin (2018). F# usa property-based como first-class. Tratamento de propriedades em domínio bem-modelado.
  3. artigo Choosing Properties for Property-Based Testing — Scott Wlaschin. fsharpforfunandprofit.com/posts/property-based-testing-2 — catálogo dos arquétipos de propriedades. Releitura recomendada.
  4. artigo How to Specify It! — John Hughes. Paper de 2019 do criador do QuickCheck. Cinco estratégias para descobrir propriedades. Acessível.
  5. artigo Find more bugs with less work — David R. MacIver. drmaciver.substack.com — autor do Hypothesis. Posts sobre quando property-based vale e quando não.
  6. artigo Hypothesis Worked Examples. hypothesis.works/articles/ — coleção de problemas resolvidos com property-based. Material de aprendizagem aprofundado.
  7. docs Hypothesis Documentation. hypothesis.readthedocs.io — fonte primária para Python. Exemplos extensos, estratégias, integração com pytest.
  8. docs FsCheck Documentation. fscheck.github.io/FsCheck/ — fonte oficial. Integração com xUnit em FsCheck.Xunit.
  9. docs rapid (Go). github.com/flyingmutant/rapid — biblioteca moderna. Documentação concisa e exemplos diretos.
  10. paper QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs — Claessen & Hughes (2000). ICFP 2000. O paper que originou property-based. Curto, fundacional. PDF gratuito.
  11. paper Smart, Easy and Cheap Random Testing — Hughes (Erlang). Caso real onde QuickCheck encontrou bugs em código de produção (carros Volvo). Mostra o ROI em projeto sério.
  12. vídeo An Introduction to Property-Based Testing — Scott Wlaschin. YouTube. 60 minutos didáticos. Baseado em F# mas conceitualmente universal.