Capítulo 59, Backend
Testes de aplicações
Testar uma aplicação não é testar tudo do mesmo jeito. É escolher, para cada risco, o teste mais barato que o pegaria.
Código deste capítulo: backend/cap59_testes_aplicacao.py
A pirâmide
| Tipo | O que exercita | Velocidade | Quantos |
|---|---|---|---|
| Unitário | Uma regra de negócio isolada, sem rede nem banco | Milissegundos | Muitos |
| Integração | O seu código com uma peça real (um banco, uma rota HTTP) | Dezenas de milissegundos | Alguns |
| Ponta a ponta | O sistema inteiro, de fora, como um usuário | Segundos | Poucos |
A pirâmide existe por custo: os testes de baixo são rápidos e apontam o problema com precisão, e os de cima são lentos e dizem apenas "algo quebrou". Eu escrevo muitos dos primeiros e poucos dos últimos.
O que isolar: as fronteiras
Dependências que atravessam a fronteira do seu processo (gateway de pagamento, e-mail, relógio, rede) devem ser substituíveis. A regra de negócio recebe essas dependências como interfaces (Protocol), e o teste entrega uma versão falsa. É o que o capítulo de arquitetura mostrou, agora aplicado. O código a testar, um checkout que cobra e depois grava o pedido:
from dataclasses import dataclass
from typing import Annotated, Protocol
from unittest.mock import Mock
import pytest
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
class PagamentoRecusado(Exception):
pass
class GatewayPagamento(Protocol):
def cobrar(self, valor_centavos: int) -> str: ...
class RepositorioPedidos(Protocol):
def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int: ...
class ServicoCheckout:
def __init__(self, gateway: GatewayPagamento, repositorio: RepositorioPedidos) -> None:
self._gateway = gateway
self._repositorio = repositorio
def finalizar(self, cliente: str, precos: list[int]) -> int:
if not precos:
raise ValueError("carrinho vazio")
total = sum(precos)
transacao = self._gateway.cobrar(total)
return self._repositorio.salvar(cliente, total, transacao)
Fakes: implementações simples e honestas
Um fake é uma implementação de verdade, só que simples (guarda em memória). Ele é melhor do que um Mock na maioria dos casos, porque o teste verifica o resultado (o que ficou salvo), e não a forma como o código chamou. Testes presos à forma quebram a cada refatoração, mesmo quando o comportamento continua certo:
class GatewayFalso:
def __init__(self, recusar: bool = False) -> None:
self.recusar = recusar
self.cobrancas: list[int] = []
def cobrar(self, valor_centavos: int) -> str:
if self.recusar:
raise PagamentoRecusado("cartão recusado")
self.cobrancas.append(valor_centavos)
return f"tx-{len(self.cobrancas)}"
class RepositorioEmMemoria:
def __init__(self) -> None:
self.pedidos: list[tuple[str, int, str]] = []
def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int:
self.pedidos.append((cliente, total_centavos, transacao))
return len(self.pedidos)
Testes unitários
O primeiro teste cobre o caminho feliz. Os seguintes cobrem o que não pode acontecer: pagamento recusado não pode gravar pedido (senão o cliente tem um pedido que não pagou), e carrinho vazio não pode nem cobrar:
def test_finaliza_cobrando_e_salvando():
gateway, repositorio = GatewayFalso(), RepositorioEmMemoria()
pedido_id = ServicoCheckout(gateway, repositorio).finalizar("Ana", [1000, 550])
assert pedido_id == 1
assert gateway.cobrancas == [1550]
assert repositorio.pedidos == [("Ana", 1550, "tx-1")]
def test_pagamento_recusado_nao_grava_pedido():
repositorio = RepositorioEmMemoria()
with pytest.raises(PagamentoRecusado):
ServicoCheckout(GatewayFalso(recusar=True), repositorio).finalizar("Ana", [500])
assert repositorio.pedidos == []
def test_carrinho_vazio_nao_cobra():
gateway = GatewayFalso()
with pytest.raises(ValueError, match="vazio"):
ServicoCheckout(gateway, RepositorioEmMemoria()).finalizar("Ana", [])
assert gateway.cobrancas == []
@pytest.mark.parametrize("precos, esperado", [([100], 100), ([100, 200, 300], 600)])
def test_total_cobrado(precos, esperado):
gateway = GatewayFalso()
ServicoCheckout(gateway, RepositorioEmMemoria()).finalizar("Ana", precos)
assert gateway.cobrancas == [esperado]
Quando um Mock faz sentido
O Mock é a ferramenta certa quando o que importa é a interação: "o e-mail foi enviado exatamente uma vez, com este destinatário". Nesse caso, a chamada é o comportamento:
def test_com_mock_verifica_a_interacao():
gateway = Mock()
gateway.cobrar.return_value = "tx-9"
repositorio = Mock()
repositorio.salvar.return_value = 7
assert ServicoCheckout(gateway, repositorio).finalizar("Bia", [500]) == 7
gateway.cobrar.assert_called_once_with(500)
repositorio.salvar.assert_called_once_with("Bia", 500, "tx-9")
Testar a rota HTTP
Para a camada web, o TestClient e a troca de dependência (dependency_overrides ou, como aqui, uma fábrica que recebe o serviço) permitem verificar o contrato HTTP: o código de status de cada situação, e o corpo. Isso é um teste de integração entre o roteamento, a validação e o serviço:
class PedidoEntrada(BaseModel):
cliente: str
precos: list[int]
def montar_app(servico: ServicoCheckout) -> FastAPI:
app = FastAPI()
def obter_servico() -> ServicoCheckout:
return servico
@app.post("/checkout")
def checkout(
entrada: PedidoEntrada, svc: Annotated[ServicoCheckout, Depends(obter_servico)]
) -> dict[str, int]:
try:
return {"pedido_id": svc.finalizar(entrada.cliente, entrada.precos)}
except PagamentoRecusado as erro:
raise HTTPException(status_code=402, detail=str(erro)) from erro
except ValueError as erro:
raise HTTPException(status_code=422, detail=str(erro)) from erro
return app
def test_rota_feliz():
cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(), RepositorioEmMemoria())))
resposta = cliente.post("/checkout", json={"cliente": "Ana", "precos": [100, 200]})
assert resposta.status_code == 200
assert resposta.json() == {"pedido_id": 1}
def test_rota_pagamento_recusado_devolve_402():
cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(recusar=True), RepositorioEmMemoria())))
assert cliente.post("/checkout", json={"cliente": "Ana", "precos": [100]}).status_code == 402
def test_rota_carrinho_vazio_devolve_422():
cliente = TestClient(montar_app(ServicoCheckout(GatewayFalso(), RepositorioEmMemoria())))
assert cliente.post("/checkout", json={"cliente": "Ana", "precos": []}).status_code == 422
Integração com o banco
Os fakes provam a lógica, mas não provam que o SQL funciona. Um teste de integração usa o banco de verdade (no SQLite em memória, rápido) para verificar que o repositório real grava e lê. O truque é uma fixture que cria o esquema do zero a cada teste, e assim nenhum teste depende de outro:
class Base(DeclarativeBase):
pass
class PedidoLinha(Base):
__tablename__ = "pedidos_checkout"
id: Mapped[int] = mapped_column(primary_key=True)
cliente: Mapped[str]
total_centavos: Mapped[int]
transacao: Mapped[str]
class RepositorioSql:
def __init__(self, sessao: Session) -> None:
self._sessao = sessao
def salvar(self, cliente: str, total_centavos: int, transacao: str) -> int:
linha = PedidoLinha(cliente=cliente, total_centavos=total_centavos, transacao=transacao)
self._sessao.add(linha)
self._sessao.commit()
return linha.id
@pytest.fixture
def sessao():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as sessao_aberta:
yield sessao_aberta
def test_repositorio_sql_grava_de_verdade(sessao):
servico = ServicoCheckout(GatewayFalso(), RepositorioSql(sessao))
servico.finalizar("Ana", [100, 200])
servico.finalizar("Bia", [50])
assert sessao.scalar(select(func.sum(PedidoLinha.total_centavos))) == 350
assert sessao.scalar(select(func.count(PedidoLinha.id))) == 2
O que torna um teste confiável
| Princípio | Na prática |
|---|---|
| Determinístico | Nada de relógio, aleatoriedade ou rede reais: injete-os ou use fakes |
| Independente | Cada teste cria o seu estado e roda em qualquer ordem |
| Testa comportamento | Verifica o resultado, e não a implementação |
| Falha por um motivo só | O nome diz o que quebrou, e um teste só afirma uma coisa |
| Rápido | Se a suíte demora, ninguém a roda |
Cobertura não é qualidade
Um teste sem
assertaumenta a cobertura e não verifica nada. Eu uso a cobertura para descobrir o que não está testado, nunca como meta a atingir. E o teste de integração com o banco pega o que os fakes escondem: um SQL errado, uma restrição que falta, uma transação que não confirma.
Exercício 1
O gateway falha no meio
Escreva um teste com Mock(side_effect=PagamentoRecusado(...)) que mostre que, quando o pagamento é recusado, o repositório nunca é chamado.
Rodar os testes
O pytest encontra as funções test_* do arquivo, executa as fixtures e mostra um ponto por teste que passou:
pytest -q
........... [100%]
11 passed in 0.01s