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 testes Python
- Configurando infraestrutura de teste
Filosofia Central de Teste
Desenvolvimento Orientado a Testes (TDD)
Sempre siga o ciclo TDD:
- VERMELHO: Escreva um teste falho 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: Escrever teste falho (VERMELHO)
def test_add_numbers():
result = add(2, 3)
assert result == 5
# Passo 2: Escrever implementação mínima (VERDE)
def add(a, b):
return a + b
# Passo 3: Refatorar se necessário (REFATORAR)
Requisitos de Cobertura
- Meta: 80%+ de cobertura de código
- Caminhos críticos: 100% de cobertura exigida
- Use
pytest --covpara medir a cobertura
pytest --cov=mypackage --cov-report=term-missing --cov-report=html
Fundamentos de 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 de string 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
# Veracidade
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 # Fornecer ao teste
# Teardown
db.close()
def test_consulta_banco_dados(banco_dados):
"""Testa operações de 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():
recurso = RecursoCaro()
yield recurso
recurso.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 Usuario(id=1, nome="Alice")
@pytest.fixture
def admin():
return Usuario(id=2, nome="Admin", funcao="admin")
def test_interacao_usuario_admin(usuario, admin):
"""Teste usando múltiplas fixtures."""
assert admin.pode_gerenciar(usuario)
Fixtures Autouse
@pytest.fixture(autouse=True)
def resetar_config():
"""Executa automaticamente antes de cada teste."""
Config.reset()
yield
Config.cleanup()
def test_sem_chamada_fixture():
# resetar_config executa 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 cliente:
yield cliente
@pytest.fixture
def cabecalhos_auth(cliente):
"""Gera cabeçalhos de autenticação para teste de API."""
resposta = cliente.post("/api/login", json={
"username": "teste",
"password": "teste"
})
token = resposta.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_maiusculo(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_adicionar(a, b, esperado):
"""Testa adição com múltiplas entradas."""
assert adicionar(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 email com IDs de teste legíveis."""
assert email_valido(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(db):
"""Teste executa 3 vezes, uma para cada banco."""
resultado = db.query("SELECT 1")
assert resultado is not None
Marcadores e Seleção de Teste
Marcadores Personalizados
# Marcar testes lentos
@pytest.mark.lento
def test_operacao_lenta():
time.sleep(5)
# Marcar testes de integração
@pytest.mark.integracao
def test_api_integracao():
resposta = requests.get("https://api.exemplo.com")
assert resposta.status_code == 200
# Marcar testes unitários
@pytest.mark.unidade
def test_logica_unitaria():
assert calcular(2, 3) == 5
Executar Testes Específicos
# Executar apenas testes rápidos
pytest -m "not lento"
# Executar apenas testes de integração
pytest -m integracao
# Executar testes de integração ou lentos
pytest -m "integracao or lento"
# Executar testes marcados como unidade mas não lentos
pytest -m "unidade and not lento"
Configurar Marcadores no pytest.ini
[pytest]
markers =
lento: marca testes como lentos
integracao: marca testes como testes de integração
unidade: marca testes como testes unitários
django: marca testes que exigem Django
Mock e Patching
Mock de Funções
from unittest.mock import patch, Mock
@patch("mypackage.chamada_api_externa")
def test_com_mock(mock_chamada_api):
"""Teste com API externa mockada."""
mock_chamada_api.return_value = {"status": "sucesso"}
resultado = minha_funcao()
mock_chamada_api.assert_called_once()
assert resultado["status"] == "sucesso"
Mock de Valores de Retorno
@patch("mypackage.Database.connect")
def test_conexao_banco(mock_connect):
"""Teste com conexão de banco mockada."""
mock_connect.return_value = MockConnection()
db = Database()
db.connect()
mock_connect.assert_called_once_with("localhost")
Mock de Exceções
@patch("mypackage.chamada_api")
def test_tratamento_erro_api(mock_chamada_api):
"""Teste de tratamento de erro com exceção mockada."""
mock_chamada_api.side_effect = ConnectionError("Erro de rede")
with pytest.raises(ConnectionError):
chamada_api()
mock_chamada_api.assert_called_once()
Mock de Gerenciadores de Contexto
@patch("builtins.open", new_callable=mock_open)
def test_leitura_arquivo(mock_arquivo):
"""Teste de leitura de arquivo com open mockado."""
mock_arquivo.return_value.read.return_value = "conteúdo do arquivo"
resultado = ler_arquivo("teste.txt")
mock_arquivo.assert_called_once_with("teste.txt", "r")
assert resultado == "conteúdo do arquivo"
Usando Autospec
@patch("mypackage.DBConnection", autospec=True)
def test_autospec(mock_db):
"""Teste com autospec para detectar mau uso da API."""
db = mock_db.return_value
db.query("SELECT * FROM usuarios")
# Isso falharia se DBConnection não tivesse o método query
mock_db.assert_called_once()
Mock de Instâncias de Classe
class TestUserService:
@patch("mypackage.UserRepository")
def test_criar_usuario(self, mock_repo):
"""Testa criação de usuário com repositório mockado."""
mock_repo.return_value.save.return_value = Usuario(id=1, nome="Alice")
servico = UserService(mock_repo.return_value)
usuario = servico.criar_usuario(nome="Alice")
assert usuario.nome == "Alice"
mock_repo.return_value.save.assert_called_once()
Mock de Propriedade
@pytest.fixture
def mock_config():
"""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_mock_config(mock_config):
"""Teste com propriedades de config mockadas."""
assert mock_config.debug is True
assert mock_config.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 adicionar_assincrono(2, 3)
assert resultado == 5
@pytest.mark.asyncio
async def test_assincrono_com_fixture(cliente_assincrono):
"""Testa async com fixture async."""
resposta = await cliente_assincrono.get("/api/usuarios")
assert resposta.status_code == 200
Fixture Assíncrona
@pytest.fixture
async def cliente_assincrono():
"""Fixture async fornecendo cliente de teste async."""
app = create_app()
async with app.test_client() as cliente:
yield cliente
@pytest.mark.asyncio
async def test_endpoint_api(cliente_assincrono):
"""Teste usando fixture async."""
resposta = await cliente_assincrono.get("/api/dados")
assert resposta.status_code == 200
Mock de Funções Assíncronas
@pytest.mark.asyncio
@patch("mypackage.chamada_api_assincrona")
async def test_mock_assincrono(mock_chamada_api):
"""Testa função async com mock."""
mock_chamada_api.return_value = {"status": "ok"}
resultado = await minha_funcao_assincrona()
mock_chamada_api.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("inválido")
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", codigo=400)
assert exc_info.value.codigo == 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):
"""Teste usando a fixture de caminho temporário integrada 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):
"""Teste 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 Teste
Estrutura de Diretórios
tests/
├── conftest.py # Fixtures compartilhadas
├── __init__.py
├── unitario/ # Testes unitários
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_utils.py
│ └── test_services.py
├── integracao/ # 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 TestUserService:
"""Agrupa testes relacionados em uma classe."""
@pytest.fixture(autouse=True)
def setup(self):
"""Setup executado antes de cada teste nesta classe."""
self.servico = UserService()
def test_criar_usuario(self):
"""Testa criação de usuário."""
usuario = self.servico.criar_usuario("Alice")
assert usuario.nome == "Alice"
def test_excluir_usuario(self):
"""Testa exclusão de usuário."""
usuario = self.servico.criar_usuario("Bob")
self.servico.excluir_usuario(usuario.id)
assert self.servico.obter_usuario(usuario.id) is None
@pytest.mark.parametrize("nome_invalido", ["", None, 123])
def test_validacao_nome(self, nome_invalido):
"""Testa validação de nome."""
with pytest.raises(ValueError, match="nome inválido"):
self.servico.criar_usuario(nome_invalido)
Melhores Práticas de Teste
Convenções de Nomenclatura
# Nomeie funções de teste descritivamente
def test_usuario_pode_fazer_login_com_credenciais_validas():
...
# Use nomes de classe para organizar testes relacionados
class TestAutenticacaoUsuario:
def test_login_com_email_valido(self):
...
def test_login_com_senha_incorreta(self):
...
Padrão Triple A (Arrange, Act, Assert)
def test_calculo_total_pedido():
# Arrange (Preparar)
pedido = Pedido()
pedido.adicionar_item(Produto("Camiseta", 50.0), 2)
pedido.adicionar_item(Produto("Calça", 100.0), 1)
# Act (Agir)
total = pedido.calcular_total()
# Assert (Verificar)
assert total == 200.0 # (50 * 2) + (100 * 1)
Orientado a Testes vs Orientado a Implementação
# RUIM: Teste muito acoplado à implementação
def test_lista_internamente_usa_lista():
colecao = MinhaColecao()
colecao.adicionar(1)
assert colecao._itens == [1] # Quebra se implementação mudar
# BOM: Testar comportamento público
def test_colecao_adiciona_e_recupera_itens():
colecao = MinhaColecao()
colecao.adicionar(1)
assert colecao.obter(0) == 1
assert len(colecao) == 1
Testes Pequenos e Focados
# RUIM: Teste testando muitas coisas
def test_servico_usuario():
# Testa criação, atualização e exclusão
usuario = criar_usuario("Alice")
assert usuario.nome == "Alice"
usuario = atualizar_usuario(usuario.id, nome="Bob")
assert usuario.nome == "Bob"
excluir_usuario(usuario.id)
assert obter_usuario(usuario.id) is None
# BOM: Testes focados
def test_criar_usuario_com_nome_valido():
usuario = criar_usuario("Alice")
assert usuario.nome == "Alice"
def test_atualizar_nome_usuario():
usuario = criar_usuario("Alice")
usuario_atualizado = atualizar_usuario(usuario.id, nome="Bob")
assert usuario_atualizado.nome == "Bob"
def test_excluir_usuario_existente():
usuario = criar_usuario("Alice")
excluir_usuario(usuario.id)
assert obter_usuario(usuario.id) is None
Usar Fixtures sobre Setup Método
# RUIM: Usar setup_method
class TestServico:
def setup_method(self):
self.servico = Servico()
self.dados = carregar_dados_teste()
def test_funcionalidade(self):
# Teste usando self.servico e self.dados
...
# BOM: Usar fixtures do pytest
class TestServico:
@pytest.fixture(autouse=True)
def configurar(self):
self.servico = Servico()
self.dados = carregar_dados_teste()
def test_funcionalidade(self):
# Teste usando self.servico e self.dados
...
Ferramentas de Relatório
Geração de Relatório de Cobertura
# Relatório de terminal
pytest --cov=mypackage
# Relatório HTML
pytest --cov=mypackage --cov-report=html
# Relatório XML (para integração CI)
pytest --cov=mypackage --cov-report=xml
# Relatório de linhas faltantes
pytest --cov=mypackage --cov-report=term-missing
Relatório de Falhas Detalhado
# Saída detalhada para falhas
pytest -v
# Mostrar saída local para falhas
pytest -v --tb=short
# Suprimir saída de sucesso
pytest -v --tb=short -q
Padrões TDD Avançados
Trabalhando com Código Legado
# 1. Comece com teste de caracterização
from unittest.mock import patch
def test_comportamento_legado_existente():
"""Teste de caracterização para código existente."""
resultado = funcao_legado(10, "teste")
# Documente o comportamento atual
assert resultado == 42
# 2. Extraia dependências
def test_funcao_refatorada():
"""Teste para versão refatorada."""
dependencia_mock = Mock()
dependencia_mock.processar.return_value = "processado"
resultado = funcao_refatorada(dependencia_mock)
dependencia_mock.processar.assert_called_once()
assert resultado == "processado"
Padrão Test Doubles
# Dummy: Passado mas não usado
def test_com_dummy():
def processar(_, _extra):
return "resultado"
assert processar("real", None) == "resultado"
# Stub: Fornece respostas prontas
class StubDatabase:
def query(self, sql):
return [{"id": 1, "nome": "Stub"}]
# Spy: Registra interações
class SpyServico:
def __init__(self):
self.chamadas = []
def executar(self, acao):
self.chamadas.append(acao)
return "feito"
# Mock: Pré-programado com expectativas
mock_servico = Mock()
mock_servico.executar.return_value = "mockado"
mock_servico.executar.assert_called_once()
Propriedades de Teste (Hypothesis)
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_inverter_lista_inverte_duas_vezes(lista):
"""Propriedade: inverter lista duas vezes retorna original."""
assert inverter(inverter(lista)) == lista
@given(st.integers(min_value=1), st.integers(min_value=1))
def test_multiplicacao_comutativa(a, b):
"""Propriedade: multiplicação é comutativa."""
assert a * b == b * a
Teste de Inalterabilidade
def test_funcao_nao_modifica_entrada():
"""Garante que a função não modifica a entrada."""
dados_originais = {"chave": "valor", "numeros": [1, 2, 3]}
dados = copy.deepcopy(dados_originais)
processar_dados(dados)
# A entrada não deve ser modificada
assert dados == dados_originais
Plugins pytest Recomendados
Plugins Esenciais
# Cobertura
pip install pytest-cov
# Testes assíncronos
pip install pytest-asyncio
# Testes paralelos
pip install pytest-xdist
# Execução de teste mais lento
pip install pytest-repeat
Plugins Avançados
# Geração de dados de teste
pip install hypothesis
# Teste de APIs
pip install pytest-httpx
# Teste de Django
pip install pytest-django
# Geração de relatório
pip install pytest-html
Configuração Exemplo
[pytest]
minversion = 7.0
testpaths = tests
python_files = test_*.py
python_functions = test_*
markers =
lento: testes lentos
integracao: testes de integração
unidade: testes unitários
api: testes de API
addopts =
--strict-markers
--tb=short
-v
Conclusão
Seguindo estes padrões, você pode:
- Escrever testes confiáveis que realmente validam o comportamento do código
- Manter suítes de teste que são rápidas, focadas e compreensíveis
- Usar padrões TDD consistentes em toda sua base de código
- Alcançar alta cobertura com testes significativos
- Integrar testes sem problemas em seu fluxo de trabalho de desenvolvimento
Lembre-se: bons testes não são apenas sobre encontrar bugs — eles são sobre projetar um código melhor, documentar comportamento e construir confiança em sua base de código.