Python

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

20 min de leitura

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

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

<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=&quot;API de Tarefas&quot;)

class Tarefa(BaseModel):

id: Optional[int] = None

titulo: str

descricao: str = &quot;&quot;

concluida: bool = False

Simulando um banco de dados em memória

tarefas_db = []

tarefa_id_counter = 1

@app.get(&quot;/&quot;)

async def root():

return {&quot;mensagem&quot;: &quot;Bem-vindo à API de Tarefas&quot;}

@app.post(&quot;/tarefas&quot;, 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(&quot;/tarefas&quot;, 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(&quot;/tarefas/{tarefa_id}&quot;, 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=&quot;Tarefa não encontrada&quot;)

@app.put(&quot;/tarefas/{tarefa_id}&quot;, 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=&quot;Tarefa não encontrada&quot;)

@app.delete(&quot;/tarefas/{tarefa_id}&quot;)

async def deletar_tarefa(tarefa_id: int):

for i, tarefa in enumerate(tarefas_db):

if tarefa.id == tarefa_id:

tarefas_db.pop(i)

return {&quot;mensagem&quot;: &quot;Tarefa deletada com sucesso&quot;}

raise HTTPException(status_code=404, detail=&quot;Tarefa não encontrada&quot;)</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=&quot;function&quot;)

def client():

&quot;&quot;&quot;

Fixture que fornece um TestClient para cada teste.

scope=&quot;function&quot; garante que o estado é resetado entre testes.

&quot;&quot;&quot;

return TestClient(app)

@pytest.fixture(autouse=True)

def limpar_banco_dados():

&quot;&quot;&quot;

Fixture que limpa o banco de dados em memória antes de cada teste.

autouse=True faz isso rodar automaticamente.

&quot;&quot;&quot;

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):

&quot;&quot;&quot;Testa se a rota raiz retorna 200 e a mensagem correta.&quot;&quot;&quot;

response = client.get(&quot;/&quot;)

assert response.status_code == 200

assert response.json() == {&quot;mensagem&quot;: &quot;Bem-vindo à API de Tarefas&quot;}

def test_criar_tarefa(client):

&quot;&quot;&quot;Testa criação de uma tarefa com dados válidos.&quot;&quot;&quot;

payload = {

&quot;titulo&quot;: &quot;Estudar FastAPI&quot;,

&quot;descricao&quot;: &quot;Aprender testes com pytest&quot;,

&quot;concluida&quot;: False

}

response = client.post(&quot;/tarefas&quot;, json=payload)

assert response.status_code == 200

data = response.json()

assert data[&quot;titulo&quot;] == &quot;Estudar FastAPI&quot;

assert data[&quot;id&quot;] == 1 # ID atribuído automaticamente

assert data[&quot;concluida&quot;] is False

def test_listar_tarefas_vazio(client):

&quot;&quot;&quot;Testa listagem quando nenhuma tarefa existe.&quot;&quot;&quot;

response = client.get(&quot;/tarefas&quot;)

assert response.status_code == 200

assert response.json() == []

def test_listar_tarefas_com_dados(client):

&quot;&quot;&quot;Testa listagem após criar tarefas.&quot;&quot;&quot;

Cria duas tarefas

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Tarefa 1&quot;, &quot;descricao&quot;: &quot;&quot;})

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Tarefa 2&quot;, &quot;descricao&quot;: &quot;&quot;})

response = client.get(&quot;/tarefas&quot;)

assert response.status_code == 200

tarefas = response.json()

assert len(tarefas) == 2

assert tarefas[0][&quot;titulo&quot;] == &quot;Tarefa 1&quot;

assert tarefas[1][&quot;titulo&quot;] == &quot;Tarefa 2&quot;

def test_obter_tarefa_especifica(client):

&quot;&quot;&quot;Testa recuperação de uma tarefa pelo ID.&quot;&quot;&quot;

Cria uma tarefa

criar_response = client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Tarefa Test&quot;})

tarefa_id = criar_response.json()[&quot;id&quot;]

Recupera essa tarefa

