Tests schreiben – die Grundlagen

the next big thing Golo Roden  –  32 Kommentare

Tests sind ein wesentlicher Baustein für qualitativ hochwertige und nachhaltige Softwareentwicklung. Doch warum genau sind Tests so wichtig, was sind ihre Vorteile, und welche Gründe sprechen für ihren Einsatz? Könnte man nicht alternativ auch von Hand testen?

Eine häufig genannte Antwort auf die Frage nach der Relevanz von Tests lautet, dass man durch ihren Einsatz sicherstellen kann, dass Software wie gewünscht funktioniert. Das ist prinzipiell zwar nicht falsch, aber eigentlich zweitrangig.

Weitaus wichtiger ist nämlich, dass Tests als Sicherheitsnetz gegen unabsichtliche Veränderungen fungieren, da sie heute wie morgen gleichermaßen ausgeführt werden können. Auf dem Weg ermöglichen sie die Erweiterung und Ergänzung von Code, ohne dass man Gefahr läuft, dass bereits Funktionierendes unbemerkt kaputt geht. Sie tragen also zur langfristigen Stabilität und der Qualität bei.

Dafür ist es essenziell, Tests automatisiert ausführen zu können, denn nur dann lässt sich Code reproduzierbar, deterministisch und zügig testen. Theoretisch wären auch Tests von Hand denkbar, doch sind dann alle drei genannten Aspekte nicht mehr gegeben. Natürlich ist es aufwendig, Tests initial zu schreiben – doch insbesondere langfristig liegt der Aufwand deutlich unter dem von manuellen Tests, bei einer gleichzeitig viel höheren Verlässlichkeit.

Außerdem neigt man dazu, beim manuellen Testen nur die positiven Fälle zu überprüfen, nicht die Sonderfälle und Edge-Cases, von denen es häufig aber deutlich mehr gibt. Hinzu kommt, dass – um all diese Fälle testen zu können – man sich vorher Gedanken machen muss: Die Tests agieren also auch als eine Art Spezifikation und Diskussionsgrundlage.

Ohne Tests werden Entscheidungen häufig nicht bewusst getroffen, sondern implizit, und sind daher in der Regel unpassend. Selbstverständlich sind auch Tests keine Garantie für ausschließlich richtige Entscheidungen, die Wahrscheinlichkeit liegt aber höher, da man sich explizit für eine bestimmte Vorgehensweise entschieden hat.

Wozu überhaupt Tests?

Unit, Integration & Co.

Dabei sind nicht alle Tests gleich, es gibt durchaus verschiedene Arten von Tests. Den Anfang machen dabei die sogenannten Unit-Tests, die einzelne Bestandteile der Software isoliert voneinander testen. Allerdings wirft das die Frage auf, was genau unter einer "Unit" zu verstehen ist beziehungsweise wo die Grenzen von Units liegen.

Unstrittig dürfte in den meisten Fällen sein, dass einzelne Funktionen als Units gesehen werden können. Besonders gut lassen sie sich testen, wenn sie im funktionalen Sinne "pure" sind, ihr Ergebnis also ausschließlich von den Eingaben abhängt, und sie keine Abhängigkeiten nach Außen aufweisen, beispielsweise in Form von Netzwerk- oder Dateisystemzugriffen oder der aktuellen Zeit.

Glücklicherweise lassen sich solche Effekte häufig gut isolieren, weshalb man einen Großteil des Codes gut mit derartigen Tests absichern kann. Verhalten sich Klassen ähnlich wie die genannten Funktionen, lassen auch sie sich einfach testen – häufig wird das aber durch den in Klassen inhärent gegebenen Zustand nicht ganz so leicht sein.

Hierüber kommt man dann zu Integrationstests, für die häufig noch weitere Dienste benötigt werden, die man beispielsweise in Form von Docker-Containern starten muss. Das funktioniert zwar gut und lässt sich einfach reproduzieren, kostet aber vor allem Zeit.

Möchte man eine UI testen, gibt es zum einen die Komponententests, die einzelne Komponenten isoliert testen, zum anderen die UI-Tests, die das komplette Zusammenspiel testen. Während Komponententests in der Regel außerhalb des Webbrowsers laufen und daher verhältnismäßig schnell sind, gilt das für UI-Tests (bewusst) nicht, da man spätestens hier auch die Kompatibilität zu verschiedenen Webbrowsern und Geräten testen möchte.

