O pytest é um dos frameworks de teste mais populares da linguagem Python, conhecido por sua simplicidade, flexibilidade e poderosos recursos. Nesta aula, vamos colocar a mão na massa: você vai aprender a escrever testes eficientes usando asserções, criar fixtures reutilizáveis, parametrizar casos de teste e organizar seus testes de forma profissional. Ao final, você terá as ferramentas necessárias para incorporar o pytest no seu fluxo de desenvolvimento com confiança.

Vamos começar com uma visão geral do pytest e depois mergulhar em cada tópico com exemplos práticos. Se você já conhece o básico de testes unitários, esta aula vai te dar um passo além, mostrando como o pytest simplifica tarefas que em outros frameworks seriam mais complexas.

Asserções

No pytest, as asserções são feitas com a palavra-chave assert do Python, o que elimina a necessidade de métodos especiais como assertEqual ou assertTrue de outros frameworks. Isso torna o código de teste mais limpo e legível, pois usa a sintaxe nativa da linguagem.

Quando uma asserção falha, o pytest exibe uma mensagem detalhada mostrando os valores envolvidos, o que facilita a depuração. Além disso, o pytest possui um mecanismo de introspecção que mostra exatamente onde a comparação falhou, mesmo em expressões complexas.

# test_exemplo.py
def test_soma():
    assert 1 + 1 == 2

def test_string_contem():
    nome = "Python"
    assert "yth" in nome

def test_lista_ordenada():
    numeros = [3, 1, 2]
    assert numeros == sorted(numeros)

Você também pode adicionar uma mensagem personalizada após a asserção, separada por vírgula, para fornecer contexto adicional quando a falha ocorrer.

def test_valor_esperado():
    resultado = calcular_juros(1000, 0.05)
    assert resultado == 1050, f"Esperado 1050, mas obteve {resultado}"

O pytest também permite usar a função pytest.raises para verificar se uma exceção específica é lançada, o que é essencial para testar tratamentos de erro.

import pytest

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

def test_divisao_por_zero():
    with pytest.raises(ValueError):
        dividir(10, 0)

Fixtures

Fixtures são funções que fornecem dados ou recursos para os testes, promovendo a reutilização e a separação de configuração. No pytest, você define uma fixture com o decorador @pytest.fixture e a utiliza como parâmetro da função de teste.

Um dos grandes benefícios das fixtures é o controle de escopo: você pode definir se a fixture será executada uma vez por teste, uma vez por módulo, uma vez por sessão, etc. Isso ajuda a otimizar a execução quando há recursos caros, como conexões de banco de dados.

import pytest

@pytest.fixture
def usuario():
    # Configuração: cria um usuário de teste
    u = {"nome": "Ana", "email": "ana@example.com"}
    return u

def test_email_valido(usuario):
    assert "@" in usuario["email"]

def test_nome_nao_vazio(usuario):
    assert usuario["nome"] != ""

Fixtures também podem ser compostas, ou seja, uma fixture pode depender de outra. Basta declarar a dependência como parâmetro da fixture.

@pytest.fixture
def banco():
    # Simula conexão com banco
    return {"conectado": True}

@pytest.fixture
def usuario_com_banco(banco, usuario):
    # Combina dados do usuário com banco
    usuario["banco"] = banco
    return usuario

def test_usuario_tem_banco(usuario_com_banco):
    assert usuario_com_banco["banco"]["conectado"]

Para lidar com limpeza, você pode usar a palavra-chave yield dentro da fixture, separando a configuração da desmontagem. O código após o yield será executado após o teste, garantindo que os recursos sejam liberados.

@pytest.fixture
def arquivo_temporario(tmp_path):
    arquivo = tmp_path / "dados.txt"
    arquivo.write_text("conteúdo")
    yield arquivo
    # cleanup: apaga o arquivo (opcional, pois tmp_path é limpo automaticamente)

Parametrização

A parametrização permite executar o mesmo teste com diferentes conjuntos de dados, evitando duplicação de código. No pytest, usamos o decorador @pytest.mark.parametrize para passar múltiplos argumentos a uma função de teste.

Isso é extremamente útil para testar funções com várias entradas e saídas esperadas, aumentando a cobertura sem escrever vários testes quase idênticos.

import pytest

def soma(a, b):
    return a + b

