Mit der Abrissbirne zu besseren Unittests - Erkenntnisse beim Kaffeetrinken mit Uncle Bob

Durch glückliche Umstände hatte ich vergangenes Jahr die Möglichkeit, an einem zweitägigen Workshop mit Robert C. Martin – besser bekannt als „Uncle Bob“ – teilzunehmen. Ich hatte zu dieser Zeit bereits einige Erfahrung mit testgetriebener Entwicklung gesammelt, sowohl in Coding Dojos als auch in der täglichen Projektarbeit. Die Arbeitsweise durch das Vorgehen nach TDD (test-driven development) hatte signifikant positive Auswirkungen auf die Qualität der Software, die wir bauten, und gab uns ein Gefühl für eventuelle Schwachstellen der Anwendungen.

 

Auf die Helme!

Ich teilte Unittests in zwei Kategorien ein: Gute Unittests und schlechte Unittests. Meine Vorstellung war, dass Unittests mit einem Schutzhelm für den Bauarbeiter zu vergleichen wären. Ein guter Schutzhelm ist robust, leicht, angenehm, nahezu unbemerkt zu tragen – und er schützt gut vor herunterfallenden Gegenständen. Ein schlechter Schutzhelm dagegen sitzt nicht richtig, ist zu groß, zu schwer, drückt auf Kopf und Wirbelsäule. Kurzum, er stört und man ist verleitet, ihn abzunehmen. Die herunterfallenden Teile treffen nun allerdings ungeschützt den Kopf.

Bei Unittests ist das ganz ähnlich. Gute Unittests sind leicht, laufen nahezu unbemerkt mit, und sie schlagen an – schützen uns davor –, wenn die Software zu brechen droht. Schlechte Unittests hingegen sind schwergewichtig, ziehen viele Abhängigkeiten, sind schwer verständlich, nahezu unwartbar, aber ständig „rot“. Und das, ohne dass sich auf den ersten Blick erkennen lässt, ob das Problem wirklich im Produktivcode liegt oder ob der Test einfach einmal wieder falsche Annahmen trifft. Diese Tests sind nicht beherrschbar. Nach und nach werden sie auskommentiert, auf „Ignore“ gesetzt, oder – schlimmer noch – die Assertions werden so angepasst, dass die Tests wieder „grün“ sind.

Meine Tests gehörten zur Kategorie der guten Schutzhelme. Ich schrieb gute Unittests – so dachte ich.

Das Problem der zwei Disks

Dann kam der besagte Workshop mit Uncle Bob. Bei einer Tasse Kaffee stellte er das „Problem der zwei Disks“ vor:

Angenommen, der gesamte Sourcecode einer Anwendung wäre auf genau zwei Disks verteilt. Die Anwendung wurde nach TDD entwickelt und deckt den gesamten Produktivcode mit Tests ab. Dabei sind alle Tests auf der einen Disk und der gesamte Produktivcode auf der anderen Disk. Leider wird eine von beiden unwiederbringlich zerstört. Welche von beiden wäre das kleinere Übel?

Der naheliegende Gedanke ist hier: Hoffentlich überlebt die Disk mit dem Produktivcode, denn dann kann man die Anwendung weiterentwickeln. Allerdings hat man durch den Verlust der Disk mit den Tests plötzlich eine Anwendung, die ausschließlich aus „Legacy Code“ besteht. Eine sinnvolle Weiterentwicklung ist nur mit sehr hohem manuellem Testaufwand möglich; Refactoringmaßnahmen können faktisch nicht mehr durchgeführt werden, denn die Absicherung dafür fehlt. Da sich die Tests aus dem Produktivcode leider auch nicht wiederherstellen lassen, ist die traurige Prognose, dass die Codebasis in kürzester Zeit „verrotten“ wird.

Überlebt dagegen die Disk mit den Tests, lässt sich über die Tests – die gleichzeitig als Spezifikation der Anforderungen dienen – das gesamte System relativ zügig wiederherstellen. Wenn alle Tests „grün“ sind, dann ist auch das System vollständig wiederhergestellt – so die Prognose.

Der Selbstversuch

Das Szenario faszinierte mich. Tests sind nicht nur Schutzhelm, sondern auch Bauplan? Was mit Code passiert, der keine Tests hat, kannte ich zur Genüge aus diversen Projekten. Interessanter am  Problem der zwei Disks war der zweite Teil. Was passiert mit Tests, die keinen Code mehr haben? Zusammen mit Kollegen spielten wir das Szenario anhand der „Conways Game of Life“-Kata nach. Wir löschten den Produktivcode zu einer testgetrieben entwickelten Lösung und versuchten schrittweise den Code wiederherzustellen. Dabei war die Bedingung, dass wir wiederum testgetrieben vorgehen mussten, und somit nur Code schreiben durften, zu dem uns die noch vorhandenen Tests „treiben“. 

