Python Admin

Dominando Testes de APIs Python: pytest com httpx e TestClient do FastAPI em Projetos Reais Já leu

Por que Testar APIs com Python? Testar uma API é tão importante quanto desenvolvê-la. Um código sem testes é como dirigir de olhos fechados: você pode chegar ao destino, mas as chances de bater são enormes. Quando falamos de APIs REST, os testes garantem que seus endpoints respondem corretamente, tratam erros adequadamente e mantêm a compatibilidade com clientes que dependem deles. A combinação de pytest, httpx e TestClient do FastAPI oferece um toolkit poderoso e elegante para isso. O pytest fornece a estrutura de testes limpa e extensível; o httpx é um cliente HTTP assíncrono moderno que funciona perfeitamente com código async; e o TestClient é a ferramenta nativa do FastAPI que permite testar sua aplicação sem precisar subir um servidor real. Juntos, eles eliminam a fricção entre desenvolvimento e testes, permitindo que você valide comportamentos complexos com poucas linhas de código. Configuração do Ambiente e Dependências Antes de escrever o primeiro teste, você precisa preparar o ambiente corretamente. A

Por que Testar APIs com Python?

Testar uma API é tão importante quanto desenvolvê-la. Um código sem testes é como dirigir de olhos fechados: você pode chegar ao destino, mas as chances de bater são enormes. Quando falamos de APIs REST, os testes garantem que seus endpoints respondem corretamente, tratam erros adequadamente e mantêm a compatibilidade com clientes que dependem deles.

A combinação de pytest, httpx e TestClient do FastAPI oferece um toolkit poderoso e elegante para isso. O pytest fornece a estrutura de testes limpa e extensível; o httpx é um cliente HTTP assíncrono moderno que funciona perfeitamente com código async; e o TestClient é a ferramenta nativa do FastAPI que permite testar sua aplicação sem precisar subir um servidor real. Juntos, eles eliminam a fricção entre desenvolvimento e testes, permitindo que você valide comportamentos complexos com poucas linhas de código.

Configuração do Ambiente e Dependências

Antes de escrever o primeiro teste, você precisa preparar o ambiente corretamente. A instalação é simples, mas cada dependência tem um propósito bem definido.

Instalando as Dependências Necessárias

Comece criando um arquivo requirements.txt ou usando pip diretamente:

pip install fastapi uvicorn pytest pytest-asyncio httpx

Se você usa poetry (recomendado para projetos maiores), o comando é:

poetry add fastapi uvicorn pytest pytest-asyncio httpx

O pytest-asyncio é crucial porque permite que o pytest execute corrotinas async nativamente. Sem ele, seus testes assincronos falharão.

Estrutura de Diretórios

Organize seu projeto assim:

projeto/
├── app/
│   ├── __init__.py
│   ├── main.py          # Sua aplicação FastAPI
│   └── models.py        # Modelos Pydantic
├── tests/
│   ├── __init__.py
│   ├── conftest.py      # Configurações compartilhadas
│   └── test_api.py      # Seus testes
├── requirements.txt
└── pytest.ini

Crie um arquivo pytest.ini na raiz do projeto para configurar comportamentos padrão:

[pytest]
asyncio_mode = auto
python_files = test_*.py
python_classes = Test*
python_functions = test_*

A opção asyncio_mode = auto permite que o pytest detecte automaticamente quais testes são assincronos.

Escrevendo Sua Primeira API com FastAPI

Antes de testar, você precisa de algo para testar. Vamos criar uma API simples mas realista.

Exemplo: API de Tarefas

Crie o arquivo app/main.py:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional

app = FastAPI(title="API de Tarefas")

class Tarefa(BaseModel):
    id: Optional[int] = None
    titulo: str
    descricao: str = ""
    concluida: bool = False

# Simulando um banco de dados em memória
tarefas_db = []
tarefa_id_counter = 1

@app.get("/")
async def root():
    return {"mensagem": "Bem-vindo à API de Tarefas"}

@app.post("/tarefas", response_model=Tarefa)
async def criar_tarefa(tarefa: Tarefa):
    global tarefa_id_counter
    tarefa.id = tarefa_id_counter
    tarefa_id_counter += 1
    tarefas_db.append(tarefa)
    return tarefa

@app.get("/tarefas", response_model=List[Tarefa])
async def listar_tarefas(concluida: Optional[bool] = None):
    if concluida is None:
        return tarefas_db
    return [t for t in tarefas_db if t.concluida == concluida]

@app.get("/tarefas/{tarefa_id}", response_model=Tarefa)
async def obter_tarefa(tarefa_id: int):
    for tarefa in tarefas_db:
        if tarefa.id == tarefa_id:
            return tarefa
    raise HTTPException(status_code=404, detail="Tarefa não encontrada")

