Skip to content

    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

    TypeWhat exercisesSpeedHow many
    UnitAn isolated business rule, without network or databaseMillisecondsMany
    IntegrationYour code with a real piece (a database, an HTTP route)Dozens of millisecondsSome
    End to endThe entire system, from the outside, like a userSecondsFew

    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:

    backend/cap59_testes_aplicacao.pylinhas 10 a 44
    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:

    backend/cap59_testes_aplicacao.pylinhas 49 a 67
    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:

    backend/cap59_testes_aplicacao.pylinhas 72 a 98
    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:

    backend/cap59_testes_aplicacao.pylinhas 103 a 110
    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:

    backend/cap59_testes_aplicacao.pylinhas 115 a 154
    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:

    backend/cap59_testes_aplicacao.pylinhas 159 a 196
    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

    PrincipleIn practice
    DeterministicNo clocks, randomness, or real networks: inject them or use fakes
    IndependentEach test creates its own state and runs in any order
    Tests behaviorChecks the result, not the implementation
    Fails for one reason onlyThe name indicates what broke, and a test only asserts one thing
    FastIf the suite takes too long, no one runs it

    Coverage is not quality

    A test without assert increases 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:

    Terminal
    pytest -q
    
    Output
    ...........                                                              [100%]
    11 passed in 0.01s