Pular para o conteúdo

    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

    TipoO que exercitaVelocidadeQuantos
    UnitárioUma regra de negócio isolada, sem rede nem bancoMilissegundosMuitos
    IntegraçãoO seu código com uma peça real (um banco, uma rota HTTP)Dezenas de milissegundosAlguns
    Ponta a pontaO sistema inteiro, de fora, como um usuárioSegundosPoucos

    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:

    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: 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:

    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)
    

    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:

    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]
    

    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:

    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")
    

    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:

    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
    

    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:

    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
    

    O que torna um teste confiável

    PrincípioNa prática
    DeterminísticoNada de relógio, aleatoriedade ou rede reais: injete-os ou use fakes
    IndependenteCada teste cria o seu estado e roda em qualquer ordem
    Testa comportamentoVerifica 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ápidoSe a suíte demora, ninguém a roda

    Cobertura não é qualidade

    Um teste sem assert aumenta 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:

    Terminal
    pytest -q
    
    Saída
    ...........                                                              [100%]
    11 passed in 0.01s