@app.put("/tarefas/{tarefa_id}", response_model=Tarefa)
async def atualizar_tarefa(tarefa_id: int, tarefa_atualizada: Tarefa):
    for i, tarefa in enumerate(tarefas_db):
        if tarefa.id == tarefa_id:
            tarefa_atualizada.id = tarefa_id
            tarefas_db[i] = tarefa_atualizada
            return tarefa_atualizada
    raise HTTPException(status_code=404, detail="Tarefa não encontrada")

@app.delete("/tarefas/{tarefa_id}")
async def deletar_tarefa(tarefa_id: int):
    for i, tarefa in enumerate(tarefas_db):
        if tarefa.id == tarefa_id:
            tarefas_db.pop(i)
            return {"mensagem": "Tarefa deletada com sucesso"}
    raise HTTPException(status_code=404, detail="Tarefa não encontrada")

Agora você tem uma API CRUD completa. Os endpoints cobrem casos de sucesso e erro, o que é perfeito para demonstrar testes robustos.

Testando com pytest, httpx e TestClient

Aqui vem a parte essencial: como testar esses endpoints de forma profissional.

Configurando o TestClient do FastAPI

Crie o arquivo tests/conftest.py:

import pytest
from fastapi.testclient import TestClient
from app.main import app

@pytest.fixture(scope="function")
def client():
    """
    Fixture que fornece um TestClient para cada teste.
    scope="function" garante que o estado é resetado entre testes.
    """
    return TestClient(app)

@pytest.fixture(autouse=True)
def limpar_banco_dados():
    """
    Fixture que limpa o banco de dados em memória antes de cada teste.
    autouse=True faz isso rodar automaticamente.
    """
    from app.main import tarefas_db
    yield  # Executa o teste
    tarefas_db.clear()  # Limpa após o teste

O conftest.py é um arquivo especial do pytest. As fixtures definidas aqui estão disponíveis para todos os testes do projeto. O TestClient é uma wrapper do httpx que sabe como testar FastAPI sem precisar de um servidor real em execução.

Testando Endpoints com Casos de Sucesso

Crie o arquivo tests/test_api.py:

import pytest
from fastapi.testclient import TestClient

def test_root(client):
    """Testa se a rota raiz retorna 200 e a mensagem correta."""
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"mensagem": "Bem-vindo à API de Tarefas"}

def test_criar_tarefa(client):
    """Testa criação de uma tarefa com dados válidos."""
    payload = {
        "titulo": "Estudar FastAPI",
        "descricao": "Aprender testes com pytest",
        "concluida": False
    }
    response = client.post("/tarefas", json=payload)

    assert response.status_code == 200
    data = response.json()
    assert data["titulo"] == "Estudar FastAPI"
    assert data["id"] == 1  # ID atribuído automaticamente
    assert data["concluida"] is False

def test_listar_tarefas_vazio(client):
    """Testa listagem quando nenhuma tarefa existe."""
    response = client.get("/tarefas")
    assert response.status_code == 200
    assert response.json() == []

def test_listar_tarefas_com_dados(client):
    """Testa listagem após criar tarefas."""
    # Cria duas tarefas
    client.post("/tarefas", json={"titulo": "Tarefa 1", "descricao": ""})
    client.post("/tarefas", json={"titulo": "Tarefa 2", "descricao": ""})

    response = client.get("/tarefas")
    assert response.status_code == 200
    tarefas = response.json()
    assert len(tarefas) == 2
    assert tarefas[0]["titulo"] == "Tarefa 1"
    assert tarefas[1]["titulo"] == "Tarefa 2"

def test_obter_tarefa_especifica(client):
    """Testa recuperação de uma tarefa pelo ID."""
    # Cria uma tarefa
    criar_response = client.post("/tarefas", json={"titulo": "Tarefa Test"})
    tarefa_id = criar_response.json()["id"]

    # Recupera essa tarefa
    response = client.get(f"/tarefas/{tarefa_id}")
    assert response.status_code == 200
    data = response.json()
    assert data["id"] == tarefa_id
    assert data["titulo"] == "Tarefa Test"

def test_atualizar_tarefa(client):
    """Testa atualização de uma tarefa existente."""
    # Cria uma tarefa
    criar_response = client.post("/tarefas", json={"titulo": "Original", "concluida": False})
    tarefa_id = criar_response.json()["id"]

    # Atualiza a tarefa
    payload = {
        "titulo": "Atualizada",
        "descricao": "Descrição nova",
        "concluida": True
    }
    response = client.put(f"/tarefas/{tarefa_id}", json=payload)

    assert response.status_code == 200
    data = response.json()
    assert data["titulo"] == "Atualizada"
    assert data["concluida"] is True

