Testes baseados em propriedades verificam uma regra geral com muitos dados gerados automaticamente. Em vez de escrever apenas assert ordenar([3, 1]) == [1, 3], você declara que ordenar qualquer lista deve preservar seus elementos, produzir ordem crescente e ser idempotente. O Hypothesis cria listas variadas, executa o teste e, se encontrar uma falha, reduz a entrada até um contraexemplo pequeno.

Essa abordagem não elimina testes tradicionais. Ela acrescenta exploração sistemática a uma suíte de testes automatizados com pytest, especialmente nas partes em que o espaço de entradas é grande: parsers, serialização, cálculos, validação e transformações de coleções.

Instalação e primeiro teste

Crie um ambiente virtual Python e instale os dois pacotes:

python -m pip install hypothesis pytest

Considere esta função:

def normalizar_nome(nome: str) -> str:
    return " ".join(nome.split()).casefold()

Uma propriedade útil é a idempotência: normalizar uma vez ou duas deve produzir o mesmo resultado.

from hypothesis import given, strategies as st

from app.texto import normalizar_nome


@given(st.text())
def test_normalizacao_e_idempotente(texto: str) -> None:
    uma_vez = normalizar_nome(texto)
    duas_vezes = normalizar_nome(uma_vez)

    assert duas_vezes == uma_vez

Execute pytest -q. O decorador @given solicita dados à strategy st.text(). Cada execução do teste recebe várias strings, incluindo vazias, Unicode, espaços incomuns e combinações difíceis de antecipar manualmente. A documentação oficial do Hypothesis descreve o comportamento e as opções atuais.

Strategies descrevem dados válidos

Uma strategy não é simplesmente um gerador aleatório. Ela descreve como produzir e simplificar valores. Há strategies para inteiros, decimais, datas, texto, binários, listas, conjuntos, dicionários e combinações.

from hypothesis import given, strategies as st


@given(st.lists(st.integers(), max_size=200))
def test_ordenacao_preserva_conteudo(valores: list[int]) -> None:
    resultado = sorted(valores)

    assert len(resultado) == len(valores)
    assert resultado == sorted(resultado)
    assert sorted(resultado, reverse=True) == list(reversed(resultado))

Restrições devem representar o domínio real. Se uma função aceita percentuais de zero a cem, prefira st.integers(min_value=0, max_value=100). Não filtre entradas só para fazer o teste passar. Muitos .filter() descartam dados e podem deixar a geração lenta; expresse limites nos argumentos ou use st.builds() para montar objetos.

Para dados dependentes, use @st.composite:

@st.composite
def intervalos(draw):
    inicio = draw(st.integers(-10_000, 10_000))
    fim = draw(st.integers(min_value=inicio, max_value=inicio + 1_000))
    return inicio, fim


@given(intervalos())
def test_intervalo_tem_tamanho_nao_negativo(intervalo):
    inicio, fim = intervalo
    assert fim - inicio >= 0

O draw preserva a relação fim >= inicio sem rejeitar pares. Para modelos maiores, st.builds(Produto, nome=..., preco=...) reaproveita o construtor e mantém a descrição perto do tipo.

Como escolher invariantes úteis

Uma invariante é uma condição que permanece verdadeira para todas as entradas do domínio. As melhores propriedades falam sobre o comportamento, não repetem a implementação. Padrões frequentes incluem:

  • Idempotência: aplicar novamente não muda o resultado, como normalização e deduplicação.
  • Round trip: decodificar o que foi codificado recupera o valor original.
  • Conservação: ordenar preserva tamanho e elementos; transferir preserva o saldo total.
  • Limites: um resultado fica dentro de um intervalo definido.
  • Relação com modelo simples: uma implementação otimizada coincide com uma versão pequena e obviamente correta.

Um teste de ida e volta para JSON precisa respeitar o domínio do formato:

import json
from hypothesis import given, strategies as st

json_escalar = st.none() | st.booleans() | st.integers() | st.text()
json_valor = st.recursive(
    json_escalar,
    lambda filhos: st.lists(filhos, max_size=10)
    | st.dictionaries(st.text(), filhos, max_size=10),
    max_leaves=30,
)


@given(json_valor)
def test_json_faz_round_trip(valor) -> None:
    assert json.loads(json.dumps(valor)) == valor

Evitar floats aqui é uma decisão de domínio, não uma forma de esconder defeitos: valores não finitos e detalhes de representação exigiriam uma propriedade específica. Anotações como as do guia de type hints em Python ajudam a comunicar o contrato, mas não validam dados em execução.

Shrinking: do defeito ao caso mínimo

