Dicas para escrever testes mais rápidos com pytest e fixtures
1. Entendendo o ciclo de vida das fixtures e seu impacto na performance
O pytest oferece cinco escopos para fixtures: function (padrão), class, module, package e session. A escolha correta do escopo é crucial para performance, pois determina quantas vezes uma fixture será criada e destruída.
# Fixture recriada a cada teste (lenta para operações pesadas)
@pytest.fixture(scope="function")
def db_connection():
conn = create_database_connection() # Operação lenta
yield conn
conn.close()
# Fixture criada uma vez por módulo (mais rápida)
@pytest.fixture(scope="module")
def db_connection():
conn = create_database_connection()
yield conn
conn.close()
O uso de yield permite setup e teardown eficientes. O código antes do yield executa no setup, e o código após no teardown, sem overhead adicional de wrappers.
@pytest.fixture(scope="session")
def api_client():
import requests
session = requests.Session()
session.headers.update({"Authorization": "Bearer test-token"})
yield session
session.close()
Evite fixtures globais que forçam dependências desnecessárias entre testes. Cada fixture deve ser o mais específica possível para seu caso de uso.
2. Otimizando fixtures com conftest.py e modularização
O arquivo conftest.py permite compartilhar fixtures entre múltiplos arquivos de teste em um diretório, sem necessidade de importação explícita.
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def db_session():
from myapp.database import create_session
session = create_session()
yield session
session.close()
@pytest.fixture
def api_client(db_session):
from myapp.api import APIClient
return APIClient(session=db_session)
Crie fixtures especializadas em vez de uma única fixture genérica que faz tudo:
# Especializadas e rápidas
@pytest.fixture
def mock_user():
return {"id": 1, "name": "Test User", "email": "test@example.com"}
@pytest.fixture
def authenticated_client(mock_user):
from myapp.api import APIClient
client = APIClient()
client.authenticate(mock_user)
return client
Use autouse=True com moderação, pois fixtures automáticas executam em cada teste mesmo quando não necessárias:
# Use apenas quando realmente necessário
@pytest.fixture(autouse=True)
def setup_test_environment():
# Setup mínimo
pass
3. Reduzindo tempo de I/O com fixtures inteligentes
Substitua chamadas reais de rede ou banco por dados mockados:
@pytest.fixture
def mock_database():
class MockDB:
def query(self, sql):
return [{"id": 1, "name": "Mocked Result"}]
return MockDB()
@pytest.fixture
def mock_http_response():
class MockResponse:
status_code = 200
def json(self):
return {"success": True, "data": []}
return MockResponse
Use tmp_path para arquivos temporários rápidos e isolados:
def test_file_processing(tmp_path):
file_path = tmp_path / "test.txt"
file_path.write_text("conteúdo de teste")
result = process_file(str(file_path))
assert result == "CONTEÚDO DE TESTE"
Cacheie dados pesados com escopo session:
@pytest.fixture(scope="session")
def expensive_data():
data = load_large_dataset() # Carrega uma vez
return data
@pytest.fixture
def filtered_data(expensive_data):
return [item for item in expensive_data if item["active"]]
4. Parametrização de fixtures para reutilização sem duplicação
Parametrize fixtures para testar múltiplos cenários sem repetir código:
@pytest.fixture(params=[
{"role": "admin", "expected_access": True},
{"role": "user", "expected_access": False},
{"role": "guest", "expected_access": False}
])
def user_access_case(request):
return request.param
def test_user_permissions(user_access_case):
user = create_user(role=user_access_case["role"])
result = check_access(user)
assert result == user_access_case["expected_access"]
Combine com pytest.mark.parametrize para cenários complexos:
@pytest.fixture
def calculator():
return Calculator()
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300)
])
def test_addition(calculator, a, b, expected):
assert calculator.add(a, b) == expected
Cuidado com escopo ao parametrizar: fixtures parametrizadas com escopo session são executadas uma vez para cada parâmetro na sessão inteira.
5. Lazy evaluation e fixtures sob demanda
Use request.getfixturevalue para carregar fixtures apenas quando necessário:
def test_with_optional_db(request):
if should_use_database():
db = request.getfixturevalue("db_session")
result = db.query("SELECT ...")
else:
result = process_without_db()
assert result is not None
Crie fixtures condicionais com pytest.skip:
@pytest.fixture
def optional_feature():
try:
import optional_dependency
return optional_dependency.SomeClass()
except ImportError:
pytest.skip("Dependência opcional não instalada")
Estratégias para fixtures sob demanda:
@pytest.fixture
def heavy_computation():
# Só executa quando o teste realmente precisa
if not pytest.config.getoption("--run-heavy"):
pytest.skip("Pule testes pesados com --run-heavy")
return perform_heavy_computation()
6. Uso de pytest-benchmark e profiling para identificar gargalos
Instale e use pytest-benchmark para medir performance:
# pip install pytest-benchmark
def test_fixture_performance(benchmark):
@pytest.fixture
def slow_fixture():
import time
time.sleep(0.1) # Simula operação lenta
return "result"
result = benchmark(lambda: slow_fixture())
assert result == "result"
Identifique fixtures lentas com profiling simples:
@pytest.fixture(scope="session")
def profiled_fixture():
import time
start = time.time()
# Operação pesada
data = load_data()
elapsed = time.time() - start
print(f"Fixture levou {elapsed:.2f}s para carregar")
return data
Refatore baseado em dados: se uma fixture leva mais de 100ms, considere mockar ou cachear.
7. Boas práticas de limpeza e isolamento para evitar vazamentos
Use addfinalizer para garantir limpeza mesmo em falhas:
@pytest.fixture
def temp_file(request):
import tempfile
import os
fd, path = tempfile.mkstemp()
os.close(fd)
def cleanup():
os.unlink(path)
request.addfinalizer(cleanup)
return path
Evite estado compartilhado sem reinicialização:
@pytest.fixture(scope="session")
def shared_resource():
resource = Resource()
yield resource
resource.reset() # Reset obrigatório para próximo uso
Reset de fixtures de sessão com autouse:
@pytest.fixture(scope="session", autouse=True)
def reset_global_state():
yield
# Limpeza global após todos os testes
clear_all_caches()
reset_database_connections()
Com essas práticas, seus testes com pytest serão não apenas mais rápidos, mas também mais confiáveis e fáceis de manter. A chave está em entender o ciclo de vida das fixtures, usar escopos adequados e evitar operações desnecessárias durante a execução dos testes.
Referências
- Documentação oficial do pytest sobre fixtures — Guia completo sobre escopos, teardown e boas práticas de fixtures
- pytest-benchmark: Medição de performance — Ferramenta para benchmark de fixtures e funções de teste
- Real Python: Effective Python Testing With pytest — Tutorial abrangente sobre pytest com exemplos práticos de otimização
- TestDriven.io: pytest Fixtures — Artigo detalhado sobre fixtures avançadas e modularização com conftest.py
- PyCon 2019: Advanced pytest Fixtures — Palestra técnica sobre patterns avançados de fixtures para performance