def test_deletar_tarefa(client):
    """Testa exclusão de uma tarefa."""
    # Cria uma tarefa
    criar_response = client.post("/tarefas", json={"titulo": "Para Deletar"})
    tarefa_id = criar_response.json()["id"]

    # Deleta a tarefa
    response = client.delete(f"/tarefas/{tarefa_id}")
    assert response.status_code == 200

    # Verifica que foi realmente deletada
    get_response = client.get(f"/tarefas/{tarefa_id}")
    assert get_response.status_code == 404

Estes testes cobrem o "caminho feliz" — quando tudo funciona corretamente. Note que cada teste é independente graças à fixture limpar_banco_dados.

Testando Casos de Erro e Validação

Agora vamos testar o que acontece quando algo dá errado:

def test_obter_tarefa_inexistente(client):
    """Testa erro 404 ao tentar acessar tarefa que não existe."""
    response = client.get("/tarefas/999")
    assert response.status_code == 404
    data = response.json()
    assert "detail" in data
    assert "não encontrada" in data["detail"]

def test_atualizar_tarefa_inexistente(client):
    """Testa erro 404 ao tentar atualizar tarefa que não existe."""
    payload = {"titulo": "Nova", "descricao": "", "concluida": False}
    response = client.put("/tarefas/999", json=payload)
    assert response.status_code == 404

def test_deletar_tarefa_inexistente(client):
    """Testa erro 404 ao tentar deletar tarefa que não existe."""
    response = client.delete("/tarefas/999")
    assert response.status_code == 404

def test_criar_tarefa_campo_obrigatorio_faltando(client):
    """Testa validação: titulo é obrigatório."""
    payload = {"descricao": "Sem titulo"}
    response = client.post("/tarefas", json=payload)
    assert response.status_code == 422  # Validation Error
    data = response.json()
    assert "detail" in data

def test_listar_tarefas_filtro_concluidas(client):
    """Testa filtro por status de conclusão."""
    # Cria tarefas com status diferentes
    client.post("/tarefas", json={"titulo": "Completa", "concluida": True})
    client.post("/tarefas", json={"titulo": "Incompleta", "concluida": False})

    # Filtra apenas as completas
    response = client.get("/tarefas?concluida=true")
    assert response.status_code == 200
    tarefas = response.json()
    assert len(tarefas) == 1
    assert tarefas[0]["titulo"] == "Completa"

Observe que os testes de erro verificam não apenas o código de status, mas também a estrutura da resposta. Isso garante que seu cliente consiga interpretar o erro corretamente.

Organizando Testes com Classes e Parametrização

Conforme seu projeto cresce, agrupamentos lógicos ficam essenciais. Use classes para organizar testes relacionados:

class TestCriacaoDeTarefas:
    """Agrupa testes relacionados à criação de tarefas."""

    def test_criar_com_todos_campos(self, client):
        payload = {
            "titulo": "Tarefa Completa",
            "descricao": "Com descrição",
            "concluida": False
        }
        response = client.post("/tarefas", json=payload)
        assert response.status_code == 200
        assert all(key in response.json() for key in ["id", "titulo", "descricao", "concluida"])

    def test_criar_com_apenas_titulo(self, client):
        payload = {"titulo": "Minimalista"}
        response = client.post("/tarefas", json=payload)
        assert response.status_code == 200
        data = response.json()
        assert data["descricao"] == ""
        assert data["concluida"] is False

class TestFiltrosDeTarefas:
    """Agrupa testes relacionados a filtros e listagem."""

    @pytest.mark.parametrize("concluida,esperado", [
        (True, 2),
        (False, 1),
        (None, 3),
    ])
    def test_filtro_por_conclusao(self, client, concluida, esperado):
        """Parametrizado para testar múltiplos valores de filtro."""
        # Setup: cria 2 concluídas e 1 não concluída
        client.post("/tarefas", json={"titulo": "T1", "concluida": True})
        client.post("/tarefas", json={"titulo": "T2", "concluida": True})
        client.post("/tarefas", json={"titulo": "T3", "concluida": False})

        # Act
        url = "/tarefas" if concluida is None else f"/tarefas?concluida={str(concluida).lower()}"
        response = client.get(url)

        # Assert
        assert response.status_code == 200
        assert len(response.json()) == esperado

A parametrização com @pytest.mark.parametrize executa o mesmo teste múltiplas vezes com diferentes valores, reduzindo duplicação de código significativamente.

Testes Assincronos com httpx

Até agora usamos TestClient, que é síncrono. Para cenários mais avançados — como testar múltiplas requisições em paralelo ou integrar com código que usa asyncio nativamente — você pode usar httpx diretamente.

