Ich habe eine starke Besessenheit mit Terminologien beim Thema Testing beobachtet, wie Integrationstests versus Unit-Tests, ob es Mocks in Unit-Tests geben sollte und so weiter. Mit Menschen zu arbeiten, denen Verbesserung am Herzen liegt, ist ein Segen. Dennoch ist eine zu starke Fokussierung auf diese Konzepte möglicherweise keine gute Idee. Das Wesen des Testens, das, was tatsächlich zählt, bleibt dasselbe, unabhängig von den darüber aufgebauten Konzepten.

Der Zweck automatisierter Tests

Bevor wir mit diesem Thema beginnen, müssen wir zunächst klären, wofür Tests da sind – das „Warum” des Testens. Um das zu beantworten, stellen wir uns eine Code-Basis ohne Tests vor. Das Wachstum von Anforderungen, die Änderung von Anforderungen und die Veränderung der Produktionsumgebung zwingen die Software dazu, modifiziert zu werden, um die zuvor nicht abgedeckten Bereiche zu erfüllen. Wie kann die Stabilität des Programms sichergestellt werden, wenn der Code modifiziert wird? Was beweist, dass bestehende Features noch so funktionieren, wie sie sollen, wenn der Programmierer einige längst vergessene Algorithmen in der Code-Basis berührt? Dafür sind Tests da: Um die Qualität und Stabilität der Software zu gewährleisten.

Was ist die Qualität von Software? Sind Qualität und Stabilität die einzigen Eigenschaften, für die Tests gut sind? Der Kürze dieses Artikels halber werde ich die Qualität der Software als die Erfüllung aller Anforderungen definieren. Andere Eigenschaften von Tests, TDD, Performance-Tests und anderes werden hier ebenfalls nicht erwähnt, da sie nicht relevant sind.

Dieses Ziel kann auf viele Arten erreicht werden. Eine davon ist ein Testprotokoll zu haben und es jedes Mal manuell durchzugehen, wenn etwas geändert wird. Dieser Ansatz skaliert jedoch nicht. Warum? Der massive Einsatz von Arbeitskraft ist der limitierende Faktor. Bei sich wiederholenden Aufgaben sind Menschen fehleranfällig, langsam, teuer und haben je nach Tagesform inkonsistente Ergebnisse. Was also tun, wenn Menschen so schwierig zu handhaben sind? Ja, das tun, was unsere Vorfahren schon lange getan haben: Automatisieren.

Wie sieht ein Test aus?

Um die Eigenschaften zu skizzieren, die ein Test haben sollte, müssen wir uns ansehen, wie der Prozess der Arbeit mit automatisierten Tests aussieht. Es kann sehr viele davon in einem gegebenen Projekt geben. Sie werden ausgeführt, und einige könnten fehlschlagen. Im Fehlerfall möchte der Programmierer wissen, welche er sich ansehen muss, sowie so schnell wie möglich zur Ursache zu gelangen. Die wichtigen Fragen sind: Wo sind die fehlgeschlagenen Tests, was (oder welche Anforderung) ist fehlgeschlagen und warum ist es fehlgeschlagen. Mit diesen Erkenntnissen können wir darüber nachdenken, wie es für den Programmierer einfacher gemacht werden kann, diese Informationen zu erhalten.

Das Testing-Framework beantwortet die Wo-Frage bereits, indem es die fehlgeschlagenen Tests protokolliert oder auf irgendeine Weise hervorhebt. Als Autor von Tests sind also die einzigen Dinge, um die man sich kümmern muss, die Was- und Warum-Antworten bereitzustellen. Die Was-Frage dreht sich alles um den Namen des Tests. Er sollte beschreibend genug sein, dass der Programmierer den Kontext des Fehlers kennt. Die Implementierung des Tests zu untersuchen ist natürlich eine Option, erfordert aber mehr Aufwand zum Verstehen und Verarbeiten als ein guter Testname. Der nächste Schritt ist, die Antwort auf das Warum zu liefern. Der Grund ist sehr situationsabhängig und daher schwer zum Zeitpunkt des Schreibens des Tests zu beantworten. Was getan werden kann, ist den Test so zu strukturieren, dass die Untersuchung der Grundursache effizient wird. Die unten erwähnten Methoden sind einige der Best Practices zur Erreichung dieser Effizienz.

Given-When-Then

