Chapter 59, Backend
Application testing
Testing an application is not testing everything the same way. It is choosing, for each risk, the cheapest test that would catch it.
Code for this chapter: backend/cap59_testes_aplicacao.py
The pyramid
| Type | What exercises | Speed | How many |
|---|---|---|---|
| Unit | An isolated business rule, without network or database | Milliseconds | Many |
| Integration | Your code with a real piece (a database, an HTTP route) | Dozens of milliseconds | Some |
| End to end | The entire system, from the outside, like a user | Seconds | Few |
The pyramid exists for cost: the lower tests are fast and pinpoint the problem accurately, while the upper ones are slow and only say "something broke". I write many of the former and few of the latter.
What to isolate: the boundaries
Dependencies that cross the boundary of your process (payment gateway, email, clock, network) must be replaceable. The business rule receives these dependencies as interfaces (Protocol), and the test delivers a fake version. This is what the architecture chapter showed, now applied. The code to test, a checkout that charges and then records the order:
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: simple and honest implementations
An fake is a real implementation, just simple (stores in memory). It is better than a Mock in most cases, because the test checks the result (what was saved), and not the form as the code called. Tests tied to the form break with every refactor, even when the behavior remains correct:
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)
Unit tests
The first test covers the happy path. The following ones cover what does not can happen: payment declined cannot record order (otherwise the customer has an order they didn't pay for), and empty cart cannot even charge:
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]
When a Mock makes sense
The Mock is the right tool when what matters is the interaction: "the email was sent exactly once, with this recipient". In this case, the call is the behavior:
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")
Testing the HTTP route
For the web layer, the TestClient and the dependency swap (dependency_overrides or, as here, a factory that receives the service) allow checking the HTTP contract: the status code of each situation, and the body. This is an integration test between routing, validation, and the service:
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
Integration with the database
Fakes prove the logic, but do not prove that SQL works. An integration test uses the real database (in-memory SQLite, fast) to verify that the real repository writes and reads. The trick is a fixture that creates the schema from scratch for each test, so no test depends on another:
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
What makes a test reliable
| Principle | In practice |
|---|---|
| Deterministic | No clocks, randomness, or real networks: inject them or use fakes |
| Independent | Each test creates its own state and runs in any order |
| Tests behavior | Checks the result, not the implementation |
| Fails for one reason only | The name indicates what broke, and a test only asserts one thing |
| Fast | If the suite takes too long, no one runs it |
Coverage is not quality
A test without
assertincreases coverage and verifies nothing. I use coverage to discover what is not tested, never as a goal to achieve. And the integration test with the database catches what the fakes hide: a wrong SQL, a missing constraint, a transaction that does not confirm.
Exercise 1
The gateway fails in the middle
Write a test with Mock(side_effect=PagamentoRecusado(...)) that shows that when the payment is declined, the repository is never is called.
Running the tests
Pytest finds the functions test_* in the file, executes the fixtures, and shows one point for each test that passed:
pytest -q
........... [100%]
11 passed in 0.01s