Das Ergebnis, das wir nach weniger als einer Stunde Arbeit am Code vorfanden war ernüchternd. Zwar waren mehr als zwei Drittel der Tests „grün“, aber die Codebasis glich einem losen Stückwerk. Der Test, an dem wir nun feststeckten, hätte es erfordert, eine Menge Code zu schreiben, zu dem wir nicht durch den Test getrieben wurden. Darin steckten so viele neue Annahmen und Aufgaben, dass wir nicht mehr guten Gewissens von „Rekonstruktion über die Tests“ sprechen konnten.

Das Experiment war gescheitert.

Was ist eigentlich die Anforderung?

Was war das Problem mit unseren Tests? Unserer Meinung nach testeten wir gegen die Anforderungen. Wir hatten Tests, die den Regelsatz des „Game of Life“ überprüften, wir hatten Tests, die prüften, ob Leben in einer Zelle existiert, wir hatten Tests, die prüften, welche Anzahl an Nachbarn eine bestimmte Zelle hatte.

Wir hatten also lauter Tests, die die Anforderungen umsetzten, wie sie in den Regeln zu „Conways Game of Life“ beschrieben sind. Bei der Ersterstellung der Anwendung waren wir außerdem nach TDD vorgegangen. Und trotzdem war daraus unmöglich eine brauchbare Anwendung wiederherzustellen.

Was wir nicht beachtet hatten war ein Kernsatz zum Vorgehen nach TDD: „Stay away from the heart of the logic“, sinngemäß: Versuche nicht bewusst einen Algorithmus zu entwickeln. 

Wir hatten diesen Kernsatz nicht beachtet, weil er scheinbar im Gegensatz dazu stand, dass unsere Anforderungen allesamt sehr konkrete Teile des Algorithmus beschrieben. Es sei denn … das, was wir als Anforderungen angesehen hatten, wären gar nicht unsere eigentlichen Anforderungen gewesen, gegen die wir testen sollten. Bei genauerer Betrachtung wurde es immer offensichtlicher: Aufgrund unserer falschen Annahmen zu den Anforderungen hatten wir sehr stark gegen Details des Algorithmus getestet und damit auch gegen konkrete Implementierungen. Das war es aber, was unserer Tests zu „Tests gegen Programmfragmente“ zersplitterte. Das was wir als Anforderungen betrachtet hatten, waren aus der Sicht eines Benutzers der Anwendung viel zu konkrete Details einer Implementierung.

Requirements Engineering für ein Haus?

Gerade so, als würde beim Hausbau statt der Anforderung „das Haus braucht einen Eingang“ eine Anforderung „Türen des Hauses müssen 1 m breit und 2 m hoch sein“ gestellt werden. Oder statt der Anforderung „vom Esszimmer aus kann man auf die Straße schauen“ die Anforderung „Räume haben Fenster aus Glas, deren Rahmen ins Mauerwerk eingelassen werden“. Während die einen Anforderungen dazu dienen, dem Benutzer ein ganz praktisches Bedürfnis zu erfüllen, sind die anderen Anforderungen Details, die dem Benutzer vorerst gar nicht wichtig sind. Vielmehr ergeben sie sich aus den Anforderungen an den praktischen Nutzen.

Eine erweiterte Liste der Anforderungen könnte so aussehen:

  • Das Haus braucht einen Eingang.
  • Durch den Eingang muss bequem eine Person ein- und ausgehen können.
  • Vom Esszimmer aus kann man auf die Straße schauen.
  • Wenn man vom Esszimmer aus auf die Straße schaut, soll man vor äußeren Wettereinflüssen geschützt sein.

Aus dieser Liste lässt sich schon sehr genau ableiten, dass Türen eine entsprechende Breite brauchen und „Gucklöcher“ im Mauerwerk mit Glas verschlossen sein müssen. Aus Anforderungen, die dem Benutzernutzen geschuldet sind, lässt sich ein abgerissenes Haus wieder so aufbauen, dass es den Bedürfnissen des Benutzers genügt – selbst, wenn es nicht mehr 1:1 das gleiche Haus ist, wie er es zuvor hatte. Nimmt man allerdings nur die technischen Details als Anforderung, könnte man ein völlig unbewohnbares Gebilde bauen, das aber alle Detailanforderungen erfüllt.

 

Gegen das Verhalten testen

Genauso verhält es sich letztendlich mit unserer Anwendung. Was wir als Anforderungen betrachtet haben, sind aus Sicht des Benutzers Details, die für ihn zweitrangig sind. Für einen Benutzer ist sehr viel wichtiger, zu beschreiben, was er über eine Benutzerschnittstelle eingeben kann und was er dann als Rückgabe aus der Anwendung erwartet. Beim „Game of Life“ sind die Eingaben beispielsweise diverse Konstellationen, in denen sich das Spiel derzeit befindet, und erwartete Konstellationen nach dem nächsten Generationswechsel. Bauen wir unsere Tests so auf, dass wir aus Benutzersicht über die Benutzerschnittstelle Eingaben machen und unsere Erwartungshaltung an das Verhalten des Spiels über die Ausgabe der Benutzerschnittstelle prüfen, dann kommen wir zu sehr viel besseren Tests.