response = client.get(f&quot;/tarefas/{tarefa_id}&quot;)

assert response.status_code == 200

data = response.json()

assert data[&quot;id&quot;] == tarefa_id

assert data[&quot;titulo&quot;] == &quot;Tarefa Test&quot;

def test_atualizar_tarefa(client):

&quot;&quot;&quot;Testa atualização de uma tarefa existente.&quot;&quot;&quot;

Cria uma tarefa

criar_response = client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Original&quot;, &quot;concluida&quot;: False})

tarefa_id = criar_response.json()[&quot;id&quot;]

Atualiza a tarefa

payload = {

&quot;titulo&quot;: &quot;Atualizada&quot;,

&quot;descricao&quot;: &quot;Descrição nova&quot;,

&quot;concluida&quot;: True

}

response = client.put(f&quot;/tarefas/{tarefa_id}&quot;, json=payload)

assert response.status_code == 200

data = response.json()

assert data[&quot;titulo&quot;] == &quot;Atualizada&quot;

assert data[&quot;concluida&quot;] is True

def test_deletar_tarefa(client):

&quot;&quot;&quot;Testa exclusão de uma tarefa.&quot;&quot;&quot;

Cria uma tarefa

criar_response = client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Para Deletar&quot;})

tarefa_id = criar_response.json()[&quot;id&quot;]

Deleta a tarefa

response = client.delete(f&quot;/tarefas/{tarefa_id}&quot;)

assert response.status_code == 200

Verifica que foi realmente deletada

get_response = client.get(f&quot;/tarefas/{tarefa_id}&quot;)

assert get_response.status_code == 404</code></pre>

<p>Estes testes cobrem o &quot;caminho feliz&quot; — 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):

&quot;&quot;&quot;Testa erro 404 ao tentar acessar tarefa que não existe.&quot;&quot;&quot;

response = client.get(&quot;/tarefas/999&quot;)

assert response.status_code == 404

data = response.json()

assert &quot;detail&quot; in data

assert &quot;não encontrada&quot; in data[&quot;detail&quot;]

def test_atualizar_tarefa_inexistente(client):

&quot;&quot;&quot;Testa erro 404 ao tentar atualizar tarefa que não existe.&quot;&quot;&quot;

payload = {&quot;titulo&quot;: &quot;Nova&quot;, &quot;descricao&quot;: &quot;&quot;, &quot;concluida&quot;: False}

response = client.put(&quot;/tarefas/999&quot;, json=payload)

assert response.status_code == 404

def test_deletar_tarefa_inexistente(client):

&quot;&quot;&quot;Testa erro 404 ao tentar deletar tarefa que não existe.&quot;&quot;&quot;

response = client.delete(&quot;/tarefas/999&quot;)

assert response.status_code == 404

def test_criar_tarefa_campo_obrigatorio_faltando(client):

&quot;&quot;&quot;Testa validação: titulo é obrigatório.&quot;&quot;&quot;

payload = {&quot;descricao&quot;: &quot;Sem titulo&quot;}

response = client.post(&quot;/tarefas&quot;, json=payload)

assert response.status_code == 422 # Validation Error

data = response.json()

assert &quot;detail&quot; in data

def test_listar_tarefas_filtro_concluidas(client):

&quot;&quot;&quot;Testa filtro por status de conclusão.&quot;&quot;&quot;

Cria tarefas com status diferentes

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Completa&quot;, &quot;concluida&quot;: True})

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;Incompleta&quot;, &quot;concluida&quot;: False})

Filtra apenas as completas

response = client.get(&quot;/tarefas?concluida=true&quot;)

assert response.status_code == 200

tarefas = response.json()

assert len(tarefas) == 1

assert tarefas[0][&quot;titulo&quot;] == &quot;Completa&quot;</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:

&quot;&quot;&quot;Agrupa testes relacionados à criação de tarefas.&quot;&quot;&quot;

def test_criar_com_todos_campos(self, client):

payload = {

&quot;titulo&quot;: &quot;Tarefa Completa&quot;,

&quot;descricao&quot;: &quot;Com descrição&quot;,

&quot;concluida&quot;: False

}

