Como usar pytest parametrize para cobrir múltiplos casos de teste

1. Introdução ao pytest parametrize: por que usar?

Escrever testes unitários repetitivos é um dos maiores pesadelos da manutenção de software. Imagine precisar testar uma função soma(a, b) com 20 combinações diferentes de entrada. Sem parametrização, você criaria 20 funções de teste quase idênticas, violando o princípio DRY (Don't Repeat Yourself) e tornando o código difícil de manter.

O @pytest.mark.parametrize resolve esse problema ao permitir que você execute o mesmo teste com múltiplos conjuntos de argumentos. Com um único decorator, você define os dados de entrada e o pytest gera automaticamente um caso de teste para cada combinação.

Benefícios principais:
- Cobertura ampla: teste centenas de combinações com poucas linhas
- Legibilidade: os dados de teste ficam claramente separados da lógica
- Facilidade de manutenção: adicionar ou remover casos é trivial

2. Sintaxe básica do decorator parametrize

A estrutura fundamental do parametrize é:

@pytest.mark.parametrize("arg1, arg2", [
    (valor1_a, valor2_a),
    (valor1_b, valor2_b)
])

Vamos testar uma função simples de soma:

# calculadora.py
def soma(a, b):
    return a + b

# test_calculadora.py
import pytest
from calculadora import soma

@pytest.mark.parametrize("a, b, esperado", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, -50, 50),
    (2.5, 3.5, 6.0)
])
def test_soma(a, b, esperado):
    assert soma(a, b) == esperado

Quando executamos pytest -v, vemos cinco testes separados, cada um nomeado com os valores dos parâmetros. Se um falhar, sabemos exatamente qual combinação causou o problema.

3. Combinando múltiplos decorators parametrize

Você pode empilhar vários decorators parametrize no mesmo teste. O pytest cria o produto cartesiano entre todos os argumentos:

@pytest.mark.parametrize("operacao", ["soma", "subtracao"])
@pytest.mark.parametrize("a, b", [
    (10, 5),
    (0, 1),
    (-3, -7)
])
def test_operacoes(operacao, a, b):
    if operacao == "soma":
        resultado = a + b
    else:
        resultado = a - b
    # lógica de teste...

Isso gera 6 testes (2 operações × 3 pares de valores). Cuidado com a explosão combinatória: se você adicionar muitos parâmetros, o número de testes cresce exponencialmente, podendo impactar o tempo de execução.

4. Trabalhando com fixtures e parametrize juntos

Fixtures podem ser combinadas com parametrize para criar testes ainda mais flexíveis. Exemplo com configurações de banco de dados:

import pytest
import sqlite3

@pytest.fixture(params=[
    {"db": ":memory:", "timeout": 5},
    {"db": "test.db", "timeout": 10}
])
def config_conexao(request):
    return request.param

@pytest.mark.parametrize("query, esperado", [
    ("SELECT 1", [(1,)]),
    ("SELECT 2+2", [(4,)])
])
def test_conexao_banco(config_conexao, query, esperado):
    conn = sqlite3.connect(**config_conexao)
    cursor = conn.cursor()
    cursor.execute(query)
    resultado = cursor.fetchall()
    assert resultado == esperado
    conn.close()

Aqui, cada configuração de banco é combinada com cada consulta SQL, gerando 4 testes no total.

5. Cobertura de casos de borda e exceções

Testar exceções é crucial. Use pytest.raises dentro de testes parametrizados:

import pytest

def dividir(a, b):
    if b == 0:
        raise ValueError("Divisão por zero")
    return a / b

@pytest.mark.parametrize("a, b, esperado_ou_excecao", [
    (10, 2, 5.0),
    (0, 5, 0.0),
    (-6, 3, -2.0),
    (1, 0, ValueError),
    (None, 2, TypeError)
])
def test_dividir(a, b, esperado_ou_excecao):
    if esperado_ou_excecao in (ValueError, TypeError):
        with pytest.raises(esperado_ou_excecao):
            dividir(a, b)
    else:
        assert dividir(a, b) == esperado_ou_excecao

