Capítulo 16: Testes Automatizados com Pytest, HTTPX AsyncClient e Fixtures
Especialização em Backend com Python & FastAPI • FastAPI & Python 3.12+ • Pydantic v2, SQLModel, Async/Await e Microsserviços
🗺️ Mapa Conceitual do Tópico
flowchart TD
A["Suíte de Testes (pytest)"] --> B["conftest.py: Configuração Global"]
B --> C["Fixture: Banco SQLite em Memória (sqlite+aiosqlite:///:memory:)"]
B --> D["app.dependency_overrides[get_db_session]"]
C --> E["Transporte ASGI em Memória: ASGITransport(app=app)"]
D --> E
E --> F["httpx.AsyncClient(base_url='http://test')"]
F --> G["Teste E2E: await client.post('/pedidos', json={...})"]
G --> H{"Validações do Teste (Asserts)"}
H --> I["Status HTTP (201 Created)"]
H --> J["Schema Pydantic de Resposta"]
H --> K["Consulta Direta ao Banco para Validar Efeito Colateral"]
K --> L["Teardown da Fixture: app.dependency_overrides.clear()"]
L --> M["Destruição do Banco em Memória (Isolamento Total)"]
style A fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
style B fill:#fff3e0,stroke:#ff9800,stroke-width:2px
style C fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
style D fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
style E fill:#fce4ec,stroke:#e91e63,stroke-width:2px
style F fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px
style G fill:#fffde7,stroke:#fbc02d,stroke-width:2px
style H fill:#e0f2f1,stroke:#009688,stroke-width:2px
style I fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style J fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style K fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L fill:#ffebee,stroke:#e53935,stroke-width:2px
style M fill:#f5f5f5,stroke:#9e9e9e,stroke-width:2px
🏛️ 1. A Evolução dos Testes: TestClient vs httpx.AsyncClient
Historicamente no ecossistema Python, testes de endpoints HTTP costumavam exigir a inicialização de um servidor web real em uma porta TCP livre (como 8001), seguida por chamadas de rede reais via requests. Esse modelo era lento, suscetível a colisões de portas em pipelines CI/CD e gerava testes intermitentes (flaky tests).
No FastAPI, os testes operam diretamente no nível da interface ASGI, sem abertura de portas nem sockets de rede do sistema operacional. As requisições são convertidas em estruturas de dados em memória e despachadas diretamente para a aplicação.
| Característica | TestClient (Starlette / Síncrono) |
httpx.AsyncClient + ASGITransport |
|---|---|---|
| Execução | Síncrona (client.get(...)) |
Assíncrona pura (await client.get(...)) |
| Compatibilidade com Event Loop | Executa o loop internamente em outra thread | Roda no mesmo Event Loop da suíte do Pytest |
| Transações Compartilhadas | Difícil sincronizar com bancos assíncronos | Perfeito para compartilhar sessões AsyncSession |
| Suporte a Lifespan Moderno | Requer with TestClient(app): para disparar |
Dispara context managers do ASGI Lifespan nativamente |
| Recomendação Atual | Testes unitários muito simples e síncronos | Padrão Ouro para APIs FastAPI Assíncronas |
⚙️ 2. Arquitetura de Testes com conftest.py e Banco Isolado
O arquivo conftest.py é o ponto nevrálgico onde configuramos fixtures compartilhadas com escopo de função (scope="function"). Isso assegura que cada teste unitário ou de integração execute contra um banco de dados totalmente limpo e isolado:
# tests/conftest.py
import pytest
from typing import AsyncGenerator
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from main import app
from core.database import get_db_session
# 1. Engine assíncrono efêmero em memória para a suíte de testes
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_async_engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
)
test_async_session_maker = async_sessionmaker(
bind=test_async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False
)
# 2. Fixture que inicializa o DDL e fornece sessão com rollback automático
@pytest.fixture(scope="function")
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with test_async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with test_async_session_maker() as session:
yield session
async with test_async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
# 3. Fixture do Cliente HTTP Assíncrono com Overrides
@pytest.fixture(scope="function")
async def async_client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
# Sobrescreve a dependência de banco de dados da aplicação
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
app.dependency_overrides[get_db_session] = override_get_db_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
yield client
# Limpa os overrides para não poluir outros testes
app.dependency_overrides.clear()
🧪 3. Escrevendo Testes de Integração E2E Expressivos
Com a fixture async_client, os testes tornam-se limpos, rápidos e altamente legíveis:
# tests/test_pedidos.py
import pytest
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select
from models.pedido import Pedido
@pytest.mark.asyncio
async def test_criar_pedido_com_sucesso(async_client: AsyncClient, db_session: AsyncSession):
# Payload validado pelo Pydantic
payload = {
"cliente_nome": "Ada Lovelace",
"email": "ada@algorithm.org",
"valor_total": 450.00,
"itens": ["Livro Algoritmos", "Teclado Mecânico"]
}
# Executa a requisição HTTP POST assíncrona
response = await async_client.post("/pedidos", json=payload)
# 1. Asserções na Resposta HTTP
assert response.status_code == 201
dados_resposta = response.json()
assert dados_resposta["id"] is not None
assert dados_resposta["cliente_nome"] == "Ada Lovelace"
assert dados_resposta["status"] == "CRIADO"
# 2. Verificação direta no banco (Garante persistência física)
stmt = select(Pedido).where(Pedido.id == dados_resposta["id"])
resultado = await db_session.exec(stmt)
pedido_banco = resultado.first()
assert pedido_banco is not None
assert pedido_banco.valor_total == 450.00
🛡️ 4. Sobrescrevendo Autenticação e Usuário Atual (get_current_user)
Outro caso de uso indispensável de app.dependency_overrides é simular usuários autenticados sem ter que executar todo o fluxo de hashing e emissão de JWT em cada teste:
from core.security import get_current_user
from models.usuario import Usuario
@pytest.fixture
def auth_client_admin(async_client: AsyncClient):
"""Injeta um usuário com privilégios administrativos."""
usuario_fake_admin = Usuario(
id=1,
nome="Administrador Root",
email="admin@corp.internal",
is_superuser=True,
senha_hash="dummy_hash"
)
app.dependency_overrides[get_current_user] = lambda: usuario_fake_admin
return async_client