Eine Funktion kann 100 % Unit-Test-Abdeckung haben, und die Seite, die sie aufruft, kann in Produktion trotzdem einen 500er zurückgeben. Das ist kein Widerspruch. Unit-, Integrations- und End-to-End-Tests prüfen unterschiedliche Dinge, und eine Testsuite, die bei einer Ebene stark und bei einer anderen schwach ist, hat ein bestimmtes, vorhersehbares Loch.

Was Unit-, Integrations- und End-to-End-Tests jeweils prüfen

Nehmen Sie eine Registrierungsfunktion: Eine Funktion validiert die Eingabe, schreibt eine Zeile in eine Datenbank und verschickt eine Willkommens-E-Mail. Drei Tests können das abdecken, und jeder beantwortet eine andere Frage.

EbeneFrage, die sie beantwortetWas echt ist, was ersetzt wird
UnitTut diese eine Funktion, was sie soll?Alles rund um die Funktion wird ersetzt: keine echte Datenbank, kein echtes Netzwerk
IntegrationFunktionieren diese Funktion und eine echte Abhängigkeit zusammen?Die Datenbank ist echt; die Teile, die noch außerhalb der getesteten Grenze liegen (der Mailserver), werden ersetzt
End-to-EndFunktioniert die gesamte Anfrage, von außen betrachtet?Nichts wird ersetzt. Eine echte HTTP-Anfrage trifft auf einen echten laufenden Prozess

Ein Unit-Test sagt Ihnen, dass die Prüfung des E-Mail-Formats genau das ablehnt, was sie ablehnen soll. Ein Integrationstest sagt Ihnen, dass die Zeile mit den erwarteten Spalten tatsächlich in der Datenbank landet. Ein End-to-End-Test sagt Ihnen, dass ein Client den Endpoint aufrufen kann und eine 201 zurückbekommt. Keiner der drei kann die anderen ersetzen.

Eine Flask-Funktion auf allen drei Ebenen testen

Hier ist die Funktion: eine sign_up-Funktion, die eine E-Mail validiert, einen Benutzer anlegt und eine Willkommensnachricht verschickt.

 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 nimmt db und smtp_client als Argumente entgegen, statt eine globale Verbindung zu verwenden. Genau diese Entscheidung macht alle drei folgenden Tests möglich: Jeder übergibt für diese zwei Parameter etwas anderes. get_db() und get_smtp_client() lesen ihr Ziel bei jedem Aufruf aus einer Umgebungsvariable, statt es einmal fest zu verdrahten. Genau das erlaubt es dem End-to-End-Test weiter unten, die laufende App ohne Änderungen an app.py auf eine Wegwerf-Datenbank und einen lokalen Test-Mailserver zeigen zu lassen.

Unit-Test: beide Abhängigkeiten ersetzen

Ein Unit-Test isoliert sign_up vollständig. Weder die Datenbank noch der SMTP-Client sind echt; beide sind Mock-Objekte aus Pythons Standardbibliothek.

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

Hier kommen zwei Arten von Test-Doubles zum Einsatz, und die Namen sind wichtig, weil sie unterschiedliche Erwartungen setzen. db im ersten Test kommt einem Dummy nahe: Es wird übergeben, weil die Signatur irgendetwas verlangt, und dem Test ist nur wichtig, dass es nie angefasst wird. smtp_client im zweiten Test ist ein Mock im engeren Sinne. Der Test legt die Erwartung fest (sendmail wird mit dieser Adresse aufgerufen) und schlägt fehl, wenn die Realität nicht passt. Das ist Verhaltensverifikation: Sie prüfen, dass ein Aufruf stattgefunden hat, nicht was dabei herauskam.

Diese Tests laufen in Millisekunden und fassen nie Festplatte oder Socket an. Sie können Ihnen aber auch nicht sagen, ob INSERT INTO users zu den tatsächlichen Spalten der Tabelle passt. db führt nie etwas Echtes aus, also würde ein Tippfehler im SQL jeden Unit-Test bestehen, den Sie schreiben.

Integrationstest: eine echte Datenbank, ein falscher Mailserver