Inclua sempre casos de borda: valores nulos, vazios, negativos, extremos (máximo inteiro, mínimo float). Organize os dados em tuplas nomeadas ou dicionários para melhor legibilidade quando houver muitos parâmetros.

6. Boas práticas e dicas avançadas

Nomeando casos com ids:

@pytest.mark.parametrize("a, b, esperado", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0)
], ids=[
    "positivos_simples",
    "zeros",
    "opostos"
])
def test_soma(a, b, esperado):
    assert soma(a, b) == esperado

Os ids aparecem nos relatórios de teste, facilitando a identificação de falhas.

Usando dicionários como parâmetros:

CASOS_TESTE = [
    {"a": 10, "b": 5, "esperado": 15, "descricao": "soma_de_dois_positivos"},
    {"a": -3, "b": 7, "esperado": 4, "descricao": "soma_com_negativo"}
]

@pytest.mark.parametrize("caso", CASOS_TESTE, ids=[c["descricao"] for c in CASOS_TESTE])
def test_soma_dict(caso):
    assert soma(caso["a"], caso["b"]) == caso["esperado"]

Evite mutabilidade: nunca use listas ou dicionários mutáveis como valores padrão em parametrize, pois eles podem ser alterados entre execuções. Prefira tuplas imutáveis.

7. Exemplo completo: testando uma calculadora com parametrize

Vamos construir uma bateria de testes para operações matemáticas:

# calculadora.py
def soma(a, b): return a + b
def subtracao(a, b): return a - b
def multiplicacao(a, b): return a * b
def divisao(a, b):
    if b == 0:
        raise ValueError("Divisão por zero")
    return a / b
def potencia(a, b): return a ** b

# test_calculadora.py
import pytest
from calculadora import soma, subtracao, multiplicacao, divisao, potencia

OPERACOES = {
    "soma": soma,
    "subtracao": subtracao,
    "multiplicacao": multiplicacao,
    "potencia": potencia
}

CASOS_PADRAO = [
    (2, 3, 6, "positivos"),
    (0, 5, 0, "zero_e_positivo"),
    (-2, 4, -8, "negativo_e_positivo"),
    (-3, -2, 6, "dois_negativos")
]

@pytest.mark.parametrize("operacao_nome, func", OPERACOES.items())
@pytest.mark.parametrize("a, b, esperado, nome_caso", CASOS_PADRAO)
def test_operacoes_padrao(operacao_nome, func, a, b, esperado, nome_caso):
    resultado = func(a, b)
    assert resultado == esperado, f"Falhou em {operacao_nome} - {nome_caso}"

@pytest.mark.parametrize("a, b", [
    (10, 0),
    (0, 0),
    (-5, 0)
], ids=["positivo_div_zero", "zero_div_zero", "negativo_div_zero"])
def test_divisao_por_zero(a, b):
    with pytest.raises(ValueError):
        divisao(a, b)

Este exemplo cobre 16 combinações (4 operações × 4 casos) para operações padrão, mais 3 casos de divisão por zero. Integre com linters como pytest-flake8 e pytest-black para garantir consistência de código.

8. Conclusão: como parametrize se encaixa no pipeline de qualidade

O pytest parametrize é uma ferramenta essencial no pipeline de qualidade de software. Ele se conecta diretamente com:

  • Cobertura de código: ferramentas como pytest-cov mostram quais linhas são executadas em cada caso parametrizado
  • Análise estática: linters detectam testes parametrizados mal escritos (parâmetros não utilizados, nomes inconsistentes)
  • TDD (Test-Driven Development): parametrize acelera o ciclo vermelho-verde-refatoração, permitindo adicionar rapidamente novos casos conforme descobre bordas

Para ir além, explore plugins como:
- pytest-xdist para executar testes parametrizados em paralelo
- pytest-randomly para embaralhar a ordem de execução
- pytest-benchmark para medir desempenho de diferentes combinações

Com parametrize, você transforma testes repetitivos em dados declarativos, ganhando tempo e confiança na qualidade do código.

Referências