Como testar uma API Flask com pytest e test_client
Teste uma API Flask sem iniciar servidor: use pytest, fixtures e test_client para validar JSON, erros HTTP e isolamento com exemplos executáveis.
Para testar uma API Flask com pytest, crie uma aplicação com TESTING=True, obtenha um app.test_client() e faça requisições como client.get() e client.post(json=...). Você verifica status HTTP e conteúdo JSON sem iniciar o servidor nem ocupar uma porta. Fixtures permitem criar uma aplicação nova para cada teste, evitando que dados de um caso contaminem o próximo.
Neste tutorial vamos testar uma pequena API de vagas Python: cadastro, listagem e validação de entrada. É um exercício útil para quem já fez o primeiro projeto Flask e quer adicionar testes demonstráveis ao portfólio de backend.
O que o test_client testa — e o que fica de fora
O cliente de testes passa requisições pela aplicação Flask, incluindo roteamento, funções de view e hooks registrados. Ele trabalha no próprio processo, sem fazer uma chamada HTTP pela rede.
| Ferramenta ou abordagem | O que verifica | Quando usar |
|---|---|---|
| pytest com funções isoladas | Regras e transformações sem HTTP | Testes unitários de lógica de negócio |
Flask test_client | Rotas, validações, respostas e integração dentro da aplicação | Desenvolvimento diário da API |
requests ou HTTPX contra servidor real | HTTP pela rede e serviço em execução | Smoke tests de ambiente isolado |
| Playwright | Fluxo no navegador, interface e interação | Testes de ponta a ponta do frontend |
test_client não valida TLS, configuração do proxy reverso nem se o processo subiu corretamente no deploy. Também não substitui testes do banco: se você simular a persistência, não estará verificando SQL nem constraints.
A proposta aqui é pequena de propósito: Flask, pytest e armazenamento em memória. Não há autenticação, banco ou serviço externo. Não publique esta API como um mural real de vagas sem adicionar esses controles.
1. Prepare o projeto
Use Python 3.10 ou superior e um ambiente virtual. No terminal:
mkdir flask-vagas-testes
cd flask-vagas-testes
python -m venv .venv
Ative o ambiente conforme o sistema:
# Linux ou macOS
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
Instale as dependências:
python -m pip install Flask pytest
A estrutura final será:
flask-vagas-testes/
├── app.py
└── tests/
├── conftest.py
└── test_vagas.py
Execute os comandos sempre da pasta flask-vagas-testes. Não dê ao seu arquivo o nome flask.py ou pytest.py: isso pode esconder o pacote instalado durante o import.
2. Crie a API com uma application factory
Salve este código completo em app.py:
from flask import Flask, jsonify, request
def create_app(config=None):
app = Flask(__name__)
if config is not None:
app.config.update(config)
# Cada chamada de create_app ganha sua própria lista.
vagas = []
@app.get("/vagas")
def listar_vagas():
return jsonify({"vagas": vagas})
@app.post("/vagas")
def criar_vaga():
if not request.is_json:
return jsonify({"erro": "Envie Content-Type application/json"}), 415
dados = request.get_json(silent=True)
if not isinstance(dados, dict):
return jsonify({"erro": "Envie um objeto JSON válido"}), 400
titulo = dados.get("titulo")
if not isinstance(titulo, str) or not titulo.strip():
return jsonify({"erro": "titulo deve ser um texto não vazio"}), 400
vaga = {"id": len(vagas) + 1, "titulo": titulo.strip()}
vagas.append(vaga)
return jsonify(vaga), 201
return app
Uma application factory é uma função que cria e configura uma instância da aplicação. Em vez de importar um único objeto global, o teste chama create_app() e recebe um estado novo.
A lista vagas está dentro da factory, não no topo do módulo. Isso faz diferença: uma lista global sobreviveria entre aplicações e poderia tornar os testes dependentes da ordem de execução. Esse repositório é apenas didático; os dados desaparecem ao recriar a aplicação e não são compartilhados entre workers.
A API distingue três situações:
201: uma vaga foi criada;400: o corpo JSON ou o título é inválido;415: o cliente não enviou um tipo de conteúdo JSON.
get_json(silent=True) retorna None quando não consegue interpretar o corpo. Como queremos um contrato explícito de erro, verificamos o resultado e devolvemos nossa resposta JSON. Uma lista JSON válida também é rejeitada: o contrato exige um objeto.
3. Configure fixtures para aplicação e cliente
Crie a pasta tests e salve este código em tests/conftest.py:
import pytest
from app import create_app
@pytest.fixture()
def app():
return create_app({"TESTING": True})
@pytest.fixture()
def client(app):
return app.test_client()
O pytest descobre fixtures em conftest.py automaticamente. Um teste que declara o parâmetro client recebe o resultado dessa fixture; não precisa importar conftest.
O escopo padrão é function: a cada teste, uma nova aplicação e um novo cliente são criados. Evite mudar para scope="session" só para economizar linhas ou alguns milissegundos; estado compartilhado pode introduzir falhas intermitentes.
TESTING=True permite, entre outros comportamentos, que exceções não tratadas cheguem ao teste em vez de serem escondidas atrás de uma resposta genérica de erro. Ele não desativa automaticamente autenticação, CSRF ou chamadas externas.
4. Teste listagem e criação com assertivas específicas
Salve em tests/test_vagas.py:
import pytest
def test_listagem_comeca_vazia(client):
resposta = client.get("/vagas")
assert resposta.status_code == 200
assert resposta.is_json
assert resposta.get_json() == {"vagas": []}
def test_cria_vaga_e_lista_resultado(client):
resposta = client.post(
"/vagas", json={"titulo": " Backend Python — remoto Brasil "}
)
assert resposta.status_code == 201
vaga = resposta.get_json()
assert vaga == {"id": 1, "titulo": "Backend Python — remoto Brasil"}
listagem = client.get("/vagas")
assert listagem.status_code == 200
assert listagem.get_json() == {"vagas": [vaga]}
@pytest.mark.parametrize("titulo", [None, "", " ", 123, ["Python"]])
def test_rejeita_titulo_invalido(client, titulo):
resposta = client.post("/vagas", json={"titulo": titulo})
assert resposta.status_code == 400
assert resposta.get_json() == {
"erro": "titulo deve ser um texto não vazio"
}
assert client.get("/vagas").get_json() == {"vagas": []}
def test_rejeita_titulo_ausente(client):
resposta = client.post("/vagas", json={})
assert resposta.status_code == 400
assert resposta.get_json()["erro"] == "titulo deve ser um texto não vazio"
O argumento json= serializa o objeto e configura o cabeçalho de conteúdo. Evite misturar json= e data= na mesma requisição.
Observe que o teste de criação não confere apenas 201: ele verifica o título normalizado e consulta a listagem. Assim detecta uma implementação que retorna sucesso, mas esquece de guardar o registro.
Nos testes negativos também conferimos que a lista continua vazia. Rejeitar a entrada depois de já alterar o estado seria um bug importante que uma assertiva de status sozinha não encontraria.
A parametrização transforma cinco entradas inválidas em cinco casos separados. Veja mais padrões no guia de testes com pytest.
5. Cubra JSON malformado e método incorreto
Acrescente os testes abaixo ao mesmo arquivo tests/test_vagas.py:
@pytest.mark.parametrize("corpo", ["{", "[]", "null"])
def test_rejeita_corpo_json_invalido(client, corpo):
resposta = client.post(
"/vagas", data=corpo, content_type="application/json"
)
assert resposta.status_code == 400
assert resposta.get_json() == {"erro": "Envie um objeto JSON válido"}
def test_rejeita_formulario(client):
resposta = client.post("/vagas", data={"titulo": "Python júnior"})
assert resposta.status_code == 415
assert resposta.get_json() == {
"erro": "Envie Content-Type application/json"
}
def test_metodo_nao_permitido(client):
resposta = client.delete("/vagas")
assert resposta.status_code == 405
def test_rota_inexistente(client):
resposta = client.get("/rota-inexistente")
assert resposta.status_code == 404
def test_nova_aplicacao_nao_herda_vagas(app, client):
from app import create_app
resposta = client.post("/vagas", json={"titulo": "Pessoa desenvolvedora Python"})
assert resposta.status_code == 201
assert len(client.get("/vagas").get_json()["vagas"]) == 1
outra_app = create_app({"TESTING": True})
outro_client = outra_app.test_client()
assert outro_client.get("/vagas").get_json() == {"vagas": []}
data= é útil quando queremos controlar o corpo cru e enviar conteúdo propositalmente malformado. { não é JSON válido; [] e null são JSON válido, mas não cumprem o contrato de objeto da nossa API.
Já os erros 404 e 405 são respostas padrão do Flask e podem vir em HTML. Por isso esses testes não chamam get_json() esperando nosso formato de erro. Se sua API promete JSON em todos os erros, implemente handlers e teste também esse contrato.
6. Execute e leia as falhas
Na raiz do projeto:
python -m pytest -q
Com os arquivos acima, a suíte deve mostrar 15 testes aprovados. A contagem inclui cada entrada parametrizada como um teste.
Para executar apenas os testes de título:
python -m pytest -q -k titulo
Para interromper na primeira falha e mostrar detalhes:
python -m pytest -x -v
Quando um teste falhar, compare o valor esperado com o recebido. Um 415 onde você esperava 201, por exemplo, pode indicar que enviou data= em vez de json=. Um 500 ou uma exceção propagada aponta para um problema na execução da rota, não necessariamente no pytest.
Para repetir exatamente o ambiente em outra máquina ou CI, registre as versões de dependências testadas no gerenciamento de pacotes do projeto. Não dependa apenas de instalar a versão mais recente a cada execução; o uv é uma opção para manter esse fluxo organizado.
Como adaptar para banco, login e serviços externos
Em uma aplicação real, preserve a factory e substitua a lista por uma camada de persistência. A fixture deve configurar um banco exclusivamente de testes antes de a aplicação inicializar suas extensões.
Cuidados essenciais:
- Banco: use dados sintéticos e limpe o estado entre testes. SQLite pode servir para alguns cenários, mas não reproduz todos os comportamentos do PostgreSQL. Valide operações importantes no mesmo motor usado em produção.
- Conexões: feche recursos no teardown, normalmente depois de
yieldna fixture. Emsqlite3, o contexto da conexão controla commit/rollback, mas não fecha a conexão automaticamente. - Login: teste uma requisição anônima e outra autenticada. Para login por sessão, faça o fluxo de login pelo cliente e confira se o acesso protegido é realmente autorizado.
- APIs externas: injete um cliente falso ou use
monkeypatchna fronteira de integração. Não faça a suíte diária depender da disponibilidade de um provedor nem enviar notificações reais. - Exceções: simule falhas de dependências e verifique o comportamento prometido. Não transforme todo erro em
200para facilitar o teste.
Se o objetivo for testar uma interface no navegador, continue com Playwright em Python. Para entender o próximo passo de persistência, veja SQLAlchemy.
Checklist antes de colocar os testes no portfólio
- A suíte roda com um comando documentado na raiz do projeto.
- Cada teste começa com estado conhecido e não depende do teste anterior.
- Entradas válidas, ausentes, malformadas e com tipo errado estão cobertas.
- Há assertivas de status e conteúdo relevante.
- Nenhum teste usa credenciais, dados pessoais ou banco de produção.
- Testes com mocks não são apresentados como prova de que uma integração real funciona.
- O CI executa a suíte antes de aceitar a mudança.
Para uma API Flask pequena, esse conjunto já demonstra uma habilidade concreta: definir o contrato HTTP e verificar automaticamente se ele continua funcionando. Depois, amplie conforme os riscos da aplicação — autorização, persistência e integração — em vez de acumular testes que apenas repetem o caminho feliz.