Ein Integrationstest schließt diese Lücke eine Abhängigkeit nach der anderen. Hier ist die Datenbank echt – eine In-Memory-SQLite-Verbindung mit dem tatsächlichen Schema –, während der Mailserver weiterhin ersetzt ist. Diesmal ist es ein Fake: eine kleine, funktionierende Implementierung statt eines skriptbasierten Stand-ins.

 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 legt vorab keine Erwartungen fest, wie es smtp_client.sendmail.assert_called_once() tat. Es führt eine echte, wenn auch winzige Liste dessen, was verschickt wurde, und der Test liest diese Liste hinterher aus. Das ist der Unterschied zwischen einem Mock und einem Fake: Einem Mock sagt man vorher, was er erwarten soll; ein Fake verhält sich wie eine kleinere Version der echten Sache, und man untersucht es danach.

Dieser Test findet einen falschen Spaltennamen oder eine fehlende Tabelle. Er findet keinen Bug im Request-Parsing von Flask und keine falsche URL im Route-Decorator, weil hier nichts über HTTP läuft.

End-to-End-Test: eine echte Anfrage gegen einen laufenden Prozess

Ein End-to-End-Test streicht beide Ersetzungen. Er startet die echte Flask-App als eigenen Prozess, schickt ihr eine echte HTTP-Anfrage über einen echten Socket und prüft das Ergebnis so, wie es ein Client von außen tun würde: durch einen erneuten Aufruf der API, nicht durch einen Blick in die Datenbank.

 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)  # dem Flask-Dev-Server einen Moment geben, um den Port zu binden

    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 spielt hier die Rolle des Mail-Catchers, den Sie in einer Staging-Umgebung laufen lassen würden: etwas, das tatsächlich SMTP über einen Socket spricht, damit der echte smtplib.SMTP(...)-Aufruf der App eine Verbindung aufbauen kann. Der Test legt sich zudem eine Wegwerf-Datenbankdatei an, statt app.db wiederzuverwenden: Lassen Sie diesen Test zweimal gegen dieselbe Datei laufen, und er schlägt beim zweiten Mal an derselben PRIMARY KEY fehl, die [email protected] schon belegt – genau die Art von Flakiness durch Restzustand, um die es im Artikel weiter unten geht. Nichts in diesem Test weiß, dass sign_up oder create_user existieren. Er kennt nur den HTTP-Vertrag. Benennen Sie eine Route um oder ändern Sie die Form des JSON, und dieser Test schlägt fehl, während die Unit- und Integrationstests oben weiterhin bestehen.

Installieren Sie die Abhängigkeiten mit pip install flask requests aiosmtpd pytest und führen Sie alle drei mit pytest aus. Wenn etwas kaputtgeht, sagt Ihnen die fehlschlagende Datei, welche Art von Bug Sie haben. Genau deshalb schreiben Sie mehr als eine Ebene – nicht wegen der Abdeckungsquote.

Testpyramide vs Test Trophy

Die klassische Form für eine Testsuite ist Mike Cohns Testpyramide: eine breite Basis aus schnellen Unit-Tests, eine kleinere Schicht aus Integrationstests und eine dünne Spitze aus langsamen, teuren End-to-End-Tests. Entscheidend ist das Verhältnis, nicht die Anzahl. Der Großteil Ihrer Tests sollte von der billigen Sorte sein.

Kent C. Dodds’ Test Trophy sieht das anders: wo das Gewicht liegen soll. Sein Argument: isolierte Unit-Tests liefern ein Vertrauen, das billig zu erkaufen ist, aber nicht widerspiegelt, wie die Software tatsächlich benutzt wird – eine Suite, die vor allem aus Unit-Tests besteht, kann also echte Bugs an den Nahtstellen übersehen. Das Trophy setzt statische Analyse (Linting, Typprüfung) an die Basis, eine Unit-Schicht darüber, Integrationstests als breitesten Teil und eine dünne End-to-End-Schicht ganz oben. test_sign_up_persists_user_and_records_email von oben ist genau die Art Test, von der das Trophy mehr will.

Pyramide (Cohn)Trophy (Dodds)
Größte SchichtUnitIntegration
Unterste SchichtUnit-TestsStatische Analyse (Lint, Typen)
LeitfrageWie billig ist dieser Test zu schreiben und auszuführen?Wie sehr ähnelt dieser Test der echten Nutzung?
Schwachstelle, die er adressiertLangsame, brüchige End-to-End-SuitenUnit-Tests, die bestehen, während die Nahtstellen kaputt sind

