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.
| Ebene | Frage, die sie beantwortet | Was echt ist, was ersetzt wird |
|---|---|---|
| Unit | Tut diese eine Funktion, was sie soll? | Alles rund um die Funktion wird ersetzt: keine echte Datenbank, kein echtes Netzwerk |
| Integration | Funktionieren 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-End | Funktioniert 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.
| |
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.
| |
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.
| |
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.
| |
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 Schicht | Unit | Integration |
| Unterste Schicht | Unit-Tests | Statische Analyse (Lint, Typen) |
| Leitfrage | Wie billig ist dieser Test zu schreiben und auszuführen? | Wie sehr ähnelt dieser Test der echten Nutzung? |
| Schwachstelle, die er adressiert | Langsame, brüchige End-to-End-Suiten | Unit-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.