Diese Tests verlangen zwar unter Umständen, dass man sich anfangs auf einige Eckpunkte der Anwendung festlegen muss (z.B. die Definition der Benutzerschnittstelle mit ihren öffentlichen Methoden), ermöglichen es aber im weiteren Verlauf, alle Implementierungsdetails aus den Tests fernzuhalten und nur gegen das Verhalten der Anwendung zu testen. Und was anfangs vielleicht als Einschränkung erscheint, erweist sich spätestens beim Refactoring als besonders nützlich: Da in den Tests keine Implementierungsdetails abgebildet werden, sondern durch eine öffentliche Schnittstelle getestet wird, ist es besonders einfach, intern auf ein anderes Interface umzustellen und lediglich einen Adapter zur alten Schnittstelle zu schreiben.

Ob die neuen Tests wirklich das Verhalten testen und somit als Spezifikation für die Anwendung dienen, lässt sich ganz einfach prüfen: Einmal mit der Abrissbirne über den Produktivcode gehen, und dann versuchen, aus den Tests die Anwendung zu rekonstruieren …

Praxisnutzen

Nun ist es natürlich ein Unterschied, ob man sich in der isolierten Kata-Welt befindet oder in der Drei-Tier-Architektur einer Geschäftsanwendung. Hier ist es nicht möglich, Unittests ausschließlich über die Endbenutzerschnittstelle durchzuführen. Das ist eher die Aufgabe von Akzeptanztests, die alle Schichten durchdringen sollen. Und doch lässt sich das Prinzip „Testen gegen das erwartete Verhalten aus Sicht des Benutzers“ auch hier gut umsetzen. Was es dazu braucht, ist, zu identifizieren, wer eigentlich der (direkte) Benutzer ist, und aus dessen Sicht die Tests zu schreiben. So ist zum Beispiel der direkte Benutzer eines Entity-Dto-Mappers möglicherweise ein Business-Service. Und aus dessen Sicht ist das erwartete Verhalten an einen Mapper, dass dieser ein bestimmtes Daten-Objekt in ein erwartetes Geschäftsobjekt wandelt. Dabei ist es für den Business-Service irrelevant, ob dazu innerhalb des Mappers ein Builder aufgerufen wird, um einige Attribute in ein Memberobjekt zusammenzufassen, oder wie ein Array innerhalb des Mappers in eine Liste gewandelt wird. Nur das Ergebnis ist interessant. Daher sollte es auch keine speziellen Tests für den Builder oder den Arraywandler geben. Diese werden implizit über die Verhaltenstests mitgetestet. Sollte im Zuge eines Refactorings des Mappers auffallen, dass es besser wäre, zukünftig einige Arbeiten an andere Objekte zu delegieren, oder dass der ursprünglich benutzte Builder gegen ein anderes Pattern ausgetauscht werden sollte, so bleiben die Tests von diesen Änderungen unberührt. Besser noch: Sie schlagen an, falls eine der Änderungen zu einem veränderten Verhalten führt.

Und sollte der Mapper aus irgendeinem Grund einmal ganz verloren gehen, so lässt sich anhand der Tests ein neuer Mapper aufbauen, der genau das gleiche Verhalten nachbildet wie der ehemalige Mapper.

Die Vorgehensweise für die Praxis heißt also:

  • Identifiziere den direkten Benutzer der zu entwickelnden Unit. 
  • Identifiziere, welches Verhalten der Benutzer von der Unit erwartet.
  • Schreibe Tests, die die Erwartungshaltung abbilden.

Fazit

Ich musste meine Meinung über Unittests erweitern. Statt zwischen guten und schlechten Tests unterscheide ich nun zwischen schlechten, mittelmäßigen und guten Tests. 

Schlechte Tests fühlen sich weiterhin an wie ein zu großer, unpassender Schutzhelm.

Mittelmäßige Tests sind die kleinen, passenden Schutzhelme, die uns helfen, den Kopf vor zusammenbrechendem Code zu schützen. Sie haben bereits positive Auswirkungen auf den Code und die Codequalität. Sie haben allerdings zwei entscheidende Nachteile: Sie schützen zwar den vorhandenen Code, aber sie ermöglichen es nicht, die Anwendung aus ihren Informationen wiederherzustellen. Und, da sie sich mit Implementierungsdetails beschäftigen, werden sie bei Refactorings unter Umständen zu unbequemen, schlechten Tests.

Gute Tests sind zweckmäßig. Sie identifizieren die Bedürfnisse des direkten Benutzers und testen gegen das von ihm erwartete Verhalten. Die Methodennamen können z.B. als eine Art Mini-Userstory formuliert werden. Da sie den Grundsatz „Stay away from the heart of the logic“ befolgen, verstecken sie Implementierungsdetails und bleiben bei Refactorings stabil. Sie sind somit wichtige Helfer für die Codepflege.

Das Umdenken und das Finden der richtigen Mini-Userstories, auf die sich die Tests beziehen, braucht etwas Routine. Die kann man sich am besten durch die Übung mit Katas aneignen. Damit wird man bald schon sehr viel bessere Unittests schreiben.

 

Quellen / Links

[1] Conway's Game of Life