Testbaren Code schreiben
Veröffentlicht: 2022-11-03Unit Testing ist ein unverzichtbares Instrument im Werkzeugkasten eines jeden Softwareentwicklers. Das Schreiben von Unit-Tests ist relativ einfach, wenn es um eine Codebasis geht, die den besten Software-Design-Praktiken, -Mustern und -Prinzipien folgt. Das eigentliche Problem entsteht, wenn man versucht, schlecht gestalteten, nicht testbaren Code zu testen.
In diesem Blog wird erläutert, wie man besser komponententestbaren Code schreibt und welche Muster und schlechten Praktiken zu vermeiden sind, um die Testbarkeit zu verbessern.
Testbarer und nicht testbarer Code
Wenn wir an umfangreichen Anwendungen arbeiten, die langfristig wartbar sein müssen, müssen wir uns auf automatisierte Tests verlassen, um die Gesamtqualität des Systems hoch zu halten. Im Vergleich zu Integrationstests, bei denen Sie mehrere Units als Ganzes testen, haben Unit-Tests den Vorteil, dass sie schnell und stabil sind. Schnell, weil wir im Idealfall nur die zu testende Klasse instanziieren, und stabil, weil wir normalerweise die externen Abhängigkeiten, z. B. Datenbank- oder Netzwerkverbindung, nachahmen.
Wenn Sie mit dem genauen Unterschied zwischen Unit- und Integrationstests nicht vertraut sind, können Sie mehr zu diesem Thema in unserem Blog zur Einführung in das Testen lesen.
Testbarer Code kann vom Rest unserer Codebasis isoliert werden. Mit anderen Worten, kleinste Einheiten können unabhängig getestet werden. Nicht testbarer Code ist so geschrieben, dass es schwierig oder sogar unmöglich ist, einen guten Komponententest dafür zu schreiben.
Sehen wir uns einige Anti-Patterns und schlechte Praktiken an, die wir beim Schreiben von testbarem Code vermeiden sollten.
Die Beispiele sind in Java geschrieben, aber die hier erwähnten Codierungskonventionen gelten für alle objektorientierten Programmiersprachen und Testframeworks. In diesem Blogbeitrag werden wir assertj und JUnit5 als Beispiele verwenden.
Abhängigkeitsspritze
Abhängigkeitsinjektion ist eines der wichtigsten Entwurfsmuster zum Erreichen von Testisolation. Abhängigkeitsinjektion ist ein Entwurfsmuster, bei dem ein Objekt andere Objekte (Abhängigkeiten) über Konstruktorparameter oder Setter erhält, anstatt sie selbst erstellen zu müssen.
Mit Abhängigkeitsinjektion können wir die zu testende Klasse einfach isolieren, indem wir die Abhängigkeiten eines Objekts verspotten.
Schauen wir uns ein Beispiel ohne Dependency Injection an:

Da die Engine-Abhängigkeit im Konstruktor der Car-Klasse konstruiert wird, kann man sagen, dass Car- und Engine-Klassen eng gekoppelt sind. Sie sind stark voneinander abhängig – eine Veränderung des einen würde eine Veränderung des anderen erfordern.
Aus Testsicht können Sie die Car-Klasse nicht isoliert testen, da das obige Beispiel die konkrete Engine-Implementierung nicht durch ein Test-Double ersetzen kann.
Wir können jedoch mithilfe von Abhängigkeitsinjektion und Polymorphismus eine Isolierung erreichen:

Jetzt können wir mehrere Motorimplementierungen und damit Autos mit unterschiedlichen Motoren konstruieren:

Isoliertes Testen ist jetzt möglich, da wir eine spöttische Implementierung der Engine-Abstraktion erstellen und an unsere Car-Klasse übergeben können:

Beim Umgang mit Objekten, die andere Objekte benötigen (Abhängigkeiten), sollten Sie diese über Konstruktorparameter (Abhängigkeitsinjektion) bereitstellen, die idealerweise hinter einer Abstraktion verborgen sind.
Wenn Sie diesem Muster folgen, wird Ihr Code lesbarer und anpassungsfähiger, um sich im Laufe der Zeit zu ändern. Außerdem sollten Sie es vermeiden, in Konstrukteuren tatsächlich zu arbeiten – alles, was über Feldzuweisungen hinausgeht, ist eigentliche Arbeit. Das Schlüsselwort „new“ in Konstruktoren ist immer ein Warnzeichen für nicht testbaren Code.
Eine Sache, die hier zu beachten ist, ist, dass in manchen Situationen eine enge Kopplung (zB neue Schlüsselwörter in Konstruktoren, innere Klassen für die Logikisolierung, Objekt-Mapper) keine schlechte Praxis ist – Klassen, die als „eigenständige“ Klasse keinen Sinn machen würden.
Globaler Zustand
Die gemeinsame Nutzung eines globalen Zustands kann häufig zu fehlerhaften Tests führen (manchmal bestanden, manchmal fehlgeschlagen), insbesondere in Multithread-Umgebungen.
Stellen Sie sich ein Szenario vor, in dem mehrere zu testende Objekte denselben globalen Zustand teilen – wenn eine Methode in einem der Objekte einen Nebeneffekt auslöst, der den Wert des gemeinsamen globalen Zustands ändert, wird die Ausgabe einer Methode im anderen Objekt unvorhersehbar. Vermeiden Sie die Verwendung unreiner statischer Methoden, da sie den globalen Status auf irgendeine Weise verändern oder auf einen globalen Status proxiert werden.
Schauen wir uns diese unreine statische Methode an:

