100 Execuções de Teste é uma Mentira: Como Dimensionar Seus Testes Baseados em Propriedades

Se você está executando testes baseados em propriedades com os 100 exemplos padrão, você está tendo o pior dos dois mundos. Seu CI está mais lento do que precisa estar, e você ainda não está capturando os bugs que importam.

O número não é mágico. A maioria das bibliotecas, incluindo Hypothesis, usa 100 como padrão porque é um número redondo que parece seguro. Mas “parece seguro” não é uma estratégia de teste.

O que property-based testing realmente promete

Property-based testing inverte o script dos unit tests. Em vez de escrever inputs e outputs esperados manualmente, você define uma propriedade. Uma regra que deve sempre se manter. O framework gera inputs para quebrá-la.

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
    assert lst == list(reversed(list(reversed(lst))))

O framework executa essa função muitas vezes com listas aleatórias de inteiros. Se encontrar um contraexemplo, ele reduz o input para a menor versão que ainda falha. Uma lista de 47 elementos que dispara um bug é inútil para debugging. Uma lista de 3 elementos é ouro.

Isso é poderoso. Também é probabilístico. Property-based testing não pode provar correção. Só pode aumentar sua confiança de que um bug não existe, ou encontrar um bug se houver. Essa natureza probabilística é o que torna a quantidade de execuções tão importante.

Por que 100 é arbitrário

Vamos ser honestos sobre de onde vem o 100. No Hypothesis, é um padrão escolhido em 2015 porque era um número redondo que capturava a maioria dos bugs sem tornar os testes insuportavelmente lentos. Foi um compromisso social, não estatístico.

A probabilidade de encontrar um bug depende de duas coisas. Quão comum o bug é no espaço de input, e quantas amostras você tira. Se um bug só é disparado quando o input é um palíndromo de comprimento maior que 20, e palíndromos são 0,01% de todas as listas, 100 execuções lhe dá aproximadamente 1% de chance de capturá-lo. Isso não é um teste. É um bilhete de loteria.

A maioria dos bugs não é tão rara. Muitas propriedades quebram em listas vazias, elementos únicos, ou duplicatas simples. Um gerador bem afinado captura esses rapidamente. Mas o padrão de 100 assume que seus geradores são perfeitos e seus bugs são rasos. Ambas as suposições estão erradas.

O que a quantidade de execuções realmente compra, estatisticamente

Se modelarmos a descoberta de bugs como amostragem com reposição de um espaço de input onde o bug tem probabilidade p de aparecer, a probabilidade de perder o bug após n execuções é (1 - p)^n.

Para p = 0,01, 100 execuções lhe dão 37% de chance de perder o bug. Para p = 0,001, 100 execuções lhe dão 90% de chance de perdê-lo. Para ter 99% de confiança de capturar um bug de 0,1%, você precisa de cerca de 4.600 execuções.

import math

def runs_for_confidence(p, confidence=0.99):
    """Returns the runs needed to catch a bug with probability `p`
    at the given confidence level."""
    return math.ceil(math.log(1 - confidence) / math.log(1 - p))

print(runs_for_confidence(0.01))    # 459
print(runs_for_confidence(0.001))   # 4603
print(runs_for_confidence(0.0001))  # 46050

Essa é a parte que deixa as pessoas desconfortáveis. Se você quer alta confiança em bugs raros, você precisa de dezenas de milhares de execuções. Ninguém quer esperar tanto no CI.

Shrinking muda a equação de custo

O padrão de 100 execuções foi definido antes de o shrinking ser tão bom quanto é hoje. Frameworks modernos de property-based testing não apenas encontram bugs. Eles encontram bugs mínimos.

Isso significa que você pode pensar em termos de orçamento, não apenas de contagem. Se você executa 1.000 exemplos e encontra um bug na 847ª execução, o shrinking pode levar mais 200 a 300 execuções para minimizar o contraexemplo. O custo total é 1.100 ou mais execuções para um bug. Mas se você executa 10.000 exemplos e não encontra nada, você gastou 10.000 execuções por tranquilidade.

O truque é separar descoberta de validação. Execute uma suíte pequena e rápida no CI para feedback imediato. Execute uma suíte maior e mais lenta durante a noite ou em branches de release para confiança mais profunda.

from hypothesis import given, settings, strategies as st
import json

# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
    assert json.loads(json.dumps(d)) == d

# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
    assert json.loads(json.dumps(d)) == d

Isso não é apenas sobre velocidade. É sobre densidade de informação. Um teste de 100 execuções que passa não diz quase nada. Um teste de 5.000 execuções que passa diz um pouco mais. Um teste de 100 execuções que falha diz exatamente onde olhar.

