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:
-
Reverse de reverse é o original:
reverse(reverse(xs)) == xspara qualquer lista. -
Sort é idempotente:
sort(sort(xs)) == sort(xs). -
Soma é comutativa:
a + b == b + apara quaisquera, b. -
Encode + decode preserva:
decode(encode(x)) == xpara qualquerx. -
Filter mantém ordem:
filter(p, xs)é sublista ordenada dexs. -
Length de concat é soma:
len(a + b) == len(a) + len(b).
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
// 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.
# 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.
// 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:
-
Parsers e serializers: roundtrip
(
parse(serialize(x)) == x) é a propriedade mais valiosa que existe. Captura bugs em encoding, edge cases de Unicode, escape characters, etc. - Algoritmos numéricos: comutatividade, associatividade, identidade, etc., são propriedades naturais.
- Estruturas de dados: invariantes são propriedades — uma árvore balanceada deve permanecer balanceada após qualquer sequência de inserções e remoções.
- Funções de transformação: filtro, map, reduce — cada um tem propriedades sobre o que preserva e o que muda.
-
Validações: se você tem uma função
isValid(), propriedades dizem o que conta como válido (todo email válido contém '@', toda senha válida tem 8+ chars).
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.
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:
- Comece em um módulo "matemático": cálculos, parsers, estruturas. Esses são onde propriedades são mais naturais.
- Adicione propriedades junto com exemplos: não substitua. Mantenha exemplos para os casos canônicos; adicione propriedade para o regime geral.
- 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.
- 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
- 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.
- 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.
-
Implemente parser com property-based. Escreva um
parser de algo simples (CSV, JSON-lite). Use propriedade
parse(serialize(x)) == xcomo guarda principal.
Referências para aprofundar
- livro Property-Based Testing with PropEr, Erlang, and Elixir — Fred Hebert (2019).
- livro Domain Modeling Made Functional — Scott Wlaschin (2018).
- artigo Choosing Properties for Property-Based Testing — Scott Wlaschin.
- artigo How to Specify It! — John Hughes.
- artigo Find more bugs with less work — David R. MacIver.
- artigo Hypothesis Worked Examples.
- docs Hypothesis Documentation.
- docs FsCheck Documentation.
- docs rapid (Go).
- paper QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs — Claessen & Hughes (2000).
- paper Smart, Easy and Cheap Random Testing — Hughes (Erlang).
- vídeo An Introduction to Property-Based Testing — Scott Wlaschin.