Padrões de Teste em Python
Estratégias abrangentes de teste para aplicações Python usando pytest, metodologia TDD e melhores práticas.
Quando Ativar
- Escrevendo novo código Python (siga TDD: vermelho, verde, refatorar)
- Projetando suítes de teste para projetos Python
- Revisando cobertura de teste em Python
- Configurando infraestrutura de teste
Filosofia Central de Teste
Desenvolvimento Orientado a Testes (TDD)
Sempre siga o ciclo TDD:
- VERMELHO: Escreva um teste que falha para o comportamento desejado
- VERDE: Escreva o código mínimo para fazer o teste passar
- REFATORAR: Melhore o código mantendo os testes verdes
# Passo 1: Escreva um teste que falha (VERMELHO)
def test_somar_numeros():
resultado = somar(2, 3)
assert resultado == 5
# Passo 2: Escreva a implementação mínima (VERDE)
def somar(a, b):
return a + b
# Passo 3: Refatore se necessário (REFATORAR)
Requisitos de Cobertura
- Alvo: 80%+ de cobertura de código
- Caminhos críticos: 100% de cobertura obrigatória
- Use
pytest --covpara medir a cobertura
pytest --cov=meupacote --cov-report=term-missing --cov-report=html
Fundamentos do pytest
Estrutura Básica de Teste
import pytest
def test_adicao():
"""Testa adição básica."""
assert 2 + 2 == 4
def test_string_maiuscula():
"""Testa conversão para maiúsculas."""
texto = "ola"
assert texto.upper() == "OLA"
def test_lista_append():
"""Testa append em lista."""
itens = [1, 2, 3]
itens.append(4)
assert 4 in itens
assert len(itens) == 4
Asserções
# Igualdade
assert resultado == esperado
# Desigualdade
assert resultado != inesperado
# Valor verdadeiro/falso
assert resultado # Verdadeiro
assert not resultado # Falso
assert resultado is True # Exatamente True
assert resultado is False # Exatamente False
assert resultado is None # Exatamente None
# Pertencimento
assert item in colecao
assert item not in colecao
# Comparações
assert resultado > 0
assert 0 <= resultado <= 100
# Verificação de tipo
assert isinstance(resultado, str)
# Teste de exceção (abordagem preferida)
with pytest.raises(ValueError):
raise ValueError("mensagem de erro")
# Verificar mensagem da exceção
with pytest.raises(ValueError, match="entrada inválida"):
raise ValueError("entrada inválida fornecida")
# Verificar atributos da exceção
with pytest.raises(ValueError) as exc_info:
raise ValueError("mensagem de erro")
assert str(exc_info.value) == "mensagem de erro"
Fixtures
Uso Básico de Fixture
import pytest
@pytest.fixture
def dados_exemplo():
"""Fixture fornecendo dados de exemplo."""
return {"nome": "Alice", "idade": 30}
def test_dados_exemplo(dados_exemplo):
"""Teste usando a fixture."""
assert dados_exemplo["nome"] == "Alice"
assert dados_exemplo["idade"] == 30
Fixture com Setup/Teardown
@pytest.fixture
def banco_dados():
"""Fixture com setup e teardown."""
# Setup
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # Fornece ao teste
# Teardown
db.close()
def test_consulta_banco_dados(banco_dados):
"""Testa operações no banco de dados."""
resultado = banco_dados.query("SELECT * FROM usuarios")
assert len(resultado) > 0
Escopos de Fixture
# Escopo de função (padrão) - executa para cada teste
@pytest.fixture
def arquivo_temporario():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# Escopo de módulo - executa uma vez por módulo
@pytest.fixture(scope="module")
def banco_modulo():
db = Database(":memory:")
db.create_tables()
yield db
db.close()
# Escopo de sessão - executa uma vez por sessão de teste
@pytest.fixture(scope="session")
def recurso_compartilhado():
resource = ExpensiveResource()
yield resource
resource.cleanup()
Fixture com Parâmetros
@pytest.fixture(params=[1, 2, 3])
def numero(request):
"""Fixture parametrizada."""
return request.param
def test_numeros(numero):
"""Teste executa 3 vezes, uma para cada parâmetro."""
assert numero > 0
Usando Múltiplas Fixtures
@pytest.fixture
def usuario():
return User(id=1, name="Alice")
@pytest.fixture
def admin():
return User(id=2, name="Admin", role="admin")
def test_interacao_usuario_admin(usuario, admin):
"""Teste usando múltiplas fixtures."""
assert admin.can_manage(usuario)
Fixtures Autouse
@pytest.fixture(autouse=True)
def resetar_config():
"""Executa automaticamente antes de cada teste."""
Config.reset()
yield
Config.cleanup()
def test_sem_chamar_fixture():
# reset_config é executado automaticamente
assert Config.get_setting("debug") is False
Conftest.py para Fixtures Compartilhadas
# tests/conftest.py
import pytest
@pytest.fixture
def cliente():
"""Fixture compartilhada para todos os testes."""
app = create_app(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def cabecalhos_auth(cliente):
"""Gera cabeçalhos de autenticação para teste de API."""
response = cliente.post("/api/login", json={
"username": "teste",
"password": "teste"
})
token = response.json["token"]
return {"Authorization": f"Bearer {token}"}
Parametrização
Parametrização Básica
@pytest.mark.parametrize("entrada,esperado", [
("ola", "OLA"),
("mundo", "MUNDO"),
("PyThOn", "PYTHON"),
])
def test_maiusculas(entrada, esperado):
"""Teste executa 3 vezes com diferentes entradas."""
assert entrada.upper() == esperado
Múltiplos Parâmetros
@pytest.mark.parametrize("a,b,esperado", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_somar(a, b, esperado):
"""Testa soma com múltiplas entradas."""
assert somar(a, b) == esperado
Parametrização com IDs
@pytest.mark.parametrize("entrada,esperado", [
("[email protected]", True),
("invalido", False),
("@sem-dominio.com", False),
], ids=["email-valido", "faltando-arroba", "faltando-dominio"])
def test_validacao_email(entrada, esperado):
"""Testa validação de e-mail com IDs de teste legíveis."""
assert is_valid_email(entrada) is esperado
Fixtures Parametrizadas
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""Testa com múltiplos bancos de dados."""
if request.param == "sqlite":
return Database(":memory:")
elif request.param == "postgresql":
return Database("postgresql://localhost/teste")
elif request.param == "mysql":
return Database("mysql://localhost/teste")
def test_operacoes_banco_dados(db):
"""Teste executa 3 vezes, uma para cada banco."""
resultado = db.query("SELECT 1")
assert resultado is not None
Marcadores e Seleção de Testes
Marcadores Personalizados
# Marcar testes lentos
@pytest.mark.slow
def test_operacao_lenta():
time.sleep(5)
# Marcar testes de integração
@pytest.mark.integration
def test_integracao_api():
response = requests.get("https://api.exemplo.com")
assert response.status_code == 200
# Marcar testes unitários
@pytest.mark.unit
def test_logica_unitaria():
assert calcular(2, 3) == 5
Executar Testes Específicos
# Executar apenas testes rápidos
pytest -m "not slow"
# Executar apenas testes de integração
pytest -m integration
# Executar testes de integração ou lentos
pytest -m "integration or slow"
# Executar testes marcados como unitários, mas não lentos
pytest -m "unit and not slow"
Configurar Marcadores no pytest.ini
[pytest]
markers =
slow: marca testes como lentos
integration: marca testes como testes de integração
unit: marca testes como testes unitários
django: marca testes como requerendo Django
Mocking e Patching
Mockando Funções
from unittest.mock import patch, Mock
@patch("meupacote.chamada_api_externa")
def test_com_mock(chamada_mock):
"""Testa com API externa simulada."""
chamada_mock.return_value = {"status": "sucesso"}
resultado = minha_funcao()
chamada_mock.assert_called_once()
assert resultado["status"] == "sucesso"
Mockando Valores de Retorno
@patch("meupacote.Database.connect")
def test_conexao_banco_dados(connect_mock):
"""Testa com conexão de banco de dados simulada."""
connect_mock.return_value = MockConnection()
db = Database()
db.connect()
connect_mock.assert_called_once_with("localhost")
Mockando Exceções
@patch("meupacote.chamada_api")
def test_tratamento_erro_api(chamada_mock):
"""Testa tratamento de erro com exceção simulada."""
chamada_mock.side_effect = ConnectionError("Erro de rede")
with pytest.raises(ConnectionError):
chamada_api()
chamada_mock.assert_called_once()
Mockando Gerenciadores de Contexto
@patch("builtins.open", new_callable=mock_open)
def test_leitura_arquivo(arquivo_mock):
"""Testa leitura de arquivo com open simulado."""
arquivo_mock.return_value.read.return_value = "conteúdo do arquivo"
resultado = ler_arquivo("teste.txt")
arquivo_mock.assert_called_once_with("teste.txt", "r")
assert resultado == "conteúdo do arquivo"
Usando Autospec
@patch("meupacote.ConexaoBD", autospec=True)
def test_autospec(db_mock):
"""Testa com autospec para detectar mau uso da API."""
db = db_mock.return_value
db.query("SELECT * FROM usuarios")
# Isso falharia se ConexaoBD não tiver método query
db_mock.assert_called_once()
Mockando Instâncias de Classes
class TestServicoUsuario:
@patch("meupacote.RepositorioUsuario")
def test_criar_usuario(self, repo_mock):
"""Testa criação de usuário com repositório simulado."""
repo_mock.return_value.save.return_value = User(id=1, name="Alice")
servico = ServicoUsuario(repo_mock.return_value)
usuario = servico.criar_usuario(name="Alice")
assert usuario.name == "Alice"
repo_mock.return_value.save.assert_called_once()
Mockando Propriedades
@pytest.fixture
def config_mock():
"""Cria um mock com uma propriedade."""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="chave-teste")
return config
def test_com_config_mock(config_mock):
"""Testa com propriedades de configuração simuladas."""
assert config_mock.debug is True
assert config_mock.api_key == "chave-teste"
Testando Código Assíncrono
Testes Assíncronos com pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_funcao_assincrona():
"""Testa função assíncrona."""
resultado = await somar_assincrono(2, 3)
assert resultado == 5
@pytest.mark.asyncio
async def test_assincrono_com_fixture(cliente_assincrono):
"""Testa assíncrono com fixture assíncrona."""
response = await cliente_assincrono.get("/api/usuarios")
assert response.status_code == 200
Fixture Assíncrona
@pytest.fixture
async def cliente_assincrono():
"""Fixture assíncrona fornecendo cliente de teste assíncrono."""
app = create_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_endpoint_api(cliente_assincrono):
"""Testa usando fixture assíncrona."""
response = await cliente_assincrono.get("/api/dados")
assert response.status_code == 200
Mockando Funções Assíncronas
@pytest.mark.asyncio
@patch("meupacote.chamada_api_assincrona")
async def test_mock_assincrono(chamada_mock):
"""Testa função assíncrona com mock."""
chamada_mock.return_value = {"status": "ok"}
resultado = await minha_funcao_assincrona()
chamada_mock.assert_awaited_once()
assert resultado["status"] == "ok"
Testando Exceções
Testando Exceções Esperadas
def test_divisao_por_zero():
"""Testa que dividir por zero levanta ZeroDivisionError."""
with pytest.raises(ZeroDivisionError):
dividir(10, 0)
def test_excecao_personalizada():
"""Testa exceção personalizada com mensagem."""
with pytest.raises(ValueError, match="entrada inválida"):
validar_entrada("invalido")
Testando Atributos de Exceção
def test_excecao_com_detalhes():
"""Testa exceção com atributos personalizados."""
with pytest.raises(ErroPersonalizado) as exc_info:
raise ErroPersonalizado("erro", code=400)
assert exc_info.value.code == 400
assert "erro" in str(exc_info.value)
Testando Efeitos Colaterais
Testando Operações de Arquivo
import tempfile
import os
def test_processamento_arquivo():
"""Testa processamento de arquivo com arquivo temporário."""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("conteúdo de teste")
caminho_temp = f.name
try:
resultado = processar_arquivo(caminho_temp)
assert resultado == "processado: conteúdo de teste"
finally:
os.unlink(caminho_temp)
Testando com a Fixture tmp_path do pytest
def test_com_tmp_path(tmp_path):
"""Testa usando a fixture de caminho temporário embutida do pytest."""
arquivo_teste = tmp_path / "teste.txt"
arquivo_teste.write_text("olá mundo")
resultado = processar_arquivo(str(arquivo_teste))
assert resultado == "olá mundo"
# tmp_path é limpo automaticamente
Testando com Fixture tmpdir
def test_com_tmpdir(tmpdir):
"""Testa usando a fixture tmpdir do pytest."""
arquivo_teste = tmpdir.join("teste.txt")
arquivo_teste.write("dados")
resultado = processar_arquivo(str(arquivo_teste))
assert resultado == "dados"
Organização de Testes
Estrutura de Diretórios
tests/
├── conftest.py # Fixtures compartilhadas
├── __init__.py
├── unit/ # Testes unitários
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_utils.py
│ └── test_services.py
├── integration/ # Testes de integração
│ ├── __init__.py
│ ├── test_api.py
│ └── test_database.py
└── e2e/ # Testes ponta a ponta
├── __init__.py
└── test_fluxo_usuario.py
Classes de Teste
class TestServicoUsuario:
"""Agrupa testes relacionados em uma classe."""
@pytest.fixture(autouse=True)
def setup(self):
"""Setup é executado antes de cada teste nesta classe."""
self.servico = ServicoUsuario()
def test_criar_usuario(self):
"""Testa criação de usuário."""
usuario = self.servico.criar_usuario("Alice")
assert usuario.nome == "Alice"
def test_deletar_usuario(self):
"""Testa exclusão de usuário."""
usuario = self.servico.criar_usuario("Bob")
self.servico.deletar_usuario(usuario.id)
assert self.servico.buscar_usuario(usuario.id) is None
Ferramentas de Qualidade de Código
Configuração do .pylintrc para Testes
[MASTER]
ignore=tests
[MESSAGES CONTROL]
disable=missing-docstring,too-few-public-methods
Configuração do mypy para Testes
[mypy]
allow_redefinition = True
ignore_missing_imports = True
[mypy-tests.*]
ignore_errors = True
Padrões Avançados
Teste de Fábrica (Factory Pattern)
class FabricaUsuario:
"""Fábrica para criar usuários de teste."""
@staticmethod
def criar(nome="Alice", email="[email protected]"):
return User(nome=nome, email=email)
@staticmethod
def admin(nome="Admin", email="[email protected]"):
user = FabricaUsuario.criar(nome=nome, email=email)
user.role = "admin"
return user
def test_usuario_padrao():
"""Teste usando fábrica."""
usuario = FabricaUsuario.criar()
assert usuario.nome == "Alice"
def test_usuario_admin():
"""Teste usando fábrica para admin."""
admin = FabricaUsuario.admin()
assert admin.role == "admin"
Teste Parametrizado com Dados Externos
import json
@pytest.fixture
def dados_teste():
"""Carrega dados de teste de um arquivo JSON."""
with open("test_data.json", "r") as f:
return json.load(f)
def test_com_dados_externos(dados_teste):
"""Teste usando dados carregados externamente."""
for caso in dados_teste:
assert processar(caso["entrada"]) == caso["esperado"]
Teste com Skip Condicional
import sys
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requer Python 3.8 ou superior")
def test_recurso_moderno():
"""Testa recurso disponível apenas em Python 3.8+."""
# Código que usa recursos modernos
Plugins pytest Recomendados
pytest-cov: Medição de cobertura de códigopytest-xdist: Execução paralela de testespytest-mock: Mocking simplificadopytest-asyncio: Suporte a testes assíncronospytest-timeout: Timeout para testespytest-randomly: Ordem aleatória de execução
Integração Contínua
Exemplo de Configuração CI/CD (GitHub Actions)
name: Executar Testes
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configurar Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Instalar dependências
run: |
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Executar testes com cobertura
run: |
pytest tests/ --cov=meupacote --cov-report=xml --cov-report=html
- name: Enviar cobertura para Codecov
uses: codecov/codecov-action@v1
Boas Práticas
- Escreva testes primeiro: Siga TDD consistentemente
- Mantenha testes focados: Cada teste deve testar uma única coisa
- Use fixtures para setup: Evite duplicação em setup/teardown
- Prefira mocks a efeitos colaterais: Isole dependências externas
- Use parametrização: Reduza código repetitivo
- Mantenha testes legíveis: Nomes descritivos e documentação
- Execute testes com frequência: Integre ao seu fluxo de trabalho
- Monitore a cobertura: Alvo de 80%+, caminhos críticos em 100%