Usando httpx para Requisições Assincronos

Crie um arquivo tests/test_api_async.py:

import pytest
import httpx
from app.main import app, tarefas_db

@pytest.fixture
async def async_client():
    """Fixture que fornece um cliente httpx assincronista."""
    async with httpx.AsyncClient(app=app, base_url="http://test") as client:
        yield client

@pytest.mark.asyncio
async def test_criar_multiplas_tarefas_paralelo(async_client):
    """
    Demonstra requisições assincronistas em paralelo.
    Isso é muito mais rápido do que fazer requisições sequenciais.
    """
    # Cria 5 tarefas simultaneamente
    tasks = [
        async_client.post("/tarefas", json={"titulo": f"Tarefa {i}"})
        for i in range(5)
    ]
    responses = await asyncio.gather(*tasks)

    # Verifica que todas foram criadas
    assert all(r.status_code == 200 for r in responses)
    assert len(tarefas_db) == 5

@pytest.mark.asyncio
async def test_listar_e_obter_sequencial(async_client):
    """Exemplo de múltiplas requisições que dependem uma da outra."""
    # Cria uma tarefa
    criar_response = await async_client.post(
        "/tarefas",
        json={"titulo": "Async Test", "descricao": "Testando async"}
    )
    tarefa_id = criar_response.json()["id"]

    # Lista todas as tarefas
    listar_response = await async_client.get("/tarefas")
    assert len(listar_response.json()) == 1

    # Obtém a tarefa específica
    obter_response = await async_client.get(f"/tarefas/{tarefa_id}")
    assert obter_response.status_code == 200
    assert obter_response.json()["titulo"] == "Async Test"

Note que você precisa fazer import asyncio no topo do arquivo. A fixture async_client usa um context manager (async with) para garantir que a conexão seja fechada corretamente após cada teste.

Quando Usar httpx vs TestClient

  • Use TestClient para a maioria dos testes de API FastAPI. É mais simples, mais rápido e integrado perfeitamente.
  • Use httpx quando precisar testar requisições paralelas, quando quiser testar contra servidores externos, ou quando estiver testando código que é inerentemente assincronista.

A regra de ouro: comece com TestClient e migre para httpx apenas se tiver uma razão específica.

Executando os Testes e Interpretando Resultados

Você já tem os testes prontos. Agora precisa executá-los e entender os resultados.

Executando com pytest

Na raiz do projeto, execute:

# Executa todos os testes
pytest

# Executa com output detalhado
pytest -v

# Executa um arquivo específico
pytest tests/test_api.py

# Executa uma classe específica
pytest tests/test_api.py::TestCriacaoDeTarefas

# Executa um teste específico
pytest tests/test_api.py::TestCriacaoDeTarefas::test_criar_com_todos_campos

# Executa com coverage (se tiver pytest-cov instalado)
pytest --cov=app --cov-report=html

Interpretando Falhas

Quando um teste falha, o pytest mostra:

FAILED tests/test_api.py::test_criar_tarefa - AssertionError: assert 201 == 200

Isso diz que o status code retornou 201 (Created) quando você esperava 200 (OK). Verifique sua implementação. Este é um exemplo fictício, mas demonstra como o pytest é descritivo.

Melhores Práticas de Assertion

Escreva assertions claras e específicas:

# ❌ Ruim: pouco informativo
assert response

# ✅ Bom: específico e claro
assert response.status_code == 200
assert response.json()["titulo"] == "Esperado"
assert len(response.json()) > 0

Use pytest.raises para testar exceções (em casos raros onde isso faz sentido):

@pytest.mark.asyncio
async def test_erro_validacao_com_fastapi():
    """Testa se FastAPI levanta erro de validação corretamente."""
    client = TestClient(app)
    response = client.post("/tarefas", json={})
    assert response.status_code == 422

Conclusão

Dominar testes de APIs em Python com pytest, httpx e TestClient do FastAPI é uma habilidade que transforma sua confiança no código. Você aprendeu que: (1) o TestClient oferece a forma mais prática de testar FastAPI, eliminando a necessidade de servidor externo; (2) fixtures do pytest são poderosas para reutilizar setup e teardown entre testes, reduzindo duplicação; (3) a parametrização e organização com classes mantêm seus testes escaláveis conforme o projeto cresce.

A jornada não termina aqui. Explore integração contínua (CI/CD) para rodar esses testes automaticamente, implemente cobertura de código com pytest-cov para medir quantas linhas estão sendo testadas, e considere usar ferramentas como Postman ou Bruno para validar comportamentos complexos em endpoints. Os testes que você escreve hoje são a base sólida para refatorações confiantes amanhã.

Referências


Artigos relacionados