Una funzione può avere il 100% di copertura dei test unitari e la pagina che la chiama può comunque restituire un 500 in produzione. Non è una contraddizione. Test unitari, test di integrazione e test end-to-end controllano cose diverse, e una suite pesante su uno e leggera su un altro ha un buco specifico e prevedibile.

Cosa controllano test unitari, di integrazione ed end-to-end

Prendi una funzionalità di registrazione: una funzione valida l’input, scrive una riga su un database e invia un’email di benvenuto. Tre test possono coprirla, e ognuno risponde a una domanda diversa.

LivelloDomanda a cui rispondeCosa è reale, cosa è sostituito
UnitarioQuesta singola funzione fa quello che deve fare?Tutto ciò che circonda la funzione è sostituito: nessun database reale, nessuna rete reale
IntegrazioneQuesta funzione e una dipendenza reale funzionano insieme?Il database è reale; le parti ancora fuori dal confine che stai testando (il server di posta) sono sostituite
End-to-endL’intera richiesta funziona, vista dall’esterno?Niente è sostituito. Una vera richiesta HTTP colpisce un vero processo in esecuzione

Un test unitario ti dice che il controllo del formato email rifiuta ciò che deve rifiutare. Un test di integrazione ti dice che la riga finisce davvero nel database con le colonne che pensi abbia. Un test end-to-end ti dice che un client può chiamare l’endpoint e ottenere un 201 in risposta. Nessuno dei tre può sostituire gli altri.

Testare una funzionalità Flask sui tre livelli

Ecco la funzionalità: una funzione sign_up che valida un’email, scrive un utente e invia un messaggio di benvenuto.

 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 prende db e smtp_client come argomenti invece di usare una connessione globale. Questa scelta rende possibili tutti e tre i test qui sotto: ognuno passa qualcosa di diverso per quei due parametri. get_db() e get_smtp_client() leggono il proprio target da una variabile d’ambiente a ogni chiamata invece di fissarlo una volta per tutte nel codice, ed è questo che permette al test end-to-end più avanti di puntare l’app in esecuzione verso un database usa e getta e un server di posta di test locale senza toccare app.py.

Test unitario: sostituisci entrambe le dipendenze

Un test unitario isola completamente sign_up. Né il database né il client SMTP sono reali: entrambi sono oggetti Mock della libreria standard di 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]"

Qui entrano in gioco due tipi di test double, e i nomi contano perché stabiliscono aspettative diverse. db nel primo test è vicino a un dummy: viene passato perché la firma richiede qualcosa, e al test importa solo che non venga mai toccato. smtp_client nel secondo test è un mock in senso stretto. Il test definisce l’aspettativa (sendmail viene chiamato con questo indirizzo) e fallisce se la realtà non corrisponde. Questa è verifica del comportamento: controlli che una chiamata sia avvenuta, non il valore di ritorno.

Questi test girano in millisecondi e non toccano mai disco o socket. Non possono però dirti se INSERT INTO users corrisponde alle colonne reali della tabella. db non esegue mai nulla di vero, quindi un errore di battitura nell’SQL passerebbe ogni test unitario che scrivi.

Test di integrazione: un database reale, un server di posta finto

Un test di integrazione chiude quel divario una dipendenza alla volta. Qui il database è reale, una connessione SQLite in memoria con lo schema effettivo, mentre il server di posta è ancora sostituito. Questa volta è un fake: una piccola implementazione funzionante, non un sostituto con risposte già scritte.

 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 non ha aspettative già scritte al proprio interno come faceva smtp_client.sendmail.assert_called_once(). Mantiene una lista reale, per quanto minuscola, di cosa è stato inviato, e il test la legge dopo. Questa è la differenza tra un mock e un fake: a un mock dici cosa aspettarti prima che il codice giri; un fake si comporta come una versione più piccola della cosa reale e la ispezioni dopo.

Questo test cattura un nome di colonna sbagliato o una tabella mancante. Non cattura un bug nel parsing delle richieste di Flask o un URL sbagliato nel decoratore della route, perché qui niente passa per HTTP.

Test end-to-end: una richiesta reale contro un processo in esecuzione

Un test end-to-end elimina entrambe le sostituzioni. Avvia l’app Flask vera come processo separato, le manda una vera richiesta HTTP su un vero socket, e controlla il risultato come farebbe un client esterno: chiamando di nuovo l’API, non frugando dentro il database.

 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)  # dai al dev server di Flask un attimo per mettersi in ascolto sulla 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 sostituisce il mail-catcher che faresti girare in un ambiente di staging: qualcosa che parla davvero SMTP su un socket, così la vera chiamata smtplib.SMTP(...) dell’app ha dove connettersi. Il test crea anche un file di database usa e getta invece di riusare app.db: far girare questo test due volte contro un file condiviso fallirebbe la seconda volta sulla stessa PRIMARY KEY già occupata da [email protected], che è esattamente il tipo di instabilità da stato residuo di cui l’articolo parla più avanti. Niente in questo test sa che sign_up o create_user esistono. Conosce solo il contratto HTTP. Rinomina una route o cambia la forma del JSON e questo è il test che fallisce, mentre i test unitari e di integrazione qui sopra continuano a passare.