Como dividimos testes baseados em propriedades em camadas

Na nossa experiência, a melhor abordagem é parar de tratar todas as propriedades como iguais. nodes as dividimos em três camadas.

Propriedades rápidas executam em todo pull request. São as mecânicas. Serialização round-trip, idempotência de deduplicação, invariantes básicos em estruturas de dados. Executamos 100 a 200 exemplos. Eles completam em menos de um segundo.

Propriedades profundas executam em todo merge para main. Estas visam state machines complexas, pipelines de processamento de events, e qualquer coisa com explosão combinatória. Executamos 2.000 a 10.000 exemplos. Eles levam minutos, não horas.

Propriedades exploratórias executam manualmente antes de releases. São aquelas onde aumentamos max_examples para 50.000 ou mais e deixamos a máquina trabalhar enquanto revisamos o changelog. Encontramos race conditions e casos limite de integer overflow dessa forma que nenhuma quantidade de unit testing teria capturado.

O que fazer em vez de chutar

Pare de tratar max_examples como um botão que você ajusta uma vez e esquece. Trate-o como uma configuração que pertence à propriedade, não ao framework.

Faça três perguntas para cada propriedade que você escreve.

Quão caro é esse teste para executar? Se cada exemplo leva 50ms, 10.000 execuções são 8 minutos. Se leva 5ms, são menos de um minuto.

Quão ruim é o bug se o perdermos? Um bug de formatação em uma mensagem de log não é o mesmo que um bug de corrupção de dados em um pipeline de pagamentos.

Quão rara é a condição de disparo? Se o bug só aparece em anos bissextos, ou quando dois UUIDs colidem, ou exatamente em INT_MAX, você precisa de mais execuções ou de um gerador mais inteligente.

Geradores mais inteligentes quase sempre vencem mais execuções. Se você está testando um parser JSON, não gere strings aleatórias e espere que façam parse. Gere objetos válidos e depois os mutacion.

from hypothesis import given, settings, strategies as st
import json

# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
    try:
        json.loads(s)
    except json.JSONDecodeError:
        pass  # Most inputs hit this immediately

# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
    assert json.loads(json.dumps(d)) == d

500 execuções com um bom gerador vencem 10.000 execuções com um ruim. Sempre.

Perguntas comuns sobre dimensionar testes baseados em propriedades

Mais execuções não significam sempre melhor coverage?

Não exatamente. Property-based testing não tem uma metric de coverage no sentido tradicional. Mais execuções aumentam a probabilidade de encontrar um bug, mas retornos decrescentes aparecem rapidamente. Dobrar de 100 para 200 execuções é significativo. Dobrar de 10.000 para 20.000 raramente é.

E quanto a fuzzing? Não é apenas property-based testing com milhões de execuções?

Fuzzing é adjacente, mas diferente. Fuzzers tipicamente executam milhões de inputs sem entendimento semântico do domínio. Property-based testing usa geradores estruturados e shrinking. Você pode pensar em PBT como fuzzing inteligente, ou fuzzing como PBT de força bruta. O cálculo de quantidade de execuções é diferente porque o custo por execução e a informação por execução não são os mesmos.

Devo definir max_examples mais alto para CI ou mais baixo?

Mais alto para CI, mais baixo para desenvolvimento local. Seu laptop é para velocidade. Seu CI é para confiança. Use um profile de settings ou variável de ambiente para alternar entre eles.

import os
from hypothesis import settings

CI = os.environ.get("CI", "false").lower() == "true"

settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)

settings.load_profile("ci" if CI else "dev")

Como sei se meu gerador é bom o suficiente?

Execute seu teste com max_examples definido bem alto, digamos 50.000, e observe o relatório de coverage. Se branches estão faltando, seu gerador não está exercitando-os. Corrija o gerador antes de reduzir a quantidade de execuções.

Pare de procurar a quantidade de execuções perfeita e comece a medir

Não existe um número universal de execuções de teste para property-based testing. Só existe o número certo para sua propriedade, seus geradores, seu orçamento de CI, e o custo do bug que você está tentando prevenir.

Comece com 100 se precisar. Mas aumente para propriedades que guardam caminhos críticos, e reduza para propriedades que são apenas sanity checks. Meça quanto tempo seus testes levam. Profile seus geradores. E lembre-se: um teste baseado em propriedades que passa 100 vezes não é prova. É apenas evidência.

Se você quer se aprofundar, a documentação do Hypothesis sobre estatísticas de teste e property-based testing direcionado vale a pena ler. A CLI do hypothesis pode mostrar exatamente em quais exemplos seus testes estão gastando tempo. Esse é o primeiro lugar a olhar quando você está decidindo se aumenta ou reduz o dial.