response = client.post(&quot;/tarefas&quot;, json=payload)

assert response.status_code == 200

assert all(key in response.json() for key in [&quot;id&quot;, &quot;titulo&quot;, &quot;descricao&quot;, &quot;concluida&quot;])

def test_criar_com_apenas_titulo(self, client):

payload = {&quot;titulo&quot;: &quot;Minimalista&quot;}

response = client.post(&quot;/tarefas&quot;, json=payload)

assert response.status_code == 200

data = response.json()

assert data[&quot;descricao&quot;] == &quot;&quot;

assert data[&quot;concluida&quot;] is False

class TestFiltrosDeTarefas:

&quot;&quot;&quot;Agrupa testes relacionados a filtros e listagem.&quot;&quot;&quot;

@pytest.mark.parametrize(&quot;concluida,esperado&quot;, [

(True, 2),

(False, 1),

(None, 3),

])

def test_filtro_por_conclusao(self, client, concluida, esperado):

&quot;&quot;&quot;Parametrizado para testar múltiplos valores de filtro.&quot;&quot;&quot;

Setup: cria 2 concluídas e 1 não concluída

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;T1&quot;, &quot;concluida&quot;: True})

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;T2&quot;, &quot;concluida&quot;: True})

client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: &quot;T3&quot;, &quot;concluida&quot;: False})

Act

url = &quot;/tarefas&quot; if concluida is None else f&quot;/tarefas?concluida={str(concluida).lower()}&quot;

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():

&quot;&quot;&quot;Fixture que fornece um cliente httpx assincronista.&quot;&quot;&quot;

async with httpx.AsyncClient(app=app, base_url=&quot;http://test&quot;) as client:

yield client

@pytest.mark.asyncio

async def test_criar_multiplas_tarefas_paralelo(async_client):

&quot;&quot;&quot;

Demonstra requisições assincronistas em paralelo.

Isso é muito mais rápido do que fazer requisições sequenciais.

&quot;&quot;&quot;

Cria 5 tarefas simultaneamente

tasks = [

async_client.post(&quot;/tarefas&quot;, json={&quot;titulo&quot;: f&quot;Tarefa {i}&quot;})

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):

&quot;&quot;&quot;Exemplo de múltiplas requisições que dependem uma da outra.&quot;&quot;&quot;

Cria uma tarefa

criar_response = await async_client.post(

&quot;/tarefas&quot;,

json={&quot;titulo&quot;: &quot;Async Test&quot;, &quot;descricao&quot;: &quot;Testando async&quot;}

)

tarefa_id = criar_response.json()[&quot;id&quot;]

Lista todas as tarefas

listar_response = await async_client.get(&quot;/tarefas&quot;)

assert len(listar_response.json()) == 1

Obtém a tarefa específica

obter_response = await async_client.get(f&quot;/tarefas/{tarefa_id}&quot;)

assert obter_response.status_code == 200

assert obter_response.json()[&quot;titulo&quot;] == &quot;Async Test&quot;</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():

&quot;&quot;&quot;Testa se FastAPI levanta erro de validação corretamente.&quot;&quot;&quot;

client = TestClient(app)

response = client.post(&quot;/tarefas&quot;, 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>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Python

Tipos Avançados em Python: Generic, Protocol, TypeVar e ParamSpec na Prática
Tipos Avançados em Python: Generic, Protocol, TypeVar e ParamSpec na Prática

Introdução aos Tipos Avançados em Python Python é uma linguagem dinamicamente...

Como Usar pytest em Python: Fundamentos, Fixtures e Organização de Testes em Produção
Como Usar pytest em Python: Fundamentos, Fixtures e Organização de Testes em Produção

Introdução ao pytest: Por Que Abandonar print() nos Testes Quando comecei a p...

Herança e Polimorfismo em Python: MRO e super() na Prática
Herança e Polimorfismo em Python: MRO e super() na Prática

Fundamentos de Herança em Python A herança é um dos pilares da Programação Or...