Installa le dipendenze con pip install flask requests aiosmtpd pytest e lanciali tutti e tre con pytest. Quando qualcosa si rompe, il file che fallisce ti dice quale classe di bug hai. Questo è il motivo per scrivere più di un livello; la percentuale di copertura non lo è.

Piramide dei test vs trofeo dei test

La forma classica per una suite di test è la piramide dei test di Mike Cohn: una base larga di test unitari veloci, uno strato più piccolo di test di integrazione, e una punta sottile di test end-to-end lenti e costosi. Il punto è il rapporto, non il conteggio. La maggior parte dei tuoi test dovrebbe essere del tipo economico.

Il trofeo dei test di Kent C. Dodds non è d’accordo su dove mettere il peso. Il suo argomento: i test unitari isolati sono economici da scrivere, ma la fiducia che danno non riflette come il software viene usato davvero, quindi una suite fatta soprattutto di test unitari può perdere bug reali alle giunture. Il trofeo mette l’analisi statica (linting, controllo dei tipi) alla base, uno strato unitario sopra, i test di integrazione come parte più larga, e uno strato end-to-end sottile in cima. test_sign_up_persists_user_and_records_email qui sopra è il tipo di test che il trofeo vuole in maggior quantità.

Piramide (Cohn)Trofeo (Dodds)
Strato più grandeUnitarioIntegrazione
Strato alla baseTest unitariAnalisi statica (lint, tipi)
Domanda guidaQuanto costa scrivere ed eseguire questo test?Quanto assomiglia questo test all’uso reale?
Punto debole che colpisceSuite end-to-end lente e fragiliTest unitari che passano mentre le giunture sono rotte

Non seguire nessuna delle due forme alla lettera. Concordano sull’ordine: i test end-to-end sono i più costosi e dovrebbero essere i meno numerosi, i test unitari sono i più economici. Dove va la maggior parte del tuo sforzo dipende dalla storia dei tuoi bug. Se la maggior parte dei bug sono componenti che si comportano male da soli, pendi verso la piramide. Se la maggior parte sono componenti che non si mettono d’accordo su come parlarsi, pendi verso il trofeo.

Perché i test end-to-end diventano instabili

Una suite con una pila crescente di test end-to-end si guadagna prima o poi una reputazione: rilanci quello che fallisce e passa. Questa è instabilità, e nasce dalla stessa proprietà che rende preziosi i test end-to-end. Toccano infrastruttura reale.

Le cause solite sono uno sleep() fisso abbastanza lungo sul tuo portatile e troppo corto su un runner CI sotto carico, dati di test lasciati da un’esecuzione precedente che vanno in collisione con la successiva, una chiamata di rete verso qualcosa fuori dal tuo controllo, e stato condiviso tra i test che emerge solo quando girano in ordine diverso o in parallelo. Il time.sleep(0.5) nel test sopra è del primo tipo; il file temporaneo che lo stesso test crea per DB_PATH è una correzione diretta per il secondo. Nessuno di questi è un bug nella funzionalità. Sono bug nel modo in cui il test gestisce l’infrastruttura reale, un problema che i test unitari non hanno mai.

Rilanciare finché non diventa verde non è la soluzione; nasconde nel rumore il prossimo fallimento vero. Mantieni sottile lo strato end-to-end coprendo i percorsi critici, non ogni ramo. Dai a ogni test i propri dati invece di fixture condivise. Sostituisci le attese fisse con un polling sulla condizione di cui hai davvero bisogno, come la porta che accetta connessioni. Il tempo della CI è l’altro costo: se hai già velocizzato la pipeline facendo cache delle dipendenze in GitHub Actions, uno strato end-to-end che ha bisogno di tre rilanci per diventare verde restituisce buona parte di quel tempo.

Quale test scrivere dopo

Se un bug è appena scappato in produzione, chiediti quale livello lo avrebbe intercettato prima di aggiungere qualsiasi test. Un calcolo sbagliato o un caso limite non gestito in una funzione chiede un test unitario. Due pezzi del tuo codice che non concordano su una forma o un contratto, come l’API che restituisce un campo che il frontend non si aspetta o una query che assume una colonna rinominata, chiedono un test di integrazione. Una funzionalità che passa ogni test ma fallisce per un utente reale manca di solito di copertura end-to-end del percorso che quell’utente ha seguito.

Scrivi prima il test unitario. È il segnale più economico e il più veloce a dirti quando hai rotto la logica. Aggiungi un test di integrazione quando due tuoi pezzi devono dimostrare di essere d’accordo. Riserva i test end-to-end per la manciata di flussi dove “funziona” significa qualcosa solo visto dall’esterno: registrazione, login, checkout, reset della password.

Mantieni questa separazione e ottieni una suite i cui fallimenti significano qualcosa. Questo conta oltre la CI. Git bisect con git bisect run ha bisogno di un comando di test che fallisce solo quando il codice è davvero rotto, e un test end-to-end instabile lo manderà sul commit sbagliato.

Articoli correlati