Tests: Unit, Integration & Co.

Test-Driven Development (TDD)

Naheliegend ist die Vorgehensweise, zunächst den eigentlichen Code und dann die Tests zu schreiben. Tatsächlich muss man das aber nicht so machen – die Vorgehensweise Test-Driven Development (TDD) schlägt nämlich genau das Gegenteil vor: Man schreibt zunächst den Test und implementiert dann nur gerade so viel, wie nötig ist, um den Test ans Laufen zu bringen.

Es ist wichtig, dass der Test zunächst rot wird, da man auf dem Weg sicherstellt, dass der Test tatsächlich neuen Code prüft und nicht durch Zufall grün wird. Außerdem ist so sichergestellt, dass jede Zeile der Implementierung durch einen Test motiviert wurde.

Bevor man dann an den nächsten Test geht, kann man zunächst noch Aufräumen und Code optimieren, was dank der hervorragenden Testabdeckung gefahrlos möglich ist. Da sich diese drei Schritte (Test schreiben, Code implementieren, Aufräumen) in einer Schleife wiederholen, spricht man hier auch vom sogenannten "TDD-Cycle", beziehungsweise von "Red-Green-Refactor".

Dadurch dass man die Tests zuerst schreibt, macht man sich automatisch über die Struktur des zu testenden Codes Gedanken. Geht man andersherum vor, stellt man häufig fest, dass es Code gibt, der sich nur schwierig oder im schlechtesten Fall gar nicht testen lässt – in aller Regel liegt das aber nicht am Testen, sondern an der Struktur des bestehenden Codes. Dieses Problem vermeidet man, wenn man die Tests an den Anfang stellt.

Außerdem konzentriert man sich durch den Fokus auf die Tests vermehrt auf die äußere Form des Codes, die eigentliche Implementierung ist eher eine Blackbox. Genau das ist sinnvoll, um semantische Schnittstellen festzulegen, und sich nicht (aus Versehen) von Implementierungsdetails abhängig zu machen. Insgesamt entsteht durch den Einsatz von TDD-Code, der durchdachter und bewusster entsteht.

Test-Driven Development (TDD)

Privaten Code testen – wie?

In der Realität kommt man allerdings trotzdem oft in die Situation, dass man bereits bestehendem Code im Nachhinein noch Tests hinzufügen muss. Eines der am häufigsten auftretenden Probleme ist dann, wie man privaten Code testet. Dafür gibt es, abhängig von der verwendeten technologischen Plattform, verschiedene Vorgehensweisen.

Eine Option ist, den privaten Code nicht als "private" zu deklarieren, sondern den Zugriff mehr oder minder gezielt für die Tests zu erlauben. Das lässt sich in C# beispielsweise mit dem Schlüsselwort "internal" umsetzen. Das ist zwar relativ einfach umsetzbar, bedingt aber eine Veränderung am Code nur mit dem Ziel der Testbarkeit.

Die zweite Option besteht darin, Reflection zu verwenden – auf dem Weg lässt sich in vielen Sprachen der "private"-Zugriffsmodifizierer aushebeln. Der Vorteil ist, dass der zu testende Code privat bleiben kann, der Nachteil ist aber, dass man im Einsatz von Reflection nicht allzu geübt ist und das Vorgehen daher sehr aufwendig und zugegebenermaßen auch umständlich ist.

Die dritte und mit Abstand schlechteste Option ist, die Tests von dem zu testenden Code durch Vererbung abzuleiten. Abgesehen davon, dass das ohnehin nur für Klassen funktioniert, schränkt es aber selbst dort die Gestaltungsmöglichkeiten ein, da es zum Beispiel nicht mehr möglich ist, Klassen als "sealed" zu markieren, das heißt, als nicht vererbbar.