Im Wesentlichen liest diese Methode das aktuelle Systemdatum und die Uhrzeit und gibt ein Ergebnis basierend auf diesem Wert zurück. Es wäre sehr schwierig, einen ordnungsgemäßen zustandsbasierten Komponententest für diese Methode zu schreiben, da der statische Aufruf von LocalDateTime.now() während der Ausführung unserer Tests zu unterschiedlichen Ergebnissen führen wird. Das Schreiben von Tests für diese Methode ist nicht möglich, ohne Datum und Uhrzeit des Systems zu ändern.
Um dies zu beheben, übergeben wir die Datumszeit als Argument an die timeOfDay-Methode:


Die statische timeOfDay-Methode ist jetzt rein – die gleichen Eingaben führen immer zu den gleichen Ergebnissen. Jetzt können wir in unseren Tests problemlos isolierte dateTime-Objekte als Argumente übergeben:

Gesetz der Demeter
Das Gesetz von Demeter oder das Prinzip des geringsten Wissens besagt, dass Objekte nur über Objekte wissen sollten, die eng mit dem ersten Objekt verwandt sind. Mit anderen Worten, ein Objekt sollte nur Zugriff auf Objekte haben, die es benötigt. Zum Beispiel haben wir eine Methode, die ein Kontextobjekt als Argument akzeptiert:

Diese Methode verstößt gegen das Gesetz von Demeter, da sie einen Objektgraphen durchlaufen muss, um die erforderlichen Informationen für ihre Arbeit zu erhalten. Das Übergeben unnötiger Informationen an Klassen und Methoden schadet der Testbarkeit.
Stellen Sie sich ein riesiges BillingContext-Objekt vor, das Verweise auf andere Objekte enthält:

Wie wir sehen können, ist unser Test mit unwesentlichen Informationen aufgebläht. Tests, die komplexe Objektgraphen erstellen, sind schwer zu lesen und führen zu unnötiger Komplexität.
Lassen Sie uns unser vorheriges Beispiel korrigieren:

Sie sollten immer direkte Abhängigkeiten an Ihre Klassen und Methoden übergeben. Es ist jedoch auch keine gute Praxis, viele Argumente an Methoden zu übergeben – idealerweise sollten Sie maximal zwei Argumente übergeben oder eng verwandte Argumente in Datenobjekte einschließen.
Gott widerspricht
Gott-Objekt ist ein Objekt, das auf viele andere unterschiedliche Objekte verweist, mehr als eine Verantwortung hat und mehrere Gründe hat, sich zu ändern. Wenn es schwierig ist, zusammenzufassen, was die Klasse tut, oder wenn das Zusammenfassen das Wort „und“ enthält, hat die Klasse wahrscheinlich mehr als eine Verantwortung.
Gott-Objekte sind schwer zu testen, da wir es mit mehreren unabhängigen Abhängigkeiten zu tun haben, verschiedene Ebenen von Abstraktionen und Bedenken vermischen, und sie erzeugen viele Nebenwirkungen. Folglich ist es schwierig, den gewünschten Zustand für unsere Testfälle zu erreichen.
Zum Beispiel:

UserService hat mehr als eine Aufgabe – das Registrieren neuer Benutzer und das Versenden von E-Mails. Beim Testen der Benutzerregistrierung müssen wir uns mit dem E-Mail-Dienst befassen und umgekehrt:

Stellen Sie sich einen UserService mit mehr als zwei unabhängigen Abhängigkeiten vor. Diese Abhängigkeiten haben ihre eigenen Abhängigkeiten und so weiter. Wir würden am Ende mit einem Test enden, der unlesbar, mit unzusammenhängenden Informationen aufgebläht und sehr schwer zu verstehen ist. Daher sollte jede Klasse nur eine Verantwortung und einen Grund haben, sich zu ändern. Eine Klasse, die nur einen Grund hat, sich zu ändern, ist eines der fünf Software-Designprinzipien, das als Single-Responsibility-Prinzip bezeichnet wird.
Hier können Sie mehr über die SOLID-Prinzipien lesen.
Fazit
Eine Codebasis, die Best Practices für das Softwaredesign folgt, macht das Schreiben von Unit-Tests viel einfacher zu handhaben.
Andererseits kann es sehr schwierig oder manchmal sogar unmöglich sein, einen funktionierenden Komponententest für eine Codebasis mit den erwähnten Anti-Patterns zu schreiben. Das Schreiben von gutem testbarem Code erfordert viel Übung, Disziplin und zusätzlichen Aufwand. Der bedeutendste Vorteil von testbarem Code ist die einfache Testbarkeit und die Fähigkeit, diesen Code zu verstehen, zu warten und zu erweitern.
Wir hoffen, dass dieser Blog Ihnen hilft, testbaren Code zu schreiben.