@pytest.mark.parametrize("a,b,esperado", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_soma_parametrizado(a, b, esperado):
    assert soma(a, b) == esperado

Você também pode parametrizar múltiplos argumentos de uma só vez e até combinar com fixtures, tornando seus testes muito mais flexíveis.

@pytest.mark.parametrize("entrada", ["texto", "outra coisa", ""])
def test_tamanho(entrada):
    assert len(entrada) >= 0

Quando um teste parametrizado falha, o pytest identifica qual combinação de parâmetros causou a falha, exibindo os valores na mensagem de erro. Isso facilita a correção rápida.

Organização

Organizar os testes é fundamental para a manutenibilidade do projeto. O pytest segue uma convenção de descoberta de testes: ele procura por arquivos nomeados test_*.py ou *_test.py e funções que começam com test_. Você pode organizar seus testes em diretórios e módulos de forma lógica, refletindo a estrutura do código fonte.

Uma boa prática é colocar os testes em um diretório separado, como tests/, e usar arquivos de configuração como pytest.ini ou pyproject.toml para definir opções de execução, como caminhos de busca e marcadores.

# Estrutura de diretórios sugerida:
# meu_projeto/
# ├── meu_modulo.py
# ├── tests/
# │   ├── __init__.py
# │   ├── test_meu_modulo.py
# │   └── conftest.py

O arquivo conftest.py é especial: ele permite definir fixtures e hooks que estarão disponíveis para todos os testes do diretório e subdiretórios. É o lugar ideal para fixtures globais.

# tests/conftest.py
import pytest

@pytest.fixture
def dados_globais():
    return {"api_url": "https://api.example.com"}

Além disso, você pode usar marcadores para categorizar testes e executá-los seletivamente. Por exemplo, marcar testes como lentos ou de integração.

import pytest

@pytest.mark.slow
def test_processamento_pesado():
    # teste demorado
    pass

Para executar apenas os testes com um marcador específico, use pytest -m slow. Você precisa registrar os marcadores no arquivo de configuração para evitar avisos.

# pytest.ini
[pytest]
markers =
    slow: testes lentos

Boas Práticas e Observações Finais

Escrever testes com pytest é uma habilidade essencial para qualquer desenvolvedor Python. Algumas boas práticas incluem: manter os testes independentes entre si, usar fixtures para configurações comuns, nomear os testes de forma descritiva e rodar os testes com frequência durante o desenvolvimento. Lembre-se de que testes não são apenas para verificar se o código funciona, mas também para documentar o comportamento esperado e prevenir regressões.

Uma dica final: explore a documentação oficial e a comunidade do pytest, que é rica em exemplos e plugins úteis. Comece com testes simples e vá evoluindo conforme a complexidade do seu projeto.

Referências

Exercícios

  1. Crie uma função que receba uma lista de números e retorne a soma dos quadrados. Escreva testes usando asserções para verificar o resultado com pelo menos três entradas diferentes.
  2. ✓ Resposta:
    def soma_quadrados(numeros):
        return sum(x**2 for x in numeros)
    
    # test_soma_quadrados.py
    def test_soma_quadrados():
        assert soma_quadrados([1, 2, 3]) == 14
        assert soma_quadrados([0, 0, 0]) == 0
        assert soma_quadrados([-1, -2]) == 5
    
  3. Crie uma fixture que forneça uma lista de números de 1 a 10. Use essa fixture em dois testes: um que verifica se a soma é 55 e outro que verifica se o comprimento é 10.
  4. ✓ Resposta:
    import pytest
    
    @pytest.fixture
    def numeros():
        return list(range(1, 11))
    
    def test_soma(numeros):
        assert sum(numeros) == 55
    
    def test_tamanho(numeros):
        assert len(numeros) == 10
    
  5. Escreva uma função que valide se uma string é um email (contém pelo menos um '@' e um '.'). Parametrize o teste com pelo menos 4 casos: dois válidos e dois inválidos.
  6. ✓ Resposta:
    def email_valido(email):
        return "@" in email and "." in email
    
    import pytest
    
    @pytest.mark.parametrize("email,esperado", [
        ("user@example.com", True),
        ("nome.sobrenome@dominio.org", True),
        ("usuario", False),
        ("@semdominio", False),
    ])
    def test_email_valido(email, esperado):
        assert email_valido(email) == esperado
    
  7. Crie uma estrutura de diretórios para um projeto chamado meu_projeto com um módulo calculadora.py e testes em tests/test_calculadora.py. Escreva um teste para a função multiplicar.
  8. ✓ Resposta: Estrutura:
    meu_projeto/
    ├── calculadora.py
    └── tests/
        ├── __init__.py
        └── test_calculadora.py
    

    calculadora.py:
    def multiplicar(a, b):
        return a * b
    
    test_calculadora.py:
    from calculadora import multiplicar
    
    def test_multiplicar():
        assert multiplicar(3, 4) == 12
    
  9. Utilize o marcador @pytest.mark.slow para marcar um teste que simule uma operação demorada (por exemplo, time.sleep(0.1)). Escreva o teste e a configuração necessária no pytest.ini para registrar o marcador.
  10. ✓ Resposta:
    import time
    import pytest
    
    @pytest.mark.slow
    def test_operacao_demorada():
        time.sleep(0.1)
        assert True
    
    pytest.ini:
    [pytest]
    markers =
        slow: testes lentos