Tritt man einmal einen Schritt zurück und stellt sich die Frage, ob es überhaupt sinnvoll ist, privaten Code zu testen, kommt man über kurz oder lang zu dem Schluss, dass dem nicht so ist: Denn nicht nur Tests können privaten Code nicht ohne Weiteres aufrufen, auch anderer Code kann das nicht. Das heißt, privater Code wird letztlich stets durch Funktionen aufgerufen, nicht privat sind – und er lässt sich über sie indirekt mit testen. Dass es sich dabei um privaten Code handelt, ist von außen gesehen letztlich ein Implementierungsdetail.

Gelegentlich mag man den Eindruck haben, dass dieses Vorgehen dem privaten Code nicht angemessen erscheint, da er sehr komplex und wichtig ist. In dem Fall bietet es sich an, den privaten Code in eine eigene Komponente oder ein eigenes Modul auszulagern und dieses dann mit Tests auszustatten. Man erhebt den privaten Code auf dem Weg sozusagen zu Code "erster Klasse".

Privaten Code testen – wie?

Wie Testen nicht funktioniert

Völlig unabhängig davon, wie man vorgeht, funktioniert Testen nur unter der Annahme, dass man eine Strategie hat und weiß, was man erreichen möchte. Das zweite "D" in "TDD" steht für "Development", nicht für "Design" – und das bedeutet, dass der Entwurf des Codes zuvor stattgefunden haben muss. Allzu oft hört man, dass sich die Architektur automatisch ergeben würde, doch dem ist nicht so: Architektur und Struktur müssen geplant werden.

Das heißt nicht, dass man ein dediziertes Gremium in Form eines Architecture Review Board braucht, aber es gibt zwischen den Extremen der Anarchie und der Planwirtschaft noch ein gesundes Mittelmaß. Es gilt daher, genau diese Balance zu finden. Der Punkt ist, dass man nur dann zielorientiert vorgehen kann, wenn man ein Ziel hat, und um das herauszufinden, gilt es zunächst, Annahmen zu validieren und eine Strategie zu klären. Dabei kann die Entwicklung eines Proof of Concept (PoC) helfen.

Man entwickelt also zunächst einen Prototypen, um Erfahrung zu sammeln und zu lernen, verwirft ihn dann und entwickelt dann die eigentliche Implementierung mit Tests. Das übliche Gegenargument lautet, dass das zu teuer sei – immerhin müsse man den Code dann zwei Mal schreiben, und zusätzlich noch die Tests. Das ist zwar richtig, verkennt aber, dass das Vorgehen ohne Strategie und Ziel langfristig noch teurer wird.

Leider hat sich der Irrglaube, man bräuchte keine Strategie, in vielen Bereichen etabliert. Das gilt nicht nur für TDD, sondern auch für Agilität im Allgemeinen. Auch der Einsatz von XP, Scrum & Co. führt nicht automatisch zu gut strukturierter Software, häufig werden hier lediglich viele Worthülsen genannt, um zu verschleiern, dass man keine Strategie hat. Beliebt ist dieses Vorgehen auch im Startup-Umfeld.

Es lässt sich aber zusammenfassen, dass Softwareentwicklung (und auch Businessentwicklung) nicht funktioniert, wenn man keine Strategie hat, sondern einfach "irgendetwas" macht und darauf wartet, dass sich der Rest von selbst ergeben wird. Wer das beherzigt und berücksichtigt, macht schon vieles richtig.

Wie Testen nicht funktioniert

Fazit

Tests sind einer der wichtigsten Bausteine für die langfristige Wahrung der Qualität in der Softwareentwicklung. Relevant ist dabei vor allem, dass Tests auf Knopfdruck rasch in immer gleicher Form ausführbar sind, und damit weitaus verlässlicher als jede Art von manuellem Testen. Damit sich Tests einfach schreiben lassen, muss der Code aber eine passende Struktur aufweisen: Ist Code schlecht testbar, liegt das häufig an ebendieser Struktur, nicht an den Tests.

Um Code gut zu strukturieren bietet es sich an, mit testgetriebener Entwicklung zu arbeiten, da der Fokus auf die Tests auch dazu führt, dass man sich bewusst Gedanken über äußere Schnittstellen macht. Man darf dabei aber nicht außer Acht lassen, dass es trotzdem Prototypen braucht, um Erfahrung zu sammeln und das Ziel zu validieren und sich eine Vorgehensweise zu erarbeiten. Architektur und Struktur passieren nicht von alleine.