Benennung ist schwierig. Wenn es 500 Tests im Projekt gibt, wie können wir sicherstellen, dass die Testnamen beschreibend sind? Ein bestimmtes Muster zu haben hilft enorm. Das ist, wenn das Given-When-Then-Paradigma ins Spiel kommt. An erster Stelle steht der Testname: Given Umgebung When Aktion ausführen, Then ist das Ergebnis X. Mit Hilfe dieses Musters sind die wichtigsten Informationen im Testnamen enthalten. Darüber hinaus gibt uns Given-When-Then auch vor, wie ein Test selbst strukturiert ist oder sein sollte. Zuerst kommt der Setup-Teil (Given), danach die Ausführung (When) und zuletzt die Überprüfungen des Ergebnisses (Then).

  • – given year 2001 when checking for leap year then return false
  • – given year 1900 when checking for leap year then return false
  • – given year 2000 when checking for leap year then return true
  • – given year 2004 when checking for leap year then return true
  • – given year 1 when checking for leap year then return false
  • – given year 0 when checking for leap year then return true
  • – given year -1 when checking for leap year then return false
  • – given year -4 when checking for leap year then return true

Eine häufige Diskussion, die ich oft höre, dreht sich um das Thema, wie Tests gruppiert werden sollen, wenn es viele davon in einer Spec-Datei gibt. Sollte das nach Given oder nach When geschehen? Der Testcode sollte so strukturiert sein, dass er dem Entwickler hilft, die Antworten auf das Was und Warum zu finden. Das Wesen der Diskussion ist, welche Grupperungsweise diesen Zweck besser erfüllt. Was wir testen, sind nicht die Umgebungen, der Given-Abschnitt, sondern das When. Wir erstellen keine Umgebungen und fragen, was der Output aller möglichen Aktionen sein sollte, sondern welche Umgebungen müssen wir für die Aktion gestalten, damit wir alle möglichen Aspekte davon richtig testen können. Die Antwort ist die Gruppierung nach When. Die Gruppenstruktur sieht wie folgt aus:

when checking for leap year
- given year 2001 then return false
- given year 1900 then return false
- given year 2000 then return true
- given year 2004 then return true
- given year 1 then return false
- given year 0 then return true
- given year -1 then return false
- given year -4 then return true

KISS – Keep It Simple Stupid

Given-When-Then ist schön. Dennoch reicht es meist nicht aus, insbesondere wenn Abstraktionen, DRY und andere Prinzipien im Namen von Clean Code angewendet werden. Wir möchten die Antworten auf unsere Fragen finden, wenn ein Test fehlschlägt. Helfen uns all diese Prinzipien dabei, unser Ziel zu erreichen? Abstraktionen sind nicht ohne Kosten. Sie fügen eine Komplexitätsschicht hinzu, und Komplexität macht den Code schwerer verständlich. Wenn etwas die Situation verschlechtert, dann verbessert das Gegenteil sie, oder? In diesem Fall ja. Der Schlüssel hier ist Einfachheit. DRY? Vererbung? Higher-Order-Funktionen? Schleifen? Nichts davon ist erlaubt, es sei denn, das Überfliegen des Codes wäre ohne sie schwieriger.

DRY schafft Abhängigkeiten, indem vorhandener Code an einem Ort zusammengeführt wird. Wenn ich den extrahierten Code ändere, werden sich zwei oder mehr Tests damit ändern. Ist das wirklich etwas Gewünschtes? Vererbung versteckt Logik hinter Superklassen. Wie soll der Entwickler den Grund für den fehlgeschlagenen Test finden, ohne in der Vererbungskette herumzuspringen? Higher-Order-Funktionen verstecken Komplexität im Funktionskörper. Das verursacht dasselbe Problem wie Vererbung. Schleifen machen es schwieriger zu verstehen, wie oft und was genau jedes Mal auf einen schnellen Blick ausgeführt wird.

Die oben genannten Muster sind nicht die einzigen, bei denen der Nachteil größer als der Gewinn für Test-Code ist. Sie werden erwähnt, weil sie die häufigsten Muster sind, die ich bei Menschen sehe. Wie kann man die Komplexität handhaben, wenn der Test-Code unhandlich wird? Die Given- und Then-Blöcke sind die einzigen Stellen, an denen irgendeine Art von Abstraktionen erlaubt ist. Der When-Block ist der Ort, an dem der zu testende Code ausgeführt wird. Jede Art von Verschönerung ist nicht erlaubt. Für das Einrichten der Umgebung sollten nur einfache Funktionen als Abstraktionen erlaubt sein. Falls das Ergebnis zu komplex ist, um es mit den vom Testing-Framework (oder anderen Bibliotheken) bereitgestellten Utilities zu überprüfen, können benutzerdefinierte Assertions erstellt werden. Benutzerdefinierte Assertions sollten weiterhin den Mustern der verwendeten Testing-Bibliothek entsprechen, z. B. eine Struktur als erwartete Ausgabe akzeptieren oder eine einfache und klare Benennung wie isJsonStringValid verwenden.

