Uma função pode ter 100% de cobertura de testes unitários e a página que a chama ainda assim pode retornar um 500 em produção. Isso não é uma contradição. Testes unitários, de integração e end-to-end verificam coisas diferentes, e uma suíte forte em um e fraca em outro tem um buraco específico e previsível.

O que cada tipo de teste verifica: unitário, integração e end-to-end

Pegue uma funcionalidade de cadastro: uma função valida a entrada, grava uma linha em um banco de dados e envia um email de boas-vindas. Três testes podem cobrir isso, e cada um responde a uma pergunta diferente.

NívelPergunta que respondeO que é real, o que é substituído
UnitárioEssa função faz o que deveria fazer?Tudo ao redor da função é substituído: sem banco de dados real, sem rede real
IntegraçãoEssa função e uma dependência real funcionam juntas?O banco de dados é real; as partes ainda fora do limite testado (o servidor de email) são substituídas
End-to-endA requisição inteira funciona, vista de fora?Nada é substituído. Uma requisição HTTP real chega a um processo real em execução

Um teste unitário te diz que a validação do formato de email rejeita o que deveria rejeitar. Um teste de integração te diz que a linha realmente é gravada no banco com as colunas que você acha que ela tem. Um teste end-to-end te diz que um cliente consegue chamar o endpoint e receber um 201 de volta. Nenhum deles substitui os outros.

Testando uma funcionalidade do Flask nos três níveis

Aqui está a funcionalidade: uma função sign_up que valida um email, cria um usuário e envia uma mensagem de boas-vindas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# app.py
import os
import smtplib
import sqlite3

from flask import Flask, jsonify, request


def get_db():
    db_path = os.environ.get("DB_PATH", "app.db")
    return sqlite3.connect(db_path)


def get_smtp_client():
    host = os.environ.get("SMTP_HOST", "localhost")
    port = int(os.environ.get("SMTP_PORT", "25"))
    return smtplib.SMTP(host, port)


def init_db(db):
    db.execute("CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT)")
    db.commit()


