O pytest é um dos frameworks de teste mais populares da linguagem Python, conhecido por sua simplicidade e poderosos recursos. Nesta aula, vamos mergulhar em quatro pilares essenciais do pytest: asserções, fixtures, parametrização e organização de testes. Você verá como escrever testes claros e eficientes, como reutilizar código de preparação e limpeza, como testar múltiplos cenários com pouco código e como estruturar projetos de teste de forma profissional.

Se você já conhece o básico de testes com pytest, esta aula vai aprofundar seu conhecimento e apresentar boas práticas. Se está começando, não se preocupe: cada conceito será explicado do zero, com exemplos práticos que você pode rodar no seu ambiente.

Asserções

Em pytest, as asserções são feitas com a palavra-chave assert do Python, mas o pytest aprimora a saída de erro, mostrando detalhes sobre os valores comparados. Isso torna a depuração muito mais fácil. Por exemplo, se você compara duas listas, o pytest mostra exatamente onde elas diferem.

Além das asserções simples, o pytest oferece um módulo pytest.raises para verificar exceções e pytest.approx para comparações de ponto flutuante. Vamos ver cada um com exemplos.

# test_assertions.py
import pytest

def test_igualdade():
    assert 1 + 1 == 2

def test_pertence():
    assert 'a' in 'abc'

def test_excecao():
    with pytest.raises(ValueError):
        int('abc')

def test_aprox():
    assert 0.1 + 0.2 == pytest.approx(0.3)

Quando um teste falha, o pytest mostra uma mensagem detalhada. Por exemplo, no teste test_igualdade, se alterarmos para 1 + 1 == 3, a saída indicará que 2 não é igual a 3. Isso é muito mais informativo do que um simples False.

Fixtures

Fixtures são funções que fornecem dados ou recursos para os testes, evitando repetição e facilitando a limpeza. Elas são definidas com o decorador @pytest.fixture e podem ser injetadas nos testes como parâmetros. O pytest gerencia o ciclo de vida: a fixture é executada antes do teste e, se definida com yield, o código após o yield roda após o teste (teardown).

Um exemplo clássico é uma conexão com banco de dados ou um arquivo temporário. Vamos criar uma fixture que fornece uma lista de usuários.

# test_fixtures.py
import pytest

@pytest.fixture
usuario():
    return {'nome': 'Ana', 'idade': 30}

def test_maioridade(usuario):
    assert usuario['idade'] >= 18

def test_nome(usuario):
    assert 'Ana' in usuario['nome']

Também podemos definir escopos de fixture: function (padrão), class, module, session. Isso controla com que frequência a fixture é criada. Por exemplo, uma fixture de module é criada uma vez por módulo de teste, economizando tempo em operações caras.

@pytest.fixture(scope='module')
def dados():
    print('\nPreparando dados...')
    yield [1, 2, 3]
    print('\nLimpando...')

As fixtures também podem depender de outras fixtures, criando uma cadeia de preparação. Isso é poderoso para testes de integração.

Parametrização

A parametrização permite rodar o mesmo teste com diferentes entradas e saídas esperadas, usando o decorador @pytest.mark.parametrize. Isso evita duplicação de código e facilita a adição de novos casos.

Você pode passar uma lista de tuplas ou uma lista de dicionários. O pytest gera um teste para cada combinação, com IDs automáticos que podem ser personalizados.

# test_parametrize.py
import pytest

@pytest.mark.parametrize('entrada, esperado', [
    (2, 4),
    (3, 9),
    (4, 16),
])
def test_quadrado(entrada, esperado):
    assert entrada ** 2 == esperado

@pytest.mark.parametrize('texto, resultado', [
    ('', False),
    ('abc', True),
    ('123', False),
])
def test_tem_letra(texto, resultado):
    assert any(c.isalpha() for c in texto) == resultado

Você também pode combinar múltiplas parametrizações, gerando o produto cartesiano dos argumentos. Para casos mais complexos, use IDs para identificar cada teste na saída.

@pytest.mark.parametrize('x', [1, 2], ids=['um', 'dois'])
@pytest.mark.parametrize('y', [10, 20], ids=['dez', 'vinte'])
def test_soma(x, y):
    assert x + y > 0

Organização

Organizar testes é crucial para a manutenção do projeto. O pytest segue convenções: arquivos de teste devem começar com test_ ou terminar com _test.py, e funções de teste devem começar com test_. Normalmente, cria-se um diretório tests/ na raiz do projeto, com subpastas para cada módulo ou funcionalidade.

