Como testar e documentar APIs Python
Teste suas APIs com pytest e documente com Swagger/OpenAPI para que outros devs consigam usar.
Por que testar APIs?
Testar APIs manualmente com curl ou Postman funciona durante o desenvolvimento, mas não escala. Conforme sua API cresce, você precisa garantir que alterações em uma rota não quebrem outras. Testes automatizados resolvem isso: rodam em segundos, cobrem dezenas de cenários e podem ser integrados ao seu pipeline de deploy.
Uma API sem testes é uma API que vai quebrar em produção. É só questão de tempo.
Configurando o ambiente de testes
pip install pytest
Vamos usar como base a API de tarefas que criamos nos artigos anteriores. Primeiro, precisamos reestruturar o código para que a aplicação Flask seja facilmente importável nos testes.
app.py (refatorado)
from flask import Flask, jsonify, request
def create_app():
app = Flask(__name__)
tarefas = [
{"id": 1, "titulo": "Estudar Flask", "descricao": "APIs REST", "concluida": False},
{"id": 2, "titulo": "Praticar Python", "descricao": "Exercicios", "concluida": True},
]
app.config["tarefas"] = tarefas
app.config["proximo_id"] = 3
@app.route("/api/tarefas", methods=["GET"])
def listar_tarefas():
return jsonify(app.config["tarefas"]), 200
@app.route("/api/tarefas/<int:tarefa_id>", methods=["GET"])
def buscar_tarefa(tarefa_id):
tarefa = next(
(t for t in app.config["tarefas"] if t["id"] == tarefa_id),
None
)
if tarefa is None:
return jsonify({"erro": "Tarefa nao encontrada"}), 404
return jsonify(tarefa), 200
@app.route("/api/tarefas", methods=["POST"])
def criar_tarefa():
dados = request.get_json()
if not dados or "titulo" not in dados:
return jsonify({"erro": "Campo 'titulo' e obrigatorio"}), 400
nova_tarefa = {
"id": app.config["proximo_id"],
"titulo": dados["titulo"],
"descricao": dados.get("descricao", ""),
"concluida": False
}
app.config["tarefas"].append(nova_tarefa)
app.config["proximo_id"] += 1
return jsonify(nova_tarefa), 201
@app.route("/api/tarefas/<int:tarefa_id>", methods=["PUT"])
def atualizar_tarefa(tarefa_id):
tarefa = next(
(t for t in app.config["tarefas"] if t["id"] == tarefa_id),
None
)
if tarefa is None:
return jsonify({"erro": "Tarefa nao encontrada"}), 404
dados = request.get_json()
if not dados or "titulo" not in dados:
return jsonify({"erro": "Campo 'titulo' e obrigatorio"}), 400
tarefa["titulo"] = dados["titulo"]
tarefa["descricao"] = dados.get("descricao", "")
tarefa["concluida"] = dados.get("concluida", False)
return jsonify(tarefa), 200
@app.route("/api/tarefas/<int:tarefa_id>", methods=["DELETE"])
def deletar_tarefa(tarefa_id):
tarefa = next(
(t for t in app.config["tarefas"] if t["id"] == tarefa_id),
None
)
if tarefa is None:
return jsonify({"erro": "Tarefa nao encontrada"}), 404
app.config["tarefas"] = [
t for t in app.config["tarefas"] if t["id"] != tarefa_id
]
return "", 204
return app
if __name__ == "__main__":
app = create_app()
app.run(debug=True)
Escrevendo os primeiros testes
O Flask fornece um test client embutido que simula requisições HTTP sem precisar iniciar o servidor. Crie um arquivo test_app.py:
Fixture do pytest
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app()
app.config["TESTING"] = True
with app.test_client() as client:
yield client
A fixture client cria uma nova instância da aplicação para cada teste. Isso garante isolamento — os dados manipulados em um teste não afetam outro.
Testando o endpoint GET (listar)
def test_listar_tarefas(client):
resposta = client.get("/api/tarefas")
assert resposta.status_code == 200
dados = resposta.get_json()
assert isinstance(dados, list)
assert len(dados) == 2
def test_listar_retorna_json(client):
resposta = client.get("/api/tarefas")
assert resposta.content_type == "application/json"
Testando o endpoint GET (buscar por ID)
def test_buscar_tarefa_existente(client):
resposta = client.get("/api/tarefas/1")
assert resposta.status_code == 200
dados = resposta.get_json()
assert dados["id"] == 1
assert dados["titulo"] == "Estudar Flask"
def test_buscar_tarefa_inexistente(client):
resposta = client.get("/api/tarefas/999")
assert resposta.status_code == 404
dados = resposta.get_json()
assert "erro" in dados
Testando o endpoint POST (criar)
def test_criar_tarefa_valida(client):
nova_tarefa = {
"titulo": "Nova tarefa de teste",
"descricao": "Criada pelo pytest"
}
resposta = client.post(
"/api/tarefas",
json=nova_tarefa
)
assert resposta.status_code == 201
dados = resposta.get_json()
assert dados["titulo"] == "Nova tarefa de teste"
assert dados["descricao"] == "Criada pelo pytest"
assert dados["concluida"] is False
assert "id" in dados
def test_criar_tarefa_sem_titulo(client):
resposta = client.post(
"/api/tarefas",
json={"descricao": "Sem titulo"}
)
assert resposta.status_code == 400
dados = resposta.get_json()
assert "erro" in dados
def test_criar_tarefa_sem_json(client):
resposta = client.post("/api/tarefas")
assert resposta.status_code == 400
def test_criar_tarefa_aparece_na_lista(client):
client.post("/api/tarefas", json={"titulo": "Tarefa extra"})
resposta = client.get("/api/tarefas")
dados = resposta.get_json()
assert len(dados) == 3
Testando o endpoint PUT (atualizar)
def test_atualizar_tarefa(client):
dados_atualizados = {
"titulo": "Tarefa atualizada",
"descricao": "Descricao nova",
"concluida": True
}
resposta = client.put("/api/tarefas/1", json=dados_atualizados)
assert resposta.status_code == 200
dados = resposta.get_json()
assert dados["titulo"] == "Tarefa atualizada"
assert dados["concluida"] is True
def test_atualizar_tarefa_inexistente(client):
resposta = client.put(
"/api/tarefas/999",
json={"titulo": "Nao existe"}
)
assert resposta.status_code == 404
def test_atualizar_tarefa_sem_titulo(client):
resposta = client.put(
"/api/tarefas/1",
json={"descricao": "Sem titulo"}
)
assert resposta.status_code == 400
Testando o endpoint DELETE
def test_deletar_tarefa(client):
resposta = client.delete("/api/tarefas/1")
assert resposta.status_code == 204
# verificar que foi realmente removida
resposta = client.get("/api/tarefas/1")
assert resposta.status_code == 404
def test_deletar_tarefa_inexistente(client):
resposta = client.delete("/api/tarefas/999")
assert resposta.status_code == 404
Testando autenticação
Se sua API usa JWT (como vimos no artigo anterior), também é preciso testar as rotas protegidas:
import jwt
from datetime import datetime, timedelta, timezone
def gerar_token_teste(app, user_id=1):
return jwt.encode(
{
"user_id": user_id,
"exp": datetime.now(timezone.utc) + timedelta(hours=1)
},
app.config.get("SECRET_KEY", "chave-teste"),
algorithm="HS256"
)
def test_rota_protegida_sem_token(client):
resposta = client.get("/api/perfil")
assert resposta.status_code == 401
def test_rota_protegida_com_token_valido(client):
token = gerar_token_teste(client.application)
resposta = client.get(
"/api/perfil",
headers={"Authorization": f"Bearer {token}"}
)
assert resposta.status_code == 200
def test_rota_protegida_com_token_expirado(client):
token = jwt.encode(
{
"user_id": 1,
"exp": datetime.now(timezone.utc) - timedelta(hours=1)
},
client.application.config.get("SECRET_KEY", "chave-teste"),
algorithm="HS256"
)
resposta = client.get(
"/api/perfil",
headers={"Authorization": f"Bearer {token}"}
)
assert resposta.status_code == 401
Rodando os testes
# rodar todos os testes
pytest test_app.py -v
# rodar com saida detalhada
pytest test_app.py -v --tb=short
# rodar apenas um teste especifico
pytest test_app.py::test_criar_tarefa_valida -v
# ver cobertura de codigo
pip install pytest-cov
pytest test_app.py --cov=app --cov-report=term-missing
Saída esperada:
test_app.py::test_listar_tarefas PASSED
test_app.py::test_listar_retorna_json PASSED
test_app.py::test_buscar_tarefa_existente PASSED
test_app.py::test_buscar_tarefa_inexistente PASSED
test_app.py::test_criar_tarefa_valida PASSED
test_app.py::test_criar_tarefa_sem_titulo PASSED
test_app.py::test_criar_tarefa_sem_json PASSED
test_app.py::test_criar_tarefa_aparece_na_lista PASSED
test_app.py::test_atualizar_tarefa PASSED
test_app.py::test_atualizar_tarefa_inexistente PASSED
test_app.py::test_deletar_tarefa PASSED
test_app.py::test_deletar_tarefa_inexistente PASSED
============ 12 passed in 0.15s ============
Documentação com Swagger/OpenAPI
Testes garantem que sua API funciona. Documentação garante que outros desenvolvedores consigam usá-la. O padrão OpenAPI (antes chamado Swagger) é o formato mais usado para documentar APIs REST.
O que é OpenAPI?
OpenAPI é uma especificação em YAML ou JSON que descreve todos os endpoints, parâmetros, respostas e modelos de dados da sua API. Com essa especificação, ferramentas geram interfaces visuais interativas automaticamente.
Documentação automática com FastAPI
O FastAPI gera documentação Swagger e ReDoc automaticamente, sem nenhuma configuração extra. Se você está começando um projeto novo e documentação é prioridade, considere o FastAPI.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI(
title="API de Tarefas",
description="API para gerenciar tarefas do dia a dia",
version="1.0.0"
)
class TarefaEntrada(BaseModel):
titulo: str
descricao: Optional[str] = ""
concluida: Optional[bool] = False
class TarefaSaida(BaseModel):
id: int
titulo: str
descricao: str
concluida: bool
tarefas = [
{"id": 1, "titulo": "Estudar FastAPI", "descricao": "Documentacao", "concluida": False}
]
proximo_id = 2
@app.get("/api/tarefas", response_model=list[TarefaSaida])
def listar_tarefas():
"""Retorna a lista completa de tarefas cadastradas."""
return tarefas
@app.get("/api/tarefas/{tarefa_id}", response_model=TarefaSaida)
def buscar_tarefa(tarefa_id: int):
"""Busca uma tarefa pelo ID. Retorna 404 se nao encontrada."""
tarefa = next((t for t in tarefas if t["id"] == tarefa_id), None)
if tarefa is None:
raise HTTPException(status_code=404, detail="Tarefa nao encontrada")
return tarefa
@app.post("/api/tarefas", response_model=TarefaSaida, status_code=201)
def criar_tarefa(tarefa: TarefaEntrada):
"""Cria uma nova tarefa. O campo titulo e obrigatorio."""
global proximo_id
nova = {
"id": proximo_id,
"titulo": tarefa.titulo,
"descricao": tarefa.descricao,
"concluida": tarefa.concluida
}
tarefas.append(nova)
proximo_id += 1
return nova
Rode com uvicorn app:app --reload e acesse:
- Swagger UI:
http://127.0.0.1:8000/docs - ReDoc:
http://127.0.0.1:8000/redoc
A documentação é gerada a partir dos type hints, dos modelos Pydantic e das docstrings das funções. Qualquer alteração no código atualiza a documentação automaticamente.
Documentando APIs Flask com flasgger
Se você está usando Flask e quer documentação Swagger, o flasgger é a opção mais direta.
pip install flasgger
from flask import Flask, jsonify, request
from flasgger import Swagger
app = Flask(__name__)
swagger = Swagger(app, template={
"info": {
"title": "API de Tarefas",
"description": "API REST para gerenciar tarefas",
"version": "1.0.0"
}
})
tarefas = [
{"id": 1, "titulo": "Estudar Flask", "descricao": "APIs REST", "concluida": False}
]
@app.route("/api/tarefas", methods=["GET"])
def listar_tarefas():
"""Listar todas as tarefas
---
responses:
200:
description: Lista de tarefas
schema:
type: array
items:
type: object
properties:
id:
type: integer
titulo:
type: string
descricao:
type: string
concluida:
type: boolean
"""
return jsonify(tarefas), 200
@app.route("/api/tarefas", methods=["POST"])
def criar_tarefa():
"""Criar uma nova tarefa
---
parameters:
- in: body
name: tarefa
required: true
schema:
type: object
required:
- titulo
properties:
titulo:
type: string
example: Minha nova tarefa
descricao:
type: string
example: Descricao da tarefa
responses:
201:
description: Tarefa criada com sucesso
400:
description: Dados invalidos
"""
dados = request.get_json()
if not dados or "titulo" not in dados:
return jsonify({"erro": "Campo 'titulo' e obrigatorio"}), 400
nova_tarefa = {
"id": len(tarefas) + 1,
"titulo": dados["titulo"],
"descricao": dados.get("descricao", ""),
"concluida": False
}
tarefas.append(nova_tarefa)
return jsonify(nova_tarefa), 201
if __name__ == "__main__":
app.run(debug=True)
Acesse http://127.0.0.1:5000/apidocs para ver a interface Swagger gerada automaticamente.
Documentando com flask-smorest
O flask-smorest oferece uma abordagem mais moderna, usando marshmallow para validação e documentação simultaneamente:
pip install flask-smorest marshmallow
from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint, abort
from marshmallow import Schema, fields
app = Flask(__name__)
app.config["API_TITLE"] = "API de Tarefas"
app.config["API_VERSION"] = "v1"
app.config["OPENAPI_VERSION"] = "3.0.2"
app.config["OPENAPI_URL_PREFIX"] = "/"
app.config["OPENAPI_SWAGGER_UI_PATH"] = "/docs"
app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/"
api = Api(app)
class TarefaSchema(Schema):
id = fields.Int(dump_only=True)
titulo = fields.Str(required=True)
descricao = fields.Str(load_default="")
concluida = fields.Bool(load_default=False)
blp = Blueprint("tarefas", __name__, url_prefix="/api/tarefas")
tarefas = []
proximo_id = 1
@blp.route("/")
class TarefaLista(MethodView):
@blp.response(200, TarefaSchema(many=True))
def get(self):
"""Listar todas as tarefas"""
return tarefas
@blp.arguments(TarefaSchema)
@blp.response(201, TarefaSchema)
def post(self, dados):
"""Criar nova tarefa"""
global proximo_id
tarefa = {
"id": proximo_id,
"titulo": dados["titulo"],
"descricao": dados.get("descricao", ""),
"concluida": dados.get("concluida", False)
}
tarefas.append(tarefa)
proximo_id += 1
return tarefa
api.register_blueprint(blp)
O flask-smorest gera a especificação OpenAPI 3.0 a partir dos schemas marshmallow, eliminando a duplicação entre validação e documentação.
Organizando testes em um projeto real
Para projetos maiores, organize os testes em uma pasta dedicada:
api-tarefas/
├── app/
│ ├── __init__.py
│ └── tarefas/
│ ├── __init__.py
│ └── routes.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_tarefas.py
│ └── test_auth.py
├── run.py
└── requirements.txt
tests/conftest.py
import pytest
from app import create_app
@pytest.fixture
def app():
app = create_app()
app.config["TESTING"] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
As fixtures definidas em conftest.py ficam disponíveis automaticamente para todos os arquivos de teste na mesma pasta.
Comparação: ferramentas de documentação
| Ferramenta | Framework | OpenAPI | Validação integrada | Dificuldade |
|---|---|---|---|---|
| FastAPI (nativo) | FastAPI | 3.0 | Sim (Pydantic) | Baixa |
| flasgger | Flask | 2.0/3.0 | Não | Baixa |
| flask-smorest | Flask | 3.0 | Sim (marshmallow) | Média |
| Manualmente | Qualquer | Qualquer | Não | Alta |
Resumo
| Conceito | O que aprendemos |
|---|---|
| pytest | Framework de testes para Python |
| test_client | Cliente de testes embutido do Flask |
| Fixtures | Funções que preparam o ambiente de teste |
| OpenAPI/Swagger | Especificação padrão para documentar APIs |
| flasgger | Documentação Swagger para Flask via docstrings |
| flask-smorest | Documentação OpenAPI 3.0 com marshmallow |
| FastAPI docs | Documentação automática via type hints |
Testar e documentar não são tarefas extras — são parte essencial do desenvolvimento de APIs profissionais. Uma API bem testada gera confiança para fazer alterações. Uma API bem documentada permite que outros desenvolvedores integrem rapidamente sem precisar ler seu código.