<h2>Por que Testar APIs com Python?</h2>
<p>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.</p>
<p>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.</p>
<h2>Configuração do Ambiente e Dependências</h2>
<p>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.</p>
<h3>Instalando as Dependências Necessárias</h3>
<p>Comece criando um arquivo <code>requirements.txt</code> ou usando <code>pip</code> diretamente:</p>
<pre><code class="language-bash">pip install fastapi uvicorn pytest pytest-asyncio httpx</code></pre>
<p>Se você usa <code>poetry</code> (recomendado para projetos maiores), o comando é:</p>
<pre><code class="language-bash">poetry add fastapi uvicorn pytest pytest-asyncio httpx</code></pre>
<p>O <code>pytest-asyncio</code> é crucial porque permite que o pytest execute corrotinas async nativamente. Sem ele, seus testes assincronos falharão.</p>
<h3>Estrutura de Diretórios</h3>
<p>Organize seu projeto assim:</p>
<pre><code>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</code></pre>
<p>Crie um arquivo <code>pytest.ini</code> na raiz do projeto para configurar comportamentos padrão:</p>
<pre><code class="language-ini">[pytest]
asyncio_mode = auto
python_files = test_*.py
python_classes = Test*
python_functions = test_*</code></pre>
<p>A opção <code>asyncio_mode = auto</code> permite que o pytest detecte automaticamente quais testes são assincronos.</p>
<h2>Escrevendo Sua Primeira API com FastAPI</h2>
<p>Antes de testar, você precisa de algo para testar. Vamos criar uma API simples mas realista.</p>
<h3>Exemplo: API de Tarefas</h3>
<p>Crie o arquivo <code>app/main.py</code>:</p>
<pre><code class="language-python">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")</code></pre>
<p>Agora você tem uma API CRUD completa. Os endpoints cobrem casos de sucesso e erro, o que é perfeito para demonstrar testes robustos.</p>
<h2>Testando com pytest, httpx e TestClient</h2>
<p>Aqui vem a parte essencial: como testar esses endpoints de forma profissional.</p>
<h3>Configurando o TestClient do FastAPI</h3>
<p>Crie o arquivo <code>tests/conftest.py</code>:</p>
<pre><code class="language-python">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</code></pre>
<p>O <code>conftest.py</code> é um arquivo especial do pytest. As fixtures definidas aqui estão disponíveis para todos os testes do projeto. O <code>TestClient</code> é uma wrapper do httpx que sabe como testar FastAPI sem precisar de um servidor real em execução.</p>
<h3>Testando Endpoints com Casos de Sucesso</h3>
<p>Crie o arquivo <code>tests/test_api.py</code>:</p>
<pre><code class="language-python">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</code></pre>
<p>Estes testes cobrem o "caminho feliz" — quando tudo funciona corretamente. Note que cada teste é independente graças à fixture <code>limpar_banco_dados</code>.</p>
<h3>Testando Casos de Erro e Validação</h3>
<p>Agora vamos testar o que acontece quando algo dá errado:</p>
<pre><code class="language-python">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"</code></pre>
<p>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.</p>
<h3>Organizando Testes com Classes e Parametrização</h3>
<p>Conforme seu projeto cresce, agrupamentos lógicos ficam essenciais. Use classes para organizar testes relacionados:</p>
<pre><code class="language-python">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</code></pre>
<p>A parametrização com <code>@pytest.mark.parametrize</code> executa o mesmo teste múltiplas vezes com diferentes valores, reduzindo duplicação de código significativamente.</p>
<h2>Testes Assincronos com httpx</h2>
<p>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 <code>asyncio</code> nativamente — você pode usar httpx diretamente.</p>
<h3>Usando httpx para Requisições Assincronos</h3>
<p>Crie um arquivo <code>tests/test_api_async.py</code>:</p>
<pre><code class="language-python">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"</code></pre>
<p>Note que você precisa fazer <code>import asyncio</code> no topo do arquivo. A fixture <code>async_client</code> usa um context manager (<code>async with</code>) para garantir que a conexão seja fechada corretamente após cada teste.</p>
<h3>Quando Usar httpx vs TestClient</h3>
<ul>
<li>Use <strong>TestClient</strong> para a maioria dos testes de API FastAPI. É mais simples, mais rápido e integrado perfeitamente.</li>
<li>Use <strong>httpx</strong> quando precisar testar requisições paralelas, quando quiser testar contra servidores externos, ou quando estiver testando código que é inerentemente assincronista.</li>
</ul>
<p>A regra de ouro: comece com TestClient e migre para httpx apenas se tiver uma razão específica.</p>
<h2>Executando os Testes e Interpretando Resultados</h2>
<p>Você já tem os testes prontos. Agora precisa executá-los e entender os resultados.</p>
<h3>Executando com pytest</h3>
<p>Na raiz do projeto, execute:</p>
<pre><code class="language-bash"># 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</code></pre>
<h3>Interpretando Falhas</h3>
<p>Quando um teste falha, o pytest mostra:</p>
<pre><code>FAILED tests/test_api.py::test_criar_tarefa - AssertionError: assert 201 == 200</code></pre>
<p>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.</p>
<h3>Melhores Práticas de Assertion</h3>
<p>Escreva assertions claras e específicas:</p>
<pre><code class="language-python"></code></pre>
<p>Use pytest.raises para testar exceções (em casos raros onde isso faz sentido):</p>
<pre><code class="language-python">@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</code></pre>
<h2>Conclusão</h2>
<p>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.</p>
<p>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ã.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://fastapi.tiangolo.com/advanced/testing-dependencies/" target="_blank" rel="noopener noreferrer">FastAPI Testing Documentation</a></li>
<li><a href="https://docs.pytest.org/" target="_blank" rel="noopener noreferrer">pytest Official Documentation</a></li>
<li><a href="https://www.python-httpx.org/" target="_blank" rel="noopener noreferrer">httpx: A next-generation HTTP client</a></li>
<li><a href="https://realpython.com/python-testing/" target="_blank" rel="noopener noreferrer">Real Python: Getting Started with Testing</a></li>
<li><a href="https://github.com/tiangolo/fastapi" target="_blank" rel="noopener noreferrer">FastAPI Best Practices by Sebastián Ramírez</a></li>
</ul>
<p><!-- FIM --></p>