Ao falhar, Hypothesis procura um exemplo mais simples que continue falhando. Esse processo é chamado shrinking. Uma lista enorme pode virar [0]; uma string complexa pode virar um único caractere. O contraexemplo mínimo costuma revelar a regra ausente com mais clareza do que a primeira entrada encontrada.

Não presuma que o primeiro valor exibido foi o primeiro gerado, nem escreva assertions sobre a ordem da geração. Também evite capturar exceções amplas dentro do teste: isso impede que o framework reconheça a falha. Se determinada exceção faz parte do contrato, use pytest.raises explicitamente.

Reprodução e regressões

Hypothesis salva exemplos interessantes em .hypothesis/ e tenta executá-los novamente. Em CI, a suíte continua determinística no sentido importante: um contraexemplo persistido vira prioridade. Não defina uma semente fixa permanentemente apenas para obter conforto, pois isso reduz a exploração.

Quando uma falha precisa ficar visível no código, transforme-a em exemplo:

from hypothesis import example, given, strategies as st


@given(st.text())
@example("\u00a0")  # regressão conhecida: espaço não separável
def test_normalizacao_nao_termina_com_espaco(texto: str) -> None:
    assert not normalizar_nome(texto).endswith(" ")

O relatório também pode fornecer @reproduce_failure(...), útil para reproduzir temporariamente o blob exato na mesma versão do Hypothesis. Depois de corrigir o problema, um @example legível normalmente documenta melhor a regressão. Em pipelines, combine isso com as práticas do guia de CI para Python com GitHub Actions.

Estratégias compostas e custo dos casos

Nem todo domínio cabe em uma combinação direta de lists() e dictionaries(). Quando um valor depende de outro, use @st.composite para gerar um objeto coerente. Um intervalo, por exemplo, pode sortear primeiro o início e depois limitar o fim a valores iguais ou maiores. Isso é melhor do que gerar dois inteiros independentes e descartar a maioria com assume().

@st.composite
def intervalos(draw):
    inicio = draw(st.integers(min_value=-1000, max_value=1000))
    fim = draw(st.integers(min_value=inicio, max_value=1000))
    return inicio, fim

Filtros e assume() são úteis para condições ocasionais, mas rejeitar dados demais reduz a exploração e pode acionar um health check. Sempre que possível, construa somente valores válidos. Também evite estratégias muito maiores que o comportamento testado: gerar textos Unicode gigantes para uma função que aceita no máximo 20 caracteres consome tempo sem representar a interface real.

Desempenho faz parte do desenho do teste. Comece com as configurações padrão, observe o tempo da suíte e altere max_examples apenas com uma justificativa. Use perfis diferentes para desenvolvimento e CI se necessário, mas não desative health checks globalmente para esconder uma estratégia ineficiente. Um deadline que falha de forma instável pode indicar ruído da máquina; antes de removê-lo, confirme se não há uma operação inesperadamente lenta.

Quando usar e quando não usar

Use Hypothesis quando há um domínio amplo e propriedades fortes: codecs, parsers, algoritmos, APIs de transformação, máquinas de estado e regras financeiras com geradores cuidadosamente limitados. Testes stateful também conseguem gerar sequências de operações, mas comece com funções puras e invariantes pequenas.

Prefira um teste por exemplo quando o requisito é um caso específico, uma mensagem exata ou uma integração cara. Interfaces visuais e fluxos externos instáveis raramente são o primeiro lugar para geração intensiva. Também não gere dados sem uma afirmação significativa: executar uma função e verificar apenas que ela não lançou exceção pode ser válido para robustez, mas é uma propriedade fraca.

Erros comuns são duplicar a própria implementação na assertion, usar random dentro do teste, filtrar quase tudo, permitir estruturas ilimitadas e aumentar max_examples para compensar uma strategy ruim. Outro erro é misturar I/O lento em cada exemplo; separe a lógica pura e teste a integração com poucos casos representativos.

Checklist final

  • Instale hypothesis e pytest no ambiente de desenvolvimento.
  • Defina o domínio com strategies precisas e tamanhos limitados.
  • Escolha invariantes independentes da implementação.
  • Mantenha cada teste rápido, isolado e sem estado global.
  • Leia o contraexemplo reduzido antes de alterar a strategy.
  • Registre regressões importantes com @example.
  • Rode a suíte localmente e na CI sem fixar sementes por padrão.

Comece por uma função com entrada variada e saída fácil de verificar. Uma boa propriedade e uma strategy fiel ao domínio entregam mais valor do que dezenas de geradores genéricos. Hypothesis é especialmente eficaz quando complementa exemplos legíveis, não quando tenta substituí-los.