Folgen Sie keiner der beiden Formen wortwörtlich. Sie stimmen in der Reihenfolge überein: End-to-End-Tests sind am teuersten und sollten am seltensten sein, Unit-Tests am billigsten. Wohin der Großteil Ihres Aufwands fließt, hängt von der Bug-Historie Ihres Projekts ab. Wenn die meisten Bugs Komponenten sind, die sich für sich allein falsch verhalten, tendieren Sie zur Pyramide. Wenn die meisten Bugs Komponenten sind, die sich nicht einig werden, wie sie miteinander reden, tendieren Sie zum Trophy.

Warum End-to-End-Tests flakig werden

Eine Suite mit einem wachsenden Stapel an End-to-End-Tests erwirbt sich irgendwann einen Ruf: Sie starten den fehlgeschlagenen Test neu, und er besteht. Das ist Flakiness, und sie entsteht aus genau der Eigenschaft, die End-to-End-Tests wertvoll macht. Sie berühren echte Infrastruktur.

Die üblichen Ursachen sind ein fest codiertes sleep(), das auf dem eigenen Laptop reicht, auf einem ausgelasteten CI-Runner aber zu kurz ist, Testdaten, die ein vorheriger Lauf hinterlassen hat und die mit dem nächsten kollidieren, ein Netzwerkaufruf zu etwas außerhalb der eigenen Kontrolle, und geteilter Zustand zwischen Tests, der erst auffällt, wenn sie in anderer Reihenfolge oder parallel laufen. Das time.sleep(0.5) im Test oben ist die erste Art; die temporäre Datei, die derselbe Test für DB_PATH anlegt, ist eine direkte Lösung für die zweite. Keine davon ist ein Bug in der Funktion. Es sind Bugs darin, wie der Test mit echter Infrastruktur umgeht – ein Problem, das Unit-Tests nie haben.

Den Test so lange neu zu starten, bis er grün ist, ist keine Lösung: Es versteckt den nächsten echten Fehlschlag im Rauschen. Halten Sie die End-to-End-Schicht dünn, indem Sie die kritischen Pfade abdecken, nicht jeden Zweig. Geben Sie jedem Test seine eigenen Daten statt geteilter Fixtures. Ersetzen Sie feste Wartezeiten durch Polling auf die Bedingung, die Sie tatsächlich brauchen, etwa dass der Port Verbindungen annimmt. Die CI-Zeit ist der andere Preis: Wenn Sie die Pipeline bereits beschleunigt haben, indem Sie Abhängigkeiten in GitHub Actions cachen, frisst eine End-to-End-Schicht, die drei Neustarts braucht, um grün zu werden, einen guten Teil davon wieder auf.

Welcher Test als Nächstes ansteht

Wenn gerade ein Bug in die Produktion durchgerutscht ist, fragen Sie zuerst, welche Ebene ihn abgefangen hätte, bevor Sie irgendeinen Test hinzufügen. Eine falsche Berechnung oder ein unbehandelter Grenzfall in einer Funktion ruft nach einem Unit-Test. Zwei Teile Ihres eigenen Codes, die sich über eine Form oder einen Vertrag nicht einig sind – etwa die API, die ein Feld liefert, das das Frontend nicht erwartet, oder eine Query, die eine umbenannte Spalte voraussetzt – rufen nach einem Integrationstest. Einer Funktion, die jeden Test besteht, aber für einen echten Nutzer fehlschlägt, fehlt meist End-to-End-Abdeckung für den Pfad, den dieser Nutzer genommen hat.

Schreiben Sie zuerst den Unit-Test. Er ist das billigste Signal und sagt Ihnen am schnellsten, wenn Sie die Logik kaputt gemacht haben. Fügen Sie einen Integrationstest hinzu, wenn zwei Ihrer eigenen Teile beweisen müssen, dass sie sich einig sind. Heben Sie sich End-to-End-Tests für die Handvoll Abläufe auf, bei denen „es funktioniert“ nur von außen betrachtet etwas bedeutet: Registrierung, Login, Checkout, Passwort-Reset.

Halten Sie diese Trennung ein, und Sie bekommen eine Suite, deren Fehlschläge etwas bedeuten. Das zählt über die CI hinaus. Git Bisect mit git bisect run braucht einen Testbefehl, der nur fehlschlägt, wenn der Code tatsächlich kaputt ist, und ein flakiger End-to-End-Test schickt Sie sonst zum falschen Commit.

Verwandte Artikel