O Problema Tradicional dos Testes Unitários
Quando começamos a escrever testes, geralmente fazemos algo assim: criamos um caso de teste para entrada específica, verificamos a saída esperada e marcamos como "resolvido". Porém, essa abordagem tem um problema fundamental. Um teste de unidade tradicional valida apenas os casos que você pensou em testar. Se um bug existe em uma combinação de entrada que você nunca considerou, ele passará despercebido em produção.
Imagine um algoritmo de ordenação que funciona perfeitamente para listas com 10 elementos, mas falha misteriosamente com 1.000. Ou uma função de validação de email que passa em todos os seus testes, mas quebra com domínios internacionalizados. O problema é que testes manuais não escalam cognitivamente. Você não pode prever todos os casos de uso possíveis, e é exatamente aqui que entram os testes parametrizados e o property-based testing.
Testes Parametrizados: Reutilizando Lógica de Teste
O Conceito Fundamental
Testes parametrizados permitem que você execute a mesma lógica de teste com múltiplos conjuntos de dados. Em vez de escrever dez funções de teste idênticas com valores diferentes, você escreve uma única função e passa diferentes parâmetros para ela. Isso reduz duplicação, melhora manutenibilidade e deixa claro quais casos você está validando.
Em Python, a biblioteca pytest oferece suporte nativo a testes parametrizados através do decorator @pytest.mark.parametrize. Vamos ver um exemplo prático:
import pytest
def calcular_desconto(preco, categoria):
"""Calcula desconto baseado na categoria do produto."""
descontos = {
'premium': 0.20,
'padrão': 0.10,
'basico': 0.05
}
desconto_percentual = descontos.get(categoria, 0)
return preco * (1 - desconto_percentual)
@pytest.mark.parametrize("preco,categoria,esperado", [
(100, 'premium', 80),
(100, 'padrão', 90),
(100, 'basico', 95),
(50, 'premium', 40),
(200, 'padrão', 180),
])
def test_calcular_desconto(preco, categoria, esperado):
assert calcular_desconto(preco, categoria) == esperado
Quando você executa pytest, ele roda a função test_calcular_desconto cinco vezes, uma para cada conjunto de parâmetros. Se um teste falhar, você vê exatamente qual combinação causou o problema. Isso é muito mais poderoso que ter cinco funções separadas porque você consegue identificar padrões nas falhas.
Parametrização Avançada
Você pode parametrizar múltiplas variáveis simultaneamente e até combinar parametrizações. Aqui está um exemplo com uma estrutura um pouco mais complexa:
import pytest
def validar_senha(senha):
"""Valida se uma senha atende aos critérios de segurança."""
if len(senha) < 8:
return False, "Senha muito curta"
if not any(c.isupper() for c in senha):
return False, "Falta letra maiúscula"
if not any(c.isdigit() for c in senha):
return False, "Falta dígito"
return True, "Válida"
@pytest.mark.parametrize("senha,valida,motivo", [
("Senha123", True, "Válida"),
("senha123", False, "Falta letra maiúscula"),
("SENHA123", False, "Falta letra minúscula"),
("Senha", False, "Senha muito curta"),
("Abc12345", True, "Válida"),
("ABCD1234", False, "Falta letra minúscula"),
])
def test_validar_senha(senha, valida, motivo):
resultado, mensagem = validar_senha(senha)
assert resultado == valida
if not valida:
assert motivo in mensagem
Este exemplo mostra casos de sucesso e falha, permitindo que você documente explicitamente o que espera de cada cenário. A parametrização deixa evidente quais comportamentos você validou e qual é o motivo de cada teste.
Property-Based Testing com Hypothesis
Por Que Property-Based Testing É Diferente
Property-based testing inverte a lógica tradicional. Em vez de você decidir quais dados testar, você define propriedades que devem ser verdadeiras para qualquer entrada válida, e a ferramenta gera centenas ou milhares de entradas automaticamente para encontrar contraexemplos. É como ter um QA muito persistente testando sua função com dados aleatórios 24/7.
A biblioteca Hypothesis em Python é a implementação mais madura de property-based testing. Ela não gera dados completamente aleatórios — ela é inteligente. Quando encontra um caso que falha, ela reduz esse caso para encontrar o mínimo exemplo que ainda causa o problema. Isso facilita enormemente o debug.
Conceito de Propriedade
Uma propriedade é uma afirmação que deve ser verdadeira para toda entrada válida, independentemente dos dados específicos. Vamos começar com um exemplo simples:
from hypothesis import given, strategies as st
def adicionar(a, b):
"""Adiciona dois números."""
return a + b
# Propriedade: adição é comutativa
@given(st.integers(), st.integers())
def test_adicao_comutativa(a, b):
assert adicionar(a, b) == adicionar(b, a)
# Propriedade: adicionar zero não muda o valor
@given(st.integers())
def test_adicao_identidade(a):
assert adicionar(a, 0) == a
# Propriedade: adição é associativa
@given(st.integers(), st.integers(), st.integers())
def test_adicao_associativa(a, b, c):
assert adicionar(adicionar(a, b), c) == adicionar(a, adicionar(b, c))
Este é um exemplo com operações matemáticas básicas, mas as propriedades são claras: não importa quais inteiros você passe, essas propriedades sempre devem ser verdadeiras. O Hypothesis tentará quebrar essas propriedades com valores extremos, negativos, zeros e combinações estranhas.
Estratégias (Strategies) do Hypothesis
As estratégias definem qual tipo de dado será gerado. O Hypothesis já vem com muitas estratégias prontas:
from hypothesis import given, strategies as st
# Estratégia básica para inteiros
@given(st.integers(min_value=0, max_value=100))
def test_com_inteiros_limitados(x):
assert 0 <= x <= 100
# Estratégia para texto
@given(st.text())
def test_com_texto(texto):
# Qualquer string deve ser convertida para string novamente sem erros
assert isinstance(str(texto), str)
# Estratégia para listas
@given(st.lists(st.integers()))
def test_com_listas(numeros):
# Uma lista sempre tem um tamanho definido
assert len(numeros) >= 0
# Estratégia combinada
@given(
st.lists(st.integers(min_value=1, max_value=100), min_size=1),
)
def test_soma_lista_positiva(numeros):
# A soma de números positivos é sempre positiva
assert sum(numeros) > 0
# Estratégia customizada
@given(st.emails())
def test_formato_email(email):
# Um email deve conter um @
assert '@' in email
O Hypothesis oferece estratégias para praticamente qualquer tipo: datas, horas, uuids, decimais, floats, dicionários, e muito mais. Você também pode combinar estratégias para criar estruturas de dados complexas.
Encontrando Bugs Reais com Hypothesis
Vamos ver um exemplo onde Hypothesis encontra um bug que testes tradicionais perderiam:
from hypothesis import given, strategies as st
def buscar_indice(lista, valor):
"""Encontra o índice de um valor em uma lista. Bugado!"""
for i in range(len(lista)):
if lista[i] == valor:
return i
return -1
# Teste tradicional (passa)
def test_buscar_indice_manual():
assert buscar_indice([1, 2, 3], 2) == 1
assert buscar_indice([1, 2, 3], 5) == -1
# Propriedade com Hypothesis
@given(
st.lists(st.integers()).filter(lambda x: len(x) > 0),
st.integers()
)
def test_buscar_indice_property(lista, valor):
indice = buscar_indice(lista, valor)
# Se encontrou, o índice deve ser válido e apontar para o valor
if indice != -1:
assert 0 <= indice < len(lista)
assert lista[indice] == valor
else:
# Se não encontrou, o valor não deve estar em lugar nenhum
assert valor not in lista
Se adicionássemos um bug à função (como if lista[i] == valor or True: return i), o teste tradicional ainda passaria, mas Hypothesis encontraria rapidamente o problema ao gerar casos onde nenhum valor deveria ser encontrado.
Usando @given com Múltiplas Estratégias
Um exemplo mais realista com validação de dados:
from hypothesis import given, strategies as st, assume
from datetime import datetime
def processar_pedido(id_cliente, valor, data_pedido):
"""Processa um pedido com validações."""
if valor < 0:
raise ValueError("Valor não pode ser negativo")
if datetime.fromisoformat(data_pedido) > datetime.now():
raise ValueError("Data não pode ser no futuro")
return {
'cliente_id': id_cliente,
'valor': valor,
'data': data_pedido,
'processado': True
}
@given(
id_cliente=st.integers(min_value=1, max_value=999999),
valor=st.floats(min_value=0.01, max_value=10000, allow_nan=False),
data_pedido=st.datetimes(max_value=datetime.now())
)
def test_processar_pedido_valido(id_cliente, valor, data_pedido):
resultado = processar_pedido(id_cliente, valor, data_pedido.isoformat())
assert resultado['processado'] is True
assert resultado['cliente_id'] == id_cliente
assert resultado['valor'] == valor
Este teste valida que a função processa corretamente qualquer combinação razoável de dados. Note o uso de allow_nan=False para evitar valores não-numéricos que causariam erro.
Redução de Exemplos com Hypothesis
Quando Hypothesis encontra uma falha, ele reduz automaticamente o exemplo para o caso mínimo:
from hypothesis import given, strategies as st
def contar_pares(numeros):
"""Conta números pares em uma lista. Bugado para listas grandes!"""
pares = [x for x in numeros if x % 2 == 0]
if len(pares) > 10: # BUG: essa condição está errada
return -1
return len(pares)
@given(st.lists(st.integers(), min_size=5, max_size=100))
def test_contar_pares(numeros):
resultado = contar_pares(numeros)
# O resultado nunca deve ser negativo
assert resultado >= 0
Quando este teste falhar, Hypothesis não vai reportar uma lista aleatória gigante. Ele vai reduzir para algo como [0, 2, 4, 6, 8, 10, 12] — a menor lista que reproduz o problema.
Combinando Testes Parametrizados e Property-Based Testing
Quando Usar Cada Um
Testes parametrizados são ideais para validar casos específicos e conhecidos — casos de negócio, edge cases documentados, ou comportamentos que você quer garantir que funcionam. Property-based testing é melhor para validar propriedades matemáticas ou invariantes que devem ser verdadeiras para qualquer entrada válida.
A melhor estratégia é usar ambos. Veja um exemplo prático:
from hypothesis import given, strategies as st
import pytest
def aplicar_taxa_imposto(valor, codigo_estado):
"""Aplica taxa de imposto baseada no estado."""
taxas = {
'SP': 0.18,
'RJ': 0.20,
'MG': 0.15,
'RS': 0.17,
}
if codigo_estado not in taxas:
raise ValueError(f"Estado desconhecido: {codigo_estado}")
taxa = taxas[codigo_estado]
return valor * (1 + taxa)
# Testes parametrizados para casos de negócio conhecidos
@pytest.mark.parametrize("valor,estado,esperado", [
(100, 'SP', 118),
(100, 'RJ', 120),
(100, 'MG', 115),
(1000, 'SP', 1180),
(250.50, 'RJ', 300.60), # Teste com decimal
])
def test_aplicar_taxa_casos_conhecidos(valor, estado, esperado):
resultado = aplicar_imposto(valor, estado)
assert abs(resultado - esperado) < 0.01 # Tolerância para floats
# Property-based testing para garantir invariantes
@given(
valor=st.floats(min_value=0.01, max_value=100000, allow_nan=False, allow_infinity=False),
estado=st.sampled_from(['SP', 'RJ', 'MG', 'RS'])
)
def test_aplicar_taxa_sempre_aumenta_valor(valor, estado):
resultado = aplicar_imposto(valor, estado)
# O resultado sempre deve ser maior que o valor original (taxa sempre positiva)
assert resultado > valor
# E deve estar em um intervalo razoável
assert resultado < valor * 1.25 # Taxa máxima é 20%
def test_aplicar_taxa_estado_invalido():
with pytest.raises(ValueError):
aplicar_imposto(100, 'XX')
Este exemplo mostra o melhor dos dois mundos: você valida casos de negócio específicos com parametrização, e garante propriedades gerais com property-based testing.
Exemplo Completo: Validador de URL
Vamos construir um validador de URL e testá-lo com ambas as técnicas:
from hypothesis import given, strategies as st
import pytest
from urllib.parse import urlparse
def validar_url(url):
"""Valida se uma string é uma URL bem-formada."""
try:
resultado = urlparse(url)
# Deve ter esquema e netloc (domínio)
return all([resultado.scheme, resultado.netloc])
except Exception:
return False
# Casos parametrizados conhecidos
@pytest.mark.parametrize("url,valida", [
("https://www.google.com", True),
("http://localhost:8000", True),
("ftp://files.example.com/arquivo.zip", True),
("não é url", False),
("", False),
("http://", False), # Falta host
])
def test_validar_url_casos_conhecidos(url, valida):
assert validar_url(url) == valida
# Properties: URLs válidas sempre têm certos componentes
@given(
esquema=st.sampled_from(['http', 'https', 'ftp']),
dominio=st.domains(),
caminho=st.text(alphabet='abcdefghijklmnopqrstuvwxyz0123456789/')
)
def test_url_valida_construida_tem_esquema_e_dominio(esquema, dominio, caminho):
url = f"{esquema}://{dominio}/{caminho}"
# URLs que construímos assim sempre devem ser válidas
assert validar_url(url) is True
Conclusão
Os três pontos principais que você deve levar desta aula são: primeiro, testes parametrizados eliminam duplicação de código de teste e deixam claro quais cenários você validou — use @pytest.mark.parametrize para casos conhecidos e específicos do seu negócio. Segundo, property-based testing com Hypothesis inverte o paradigma, deixando a ferramenta gerar entradas para você e focando em propriedades invariantes — isso encontra bugs que testes manuais perdem. Terceiro, a combinação de ambas as abordagens é mais poderosa que cada uma isoladamente: parametrização para casos determinísticos, Hypothesis para garantias matemáticas.