Além de organização de arquivos, é importante usar conftest.py para compartilhar fixtures entre vários arquivos de teste. O conftest.py é carregado automaticamente pelo pytest e pode conter fixtures, hooks e plugins.

# Estrutura de diretórios sugerida
projeto/
├── src/
│   └── meu_modulo.py
├── tests/
│   ├── conftest.py
│   ├── test_operacoes.py
│   └── test_utils/
│       ├── __init__.py
│       └── test_helpers.py
└── pyproject.toml

No conftest.py, você pode definir fixtures globais:

# tests/conftest.py
import pytest

@pytest.fixture
def cliente():
    return {'nome': 'Teste', 'email': 'teste@exemplo.com'}

Outra prática é usar marcadores para categorizar testes, como @pytest.mark.slow para testes demorados. Você pode executar apenas testes com um marcador específico usando -m.

pytest -m slow

Por fim, use a opção -k para filtrar por nome e --maxfail para parar após N falhas. Essas ferramentas ajudam a gerenciar grandes suítes de teste.

Boas Práticas e Observações Finais

Algumas boas práticas ao usar pytest: mantenha os testes independentes e isolados, não dependa de ordem de execução; use fixtures para preparação e limpeza; nomeie os testes de forma descritiva; e evite testes que testam implementação em vez de comportamento.

Além disso, aproveite os plugins do ecossistema, como pytest-cov para cobertura de código, pytest-mock para mocking e pytest-django para projetos Django. Esses plugins ampliam o poder do pytest e tornam o desenvolvimento mais produtivo.

Exercícios

  1. Escreva uma função par_ou_impar(n) que retorna 'par' ou 'ímpar'. Crie testes parametrizados para os valores 1, 2, 3, 4 e 5.
  2. ✓ Resposta:
    import pytest
    
    def par_ou_impar(n):
        return 'par' if n % 2 == 0 else 'ímpar'
    
    @pytest.mark.parametrize('n, esperado', [
        (1, 'ímpar'),
        (2, 'par'),
        (3, 'ímpar'),
        (4, 'par'),
        (5, 'ímpar'),
    ])
    def test_par_ou_impar(n, esperado):
        assert par_ou_impar(n) == esperado
    
  3. Crie uma fixture chamada lista_palavras que retorna uma lista com ['python', 'pytest', 'programação']. Use-a em dois testes: um que verifica o tamanho da lista e outro que verifica se 'pytest' está na lista.
  4. ✓ Resposta:
    import pytest
    
    @pytest.fixture
    def lista_palavras():
        return ['python', 'pytest', 'programação']
    
    def test_tamanho(lista_palavras):
        assert len(lista_palavras) == 3
    
    def test_contem_pytest(lista_palavras):
        assert 'pytest' in lista_palavras
    
  5. Escreva um teste que verifica se a função divide(a, b) levanta ZeroDivisionError quando b é zero. Use pytest.raises.
  6. ✓ Resposta:
    import pytest
    
    def divide(a, b):
        return a / b
    
    def test_divide_por_zero():
        with pytest.raises(ZeroDivisionError):
            divide(10, 0)
    
  7. Utilize a parametrização para testar uma função soma_lista(lista) que retorna a soma dos elementos. Teste com listas vazias, listas de positivos, listas de negativos e listas mistas.
  8. ✓ Resposta:
    import pytest
    
    def soma_lista(lista):
        return sum(lista)
    
    @pytest.mark.parametrize('lista, esperado', [
        ([], 0),
        ([1, 2, 3], 6),
        ([-1, -2, -3], -6),
        ([1, -2, 3], 2),
    ])
    def test_soma_lista(lista, esperado):
        assert soma_lista(lista) == esperado
    
  9. Crie um conftest.py com uma fixture usuario_admin que retorna um dicionário com 'nome': 'Admin' e 'admin': True. Escreva um teste em um arquivo separado que use essa fixture e verifique se o usuário é admin.
  10. ✓ Resposta: conftest.py:
    import pytest
    
    @pytest.fixture
    def usuario_admin():
        return {'nome': 'Admin', 'admin': True}
    
    test_admin.py:
    def test_usuario_admin(usuario_admin):
        assert usuario_admin['admin'] is True
    

Referências