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