def create_user(db, email, name):
    db.execute("INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
    db.commit()


def send_welcome_email(smtp_client, to_address, name):
    smtp_client.sendmail(
        "[email protected]",
        to_address,
        f"Subject: Welcome\n\nHi {name}, thanks for signing up.",
    )


def sign_up(db, smtp_client, email, name):
    if not email or "@" not in email:
        raise ValueError("invalid email")
    create_user(db, email, name)
    send_welcome_email(smtp_client, email, name)
    return {"email": email, "name": name, "status": "created"}


app = Flask(__name__)


@app.post("/signup")
def signup_route():
    data = request.get_json()
    db = get_db()
    init_db(db)
    smtp_client = get_smtp_client()
    result = sign_up(db, smtp_client, data["email"], data["name"])
    smtp_client.quit()
    return jsonify(result), 201


@app.get("/users/<email>")
def get_user_route(email):
    db = get_db()
    row = db.execute("SELECT email, name FROM users WHERE email = ?", (email,)).fetchone()
    if row is None:
        return jsonify({"error": "not found"}), 404
    return jsonify({"email": row[0], "name": row[1]})

sign_up recebe db e smtp_client como argumentos em vez de usar uma conexão global. Essa escolha é o que torna possíveis os três testes abaixo: cada um passa algo diferente para esses dois parâmetros. get_db() e get_smtp_client() leem seu destino de uma variável de ambiente a cada chamada em vez de fixá-lo de uma vez por todas, e é isso que permite ao teste end-to-end mais adiante apontar a aplicação em execução para um banco de dados descartável e um servidor de email de teste local sem tocar em app.py.

Teste unitário: substitua as duas dependências

Um teste unitário isola sign_up completamente. Nem o banco de dados nem o cliente SMTP são reais: ambos são objetos Mock da biblioteca padrão do Python.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test_unit.py
from unittest.mock import Mock

import pytest

from app import sign_up


def test_sign_up_rejects_invalid_email():
    db, smtp_client = Mock(), Mock()
    with pytest.raises(ValueError):
        sign_up(db, smtp_client, "not-an-email", "Ada")
    db.execute.assert_not_called()


def test_sign_up_sends_welcome_email():
    db, smtp_client = Mock(), Mock()
    sign_up(db, smtp_client, "[email protected]", "Ada")
    smtp_client.sendmail.assert_called_once()
    to_address = smtp_client.sendmail.call_args.args[1]
    assert to_address == "[email protected]"

Dois tipos de test double entram em ação aqui, e os nomes importam porque estabelecem expectativas diferentes. db no primeiro teste está perto de um dummy: é passado porque a assinatura exige algo, e o teste só se importa que ele nunca seja tocado. smtp_client no segundo teste é um mock no sentido estrito. O teste define a expectativa (sendmail é chamado com esse endereço) e falha se a realidade não bater. Isso é verificação de comportamento: você confere que uma chamada aconteceu, não o que ela retornou.

Esses testes rodam em milissegundos e nunca tocam disco ou socket. Também não conseguem te dizer se INSERT INTO users bate com as colunas reais da tabela. db nunca executa nada de verdade, então um erro de digitação no SQL passaria em todo teste unitário que você escrever.

Teste de integração: um banco de dados real, um servidor de email falso

Um teste de integração fecha essa lacuna uma dependência de cada vez. Aqui o banco de dados é real, uma conexão SQLite em memória com o esquema de verdade, enquanto o servidor de email continua substituído. Dessa vez é um fake: uma implementação pequena que funciona, em vez de um dublê com respostas predefinidas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test_integration.py
import sqlite3

from app import init_db, sign_up


class FakeSMTP:
    def __init__(self):
        self.sent = []

    def sendmail(self, from_addr, to_addr, message):
        self.sent.append((to_addr, message))


def test_sign_up_persists_user_and_records_email():
    db = sqlite3.connect(":memory:")
    init_db(db)
    smtp_client = FakeSMTP()

    sign_up(db, smtp_client, "[email protected]", "Grace")

    row = db.execute(
        "SELECT email, name FROM users WHERE email = ?", ("[email protected]",)
    ).fetchone()
    assert row == ("[email protected]", "Grace")
    assert smtp_client.sent[0][0] == "[email protected]"

FakeSMTP não tem expectativas definidas de antemão como tinha smtp_client.sendmail.assert_called_once(). Ele mantém uma lista real, ainda que minúscula, do que foi enviado, e o teste a lê depois. Essa é a diferença entre um mock e um fake: a um mock você diz o que esperar antes de o código rodar; um fake se comporta como uma versão menor da coisa real, e você o inspeciona depois.

Esse teste pega um nome de coluna errado ou uma tabela faltando. Não pega um bug no parsing de requisições do Flask nem uma URL errada no decorator da rota, porque nada aqui passa por HTTP.

Teste end-to-end: uma requisição real contra um processo em execução

Um teste end-to-end elimina as duas substituições. Ele inicia a aplicação Flask de verdade como um processo separado, manda para ela uma requisição HTTP real por um socket real, e verifica o resultado como um cliente externo faria: chamando a API de volta, não vasculhando o banco de dados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# test_e2e.py
import multiprocessing
import os
import tempfile
import time

import requests
from aiosmtpd.controller import Controller

from app import app


class AcceptAllHandler:
    async def handle_DATA(self, server, session, envelope):
        return "250 Message accepted for delivery"


def run_server():
    app.run(port=5055)


def test_signup_end_to_end():
    db_fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(db_fd)
    os.environ["DB_PATH"] = db_path
    os.environ["SMTP_HOST"] = "127.0.0.1"
    os.environ["SMTP_PORT"] = "1025"

    mail_server = Controller(AcceptAllHandler(), hostname="127.0.0.1", port=1025)
    mail_server.start()

    server = multiprocessing.Process(target=run_server)
    server.start()
    time.sleep(0.5)  # da um tempo para o servidor de dev do Flask ocupar a porta

    try:
        signup = requests.post(
            "http://127.0.0.1:5055/signup",
            json={"email": "[email protected]", "name": "Amelia"},
        )
        assert signup.status_code == 201

        lookup = requests.get("http://127.0.0.1:5055/users/[email protected]")
        assert lookup.json() == {"email": "[email protected]", "name": "Amelia"}
    finally:
        server.terminate()
        mail_server.stop()
        os.remove(db_path)

aiosmtpd faz o papel do coletor de emails que você rodaria em um ambiente de staging: algo que realmente fala SMTP por um socket, para que a chamada real smtplib.SMTP(...) da aplicação tenha para onde se conectar. O teste também cria para si um arquivo de banco de dados descartável em vez de reaproveitar app.db: rodar esse teste duas vezes contra um arquivo compartilhado falharia na segunda vez na mesma PRIMARY KEY que [email protected] já ocupa, exatamente o tipo de instabilidade por estado residual de que o artigo trata mais adiante. Nada nesse teste sabe que sign_up ou create_user existem. Ele só conhece o contrato HTTP. Renomeie uma rota ou mude o formato do JSON, e é esse teste que falha, enquanto os testes unitário e de integração acima continuam passando.

Instale as dependências com pip install flask requests aiosmtpd pytest e rode os três com pytest. Quando algo quebra, o arquivo que falha te diz qual classe de bug você tem. Esse é o motivo para escrever mais de um nível; a porcentagem de cobertura não é.

Pirâmide de testes vs troféu de testes

A forma clássica para uma suíte de testes é a pirâmide de testes de Mike Cohn: uma base larga de testes unitários rápidos, uma camada menor de testes de integração, e um topo fino de testes end-to-end lentos e caros. O que importa é a proporção, não a contagem. A maioria dos seus testes deveria ser do tipo barato.

O troféu de testes de Kent C. Dodds discorda de onde deve ir o peso. O argumento dele: testes unitários isolados são baratos de escrever e passar, mas a confiança que dão não reflete como o software é usado de verdade, então uma suíte feita sobretudo de testes unitários pode deixar passar bugs reais nas junções. O troféu coloca a análise estática (linting, checagem de tipos) na base, uma camada unitária acima, testes de integração como a parte mais larga, e uma camada end-to-end fina no topo. test_sign_up_persists_user_and_records_email acima é o tipo de teste que o troféu quer em maior quantidade.

Pirâmide (Cohn)Troféu (Dodds)
Camada maiorUnitárioIntegração
Camada da baseTestes unitáriosAnálise estática (lint, tipos)
Pergunta que guiaQuão barato é escrever e rodar esse teste?Quanto esse teste se parece com o uso real?
Ponto fraco que atacaSuítes end-to-end lentas e frágeisTestes unitários que passam enquanto as junções estão quebradas

Não siga nenhuma das duas formas ao pé da letra. Elas concordam na ordem: testes end-to-end são os mais caros e deveriam ser os menos numerosos, testes unitários são os mais baratos. Para onde vai a maior parte do seu esforço depende do histórico de bugs do seu projeto. Se a maioria dos bugs são componentes se comportando mal sozinhos, incline-se para a pirâmide. Se a maioria são componentes que não concordam sobre como conversar entre si, incline-se para o troféu.

Por que testes end-to-end ficam instáveis

Uma suíte com uma pilha crescente de testes end-to-end acaba ganhando uma reputação: você roda de novo o que falhou e ele passa. Isso é instabilidade (flakiness), e vem da mesma propriedade que torna os testes end-to-end valiosos. Eles tocam infraestrutura real.

As causas mais comuns são um sleep() fixo que é suficiente no seu notebook e curto demais em um runner de CI sob carga, dados de teste deixados por uma execução anterior colidindo com a seguinte, uma chamada de rede para algo fora do seu controle, e estado compartilhado entre testes que só aparece quando eles rodam em outra ordem ou em paralelo. O time.sleep(0.5) no teste acima é do primeiro tipo; o arquivo temporário que esse mesmo teste cria para DB_PATH é uma correção direta para o segundo. Nenhum desses é um bug na funcionalidade. São bugs em como o teste lida com infraestrutura real, um problema que testes unitários nunca têm.

Rodar de novo até ficar verde não é a solução; isso esconde a próxima falha real no meio do ruído. Mantenha a camada end-to-end fina cobrindo os caminhos críticos, não cada ramificação. Dê a cada teste seus próprios dados em vez de fixtures compartilhadas. Substitua esperas fixas por um polling na condição de que você realmente precisa, como a porta aceitando conexões. O tempo de CI é o outro custo: se você já acelerou o pipeline fazendo cache de dependências no GitHub Actions, uma camada end-to-end que precisa de três novas tentativas para ficar verde devolve boa parte desse tempo.

Qual teste escrever a seguir

Se um bug acabou de escapar para produção, pergunte qual nível teria pego antes de adicionar qualquer teste. Um cálculo errado ou um caso extremo não tratado em uma função pede um teste unitário. Duas partes do seu próprio código que discordam sobre um formato ou um contrato, como a API retornando um campo que o frontend não espera ou uma query que assume uma coluna que foi renomeada, pedem um teste de integração. Uma funcionalidade que passa em todos os testes mas falha para um usuário real geralmente está sem cobertura end-to-end do caminho que esse usuário seguiu.

Escreva primeiro o teste unitário. É o sinal mais barato e o mais rápido para te dizer quando você quebrou a lógica. Adicione um teste de integração quando duas das suas próprias partes precisam provar que concordam entre si. Reserve os testes end-to-end para o punhado de fluxos em que “funciona” só significa algo visto de fora: cadastro, login, checkout, redefinição de senha.

Mantenha essa separação e você tem uma suíte cujas falhas significam algo. Isso importa além da CI. O Git bisect com git bisect run precisa de um comando de teste que falhe só quando o código está realmente quebrado, e um teste end-to-end instável vai mandá-lo para o commit errado.

Artigos relacionados