Über Mocks

Ein Konzept, das der Mehrheit derjenigen, die bereits Tests geschrieben haben, nicht fremd ist, sind Mocks. Sie sind Ersatzobjekte für Abhängigkeiten in Tests, sodass diese Abhängigkeiten nicht verwendet werden müssen. Gehen wir zurück zum ersten Absatz dieses Artikels und schauen, wofür Tests da sind: Um die Qualität und Stabilität der Software zu gewährleisten. Das wirft die Frage auf, ob Mocks uns dabei helfen, dieses Ziel zu erreichen. Da Mocks so verbreitet und weit verbreitet sind, hilft es uns dabei, oder? Stimmt das jedoch wirklich?

Nehmen wir folgenden Fall an: Ich mocke eine Abhängigkeit weg, und in 2 Monaten hat genau diese Abhängigkeit ihre Implementierung geändert und gibt bei derselben Eingabe etwas anderes zurück. Sie gab mir vorher 4 als Rückgabewert, während sie jetzt 6 zurückgibt, aber ich habe eine Prüfung für das Werfen eines Fehlers für jeden Wert größer als 5, weil wir nichts Größeres akzeptieren. Ich wusste nichts davon, also habe ich die Mocks nicht geändert, oder besser gesagt: die Person, die diese Implementierung geändert hat, hat vergessen, auch die Mocks zu ändern. Es ist möglicherweise nicht einmal bekannt, dass es in einem bestimmten Test einen Mock dafür gibt. Tut mein Test jetzt noch das, was er tun soll?

Der Sinn eines Tests besteht darin, die Qualität und Stabilität der Software zu gewährleisten. Wenn ich keinen Code teste, der in der Produktion ausgeführt wird, erfülle ich diesen Zweck nicht. Das eigentliche Wesen des Mockens arbeitet tatsächlich gegen unser Ziel! Im Idealfall sollte es nirgendwo irgendwelche Mocks geben. Natürlich ist die Realität anders. Mocks lösen reale Probleme.

Nehmen wir folgenden Fall an: Ich muss einen Netzwerkaufruf an das Backend machen. Das bedeutet, ich muss eine laufende Backend-Instanz haben, die höchstwahrscheinlich auch eine Datenbank und potenziell einige andere Backend-Dienste erfordert. All das zu starten dauert einfach sehr lange, und der Rechen- und Kommunikations-Overhead macht alles noch langsamer. Eine Test-Suite, die sehr lange zum Ausführen braucht, ist einfach nicht akzeptabel, da die Feedback-Schleife zu lang ist. Hier kommen Mocks ins Spiel: Das Backend wird durch ein Dummy ersetzt, und wir geben einige vordefinierte Antworten für die Aufrufe zurück, die wir benötigen.

Ist das nicht verwirrend? Mocks arbeiten gegen den Grund, warum Tests existieren, aber manchmal brauchen wir sie wirklich, um unser Leben einfacher zu machen. Das bedeutet, Mocks sind doch gut? Wann sollten Mocks verwendet werden? Gibt es Kriterien dafür? Was wiederum zu den Kosten des Mockens und Nicht-Mockens führt, nämlich den folgenden:

Setup-Kosten

Wie viel kostet es, all diese Abhängigkeiten einzurichten? Das Backend auf dem CI für Frontend einzurichten ist möglicherweise aus verschiedenen Gründen nicht einfach. Mocks können ebenfalls unterschiedliche Kosten haben. Manchmal reichen Noops (keine Operation) aus, und manchmal sollten nuanciertere Antworten zurückgegeben werden. Das hängt vom Mock ab.

Laufzeitkosten

Laufzeitkosten werden weiter in Startkosten und die Kosten für den Aufruf unterteilt. Das Starten von Abhängigkeiten wie Backend-Servern und Datenbanken kann einige Zeit dauern. Einige Frameworks wie Spring tun viel zur Laufzeit, was auch die Startkosten in die Höhe treiben kann. Die Aufrufkosten sind, wie lange es dauert, bis die Abhängigkeit bei einer Anfrage Ergebnisse zurückgibt. Mocks sind Dummies, sodass ihre Laufzeitkosten sehr niedrig sind.

Wartungskosten

Im Falle von Abhängigkeitsaktualisierungen: Wie einfach ist es, das aktuelle Qualitäts- und Stabilitätsniveau des Tests zu erhalten? Es könnte nur eine Änderung im Versionsstring sein, wenn die tatsächliche Implementierung verwendet wird, oder es ist keine Aktion erforderlich, da sich diese Abhängigkeit in derselben Code-Basis befindet. Mocks hingegen müssen manuell überprüft werden, ob sie noch dem Verhalten entsprechen, das von ihren jeweiligen Zielen erwartet wird.

Nicht alle Kosten sind jedoch gleich. Der menschliche Faktor bei der Wartung ist sehr groß, was ihn schwerer wiegen lässt als die anderen zwei Kosten. Ob Mocks in bestimmten Situationen bevorzugt werden sollten, ist einfach eine Kostenberechnung: Summe aller Kosten der Verwendung der tatsächlichen Implementierung im Vergleich zur Summe aller Kosten der Verwendung von Mocks. Welche kleiner ist, ist die, für die ihr euch entscheiden solltet.

Mocking in Unit-, Integrations- und E2E-Tests

Mit all dem gesagt: Wie funktioniert das mit bestehenden Terminologien zusammen? Wann sollte man in einem Unit-, Integrations- oder E2E-Test mocken? Sicherlich ist die Testing-Pyramide nicht neu für euch, oder? 😉

Ein E2E-Test stellt sicher, dass die Software in ihrer Gesamtheit wie erwartet funktioniert, genau als würde sie in der Produktion laufen. Da E2E-Tests für die gesamte Software sind, ist das Mocken von irgendetwas dort eine schlechte Idee. Seid ihr sicher, dass es auch in der Produktion funktioniert, wenn z. B. das Backend gemockt ist?

Ein Integrationstest stellt sicher, dass die Kombination von Units, die einen größeren Teil des Systems ausmacht, korrekt funktioniert. Die Units in Integrationstests sind Code-Teile aus derselben Code-Basis. In diesem Fall ist das Wegmocken externer Abhängigkeiten wie Backends, Datenbanken usw. in Ordnung, wenn nötig.

Ein Unit-Test stellt sicher, dass eine einzige Unit das tut, was sie tun soll. Es ist diskutierbar, wie die Unit definiert wird: Ist eine Klasse/Funktion/… mit all ihren Abhängigkeiten eine Unit oder ohne sie? Wenn es ersteres ist, was ist dann der Unterschied zwischen diesem Unit-Test und einem Integrationstest? Wenn es letzteres ist, sollte ich dann nicht alle Abhängigkeiten wegmocken, da sie nicht wichtig sind? Andererseits sollte das gemäß den oben genannten Szenarien und Kosten für Mocks nicht getan werden. Was ist was jetzt?

Treten wir einen Schritt zurück. Tief durchatmen und uns fragen: Was möchten wir eigentlich erreichen? Das Endziel ist klar: Die Qualität und Stabilität der Software gewährleisten. Wie gewährleisten wir die Qualität dieses Code-Teils am besten? Richtig, nur mocken, wenn die Kosten dafür geringer sind als es nicht zu tun. Wenn es Integrationstest genannt wird, dann sei es so. Wenn es Unit-Test genannt wird, dann sei es auch so. Beinhaltet eine Unit auch ihre Abhängigkeiten oder nicht? Die Kategorisierung ist hier nicht wichtig. Terminologien können uns helfen, unsere Ziele zu erreichen, aber sie sind nicht die Ziele selbst. Erinnert ihr euch an den ersten Satz dieses Artikels?

To Mock Or Not To Mock.jpg

Mock-Management

Gut, wir haben die Vor- und Nachteile des Mockens verstanden und wann es angewendet werden soll. Der größte Nachteil von Mocks ist deren Wartung. Die Setup-Kosten sind eine einmalige Zahlung, sodass sie langfristig nicht viel ausmachen. Gibt es also eine Möglichkeit, die Wartungskosten zu reduzieren? Die Antwort ist ja.

Vom Upstream bereitgestellte Mocks verwenden

Es liegt nicht mehr in unserer Verantwortung, wenn die Mocks vom Abhängigkeits-Maintainer stammen. 😉 Ein Beispiel wäre ReadyAPI für OpenAPI.

Bestehende Mocks verwenden

Nur einen Mock pro Abhängigkeit in der gesamten Code-Basis haben und diesen in jedem Test verwenden, der ihn benötigt. Auf diese Weise entfällt die Notwendigkeit, alle Verwendungen dieser Abhängigkeit durchzugehen.

Konsistenter Mock-Speicherort

Entweder alle Mocks in einem Ordner ablegen oder direkt dort, wo die tatsächliche Implementierung liegt. Das Entfernen der Barrieren für die Aktualisierung des Mocks wird die Häufigkeit des Vergessens dabei verringern.