Testing und Test-Driven-Development (TDD)

In der Theorie sind Tests sehr wichtig für funktionierenden Code. Warum werden diese dann oft nur widerwillig geschrieben und was für Praktiken erleichtern das schreiben von Tests?
Wir schauen uns die Hintergründe für die Unlust am Testen an und versuchen Hürden zu identifizieren und abzubauen.
Ich stelle die Methode vor die für mich persönlich am Besten funktioniert und wir sehen uns Code-Beispiele an.
Warum Testing?
Jedem der schonmal Software geschrieben hat muss man nicht erklären warum man nach dem Programmieren nochmal ausprobieren muss ob denn alles auch so funktioniert wie geplant.
Software hat Fehler, unser Wissen ist begrenzt, wir wollen wissen “tut es”.
Dieses “Durchklicken” oder “Ausprobieren” (z.B. Rest-API Calls abfeuern, etc.) ist schon der Kern des Testing und absolut natürlich.
Wir haben eine gewisse Erwartung was passieren soll (Testfall) und probieren aus ob unsere Erwartung erfüllt wird.
Automatisiertes Testen, der nächst logische Schritt
Sehr schnell wird es sehr aufwändig alle Tests manuell durchzuführen. Es wird unübersichtlich man wird unaufmerksam und macht Fehler. Nicht mehr alle Tests werden jedes Mal durchgeführt.
Eigentlich wollen wir nach jeder Änderung am Code wissen ob dieser noch funktioniert. Die einzig nachhaltige Lösung: Wir müssen die Tests automatisieren.

Von der Theorie zur Praxis
In der Theorie gehen in der Regel die meisten noch mit: Ja klar brauchen wir Tests. In der Praxis wird es dann schnell dünn. Tests schreiben ist eine Qual. Hektisch werden von den Entwickler:innen noch schnell ein paar Tests dazu gebaut, damit die Definition of Done erfüllt ist und die Build-Metriken besser aussehen.
Doch warum ist das so?
Tests schreiben ist schwer
Gute Tests zu schreiben ist schwer. Test-Code ist den gleichen Regeln unterworfen wie funktionaler Code, man braucht viele Jahre Erfahrung um einfach und schnell Tests zu schreiben. Zusätzlich erfordert das Schreiben von Tests jedoch noch ein tiefes Verständnis von Abläufen und Abhängigkeiten und oftmals offenbart das Schreiben der Tests größere Baustellen in der Architektur. Dies ist zwar ein sehr positiver Nebeneffekt von Tests, viele Entwickler:innen sind jedoch davon überfordert. Nun müssen auch noch Entscheidungen über Architektur-Refactorings getroffen werden. Dabei wollte man nur kurz … . Wird das Refactoring aber nicht durchgeführt kann oft der Test nur schwer geschrieben werden oder ist in der Ausführung unzuverlässig.
So much to do, so little time: Funktionalität vs. Zuverlässigkeit
Beim Schreiben von Software ist der Tag eigentlich immer randvoll gepackt mit Aufgaben. Die Feature-Liste ist lang, der Druck diese zeitnah zu implementieren groß. Tests sind unsichtbar, der neue Button in der UI, der neue REST-API Endpunkt sind sichtbar. Für Außenstehende ist die Qualität der geschriebenen Tests nicht nachvollziehbar. Die des Codes auch nicht, aber zumindest gibt es einen Interaktionspunkt und man kann sagen: es tut was es soll, oder es tut nicht, was es soll.
Durch jahrzehnte lange Praxis an unzureichenden Tests und schlechtem Code sind die Fachbereiche und das Management abgestumpft. Es ist völlig “normal”, dass Software am Anfang nicht das tut was von ihr erwartet wird. Selbst in Bereichen mit unverhandelbaren völlig klaren Spezifikationen ist die Experimentalinformatik an der Tagesordung.
Keine Maurer:in würde sich dazu hetzen lassen zu wenig Mörtel zu verwenden und die Mauer schief zu bauen. In der Software-Entwicklung ist das an der Tagesordnung. Die Folge ist eine Bug-Dichte auf peinlichstem Niveau.
Die Gratwanderung: Fast-Prototyping vs. schlampige Arbeit
Im letzten Absatz habe ich von Experimentalinformatik im negativen Kontext gesprochen, doch diese gibt es auch im positiven Kontext. Oft sind die fachlichen Anforderungen anfangs noch unklar, ebenso wie die Code Architektur. In diesem Zustand bereits Tests zu schreiben verlangsamt den Entwicklungsprozess extrem, größere Umbauten erfordern auch größere Änderungen am Test-Code.
Ich finde es völlig nachvollziehbar, dass man anfangs ohne Tests startet bis sich eine Struktur herauskristallisiert hat die man verfolgen möchte. Spätestens wenn ein Stück Software in den produktiven Betrieb geht müssen jedoch Tests existieren.
Man braucht keine große Fantasie um sich vorzustellen, dass hier gern eine Abkürzung genommen wird.
Der Code wird schlecht oder gar nicht automatisiert getestet in Produktion abgeliefert.
Test Driven Development
Es gibt eine Bewegung, die diesem Problem durch eine radikale Lösung entgegen tritt. Test Driven Development beschreibt den Ansatz zuerst einen Test zu schreiben der die fachliche Spezifikation in Test-Code gießt und erst danach die Spezifikation als funktionalen Code zu implementieren.
Durch dieses Vorgehen werden mehrere Fliegen mit einer Klappe geschlagen:
- Tests existieren von Anfang an und können am Ende nicht einfach weggelassen werden
- Dadurch, dass zum Zeitpunkt des Test schreibens der funktionale Code noch nicht existiert reduziert sich das Risiko Details der technischen Implementierung im Test-Code abzuprüfen. Stattdessen beschränkt man sich auf die fachliche Anforderung was die Test-Cases klarer zu lesen und robuster gegen Veränderung macht
Das klingt doch alles toll, warum wird es nicht mehr gemacht. Meine persönliche Erfahrung mit Test Driven Development war folgende:
- Befinde ich mich noch in der “Selbstfindungsphase” der Software, wo die Funktionalität und Architektur noch unklar ist verlangsamt das Schreiben der Tests die Entwicklung extrem und bringt wenig Mehrwert
- Sobald die Architektur und Funktionalität klar ist bringt Test Driven Development einen großen Vorteil und deckt oft auch Fehler links und rechts des eigentlich neuen Codes auf
Test Coverage
Ein vieldiskutiertes Thema ist das Thema Test Coverage. In jedem Projekt werden ein paar Stunden mit der Diskussion verschwendet welche Art von Test-Coverage Metrik man denn nun meine. Wenn ich von Test-Coverage spreche geht es immer um Line-Coverage.
Ich möchte wissen ob all der Code den ich geschrieben habe mindestens von einem Test durchlaufen wird.
Wichtig zu verstehen ist, dass die Test Coverage keinerlei Aussage über die Qualität von Tests geben kann und auch kein Maß für Vollständigkeit ist.
Auf Grund einer schnell quasi unendlich großen Möglichkeit an Kombinationen durch verschiedene Eingabewerte und Infrastruktur-Abhängigkeiten ist es nicht möglich Code vollständig zu testen.
Die Test-Coverage beschreibt jedoch eine andere Metrik. Es ist eine Mindestanforderung.
Nun kann man sich überlegen: Wieviel Prozent des Codes der geschrieben wurde soll von einem Test durchlaufen werden (durchlaufen! nicht unbedingt getestet). Die Antwort ist klar: 100%. Jeder Code der geschrieben wurde sollte mindestens 1 mal während dem Abfahren der Tests “besucht” werden.
100% Testabdeckung klingt gigantisch und so als ob man etwas großes erreicht hätte. In Wahrheit ist es eine Mindestanforderung.
Warum haben dann viele Teams die Tools zur automatischen Code Analyse verwenden wie Sonar ein Coverage Target von 85% oder 90%?
Dies liegt vorwiegend am nächsten Punkt.
Infrastruktur-Abhängigkeiten sind teuer und komplex (aber notwendig)
Man stelle sich vor einen Test für diese Funktion zu schreiben. Easy.
Nun stelle man sich vor einen Test für die selbe Funktion zu schreiben, jedoch kommen die Input Werte aus einer Kafka-Message und man möchte explizit den Infrastruktur Teil der Software mit testen. Horror.
Es gibt verschiedene Grade des Schmerzes. Man kann verschiedene Mock-Szenarien erstellen, oder gleich einen oder mehrere Test-Container mit Kafka (+ Zookeeper) hochfahren. Zu testen gilt auch was passiert in Fällen von fehlerhaften Messages, etc. . Zwischen jedem Test muss evtl. der Kafka Container komplett abgerissen und neu aufgesetzt werden.
Die Tests werden langsam, ressourcen-intensiv und fehleranfällig.
In der Realität ist man leider dazu gezwungen diese Tests trotzdem zu schreiben. Viele Bugs hängen mit der Infrastruktur zusammen und gerade eine korrekte Fehlerbehandlung ist extrem wichtig. Manche Funktionalität lässt sich nur extrem schwer mocken, auch in diesen Fällen braucht man die originale Infrastruktur. z.B. via Test-Containers.
Behavior Driven Development - BDD - Die fachliche Logik als “First Class Citizen”
Eine andere interessante Bewegung die mein Verständnis von Testing komplett verändert hat ist BDD (Behavior Driven Development).
Bei BDD stehen die fachlichen Anforderungen im Vordergrund, die fachliche Beschreibung wird ohne jeglichen Code erstellt und kann auch für sich selbst stehen.
Automatisierte Tests testen dann die fachliche Beschreibung ab.
Bekannt ist der für BDD typische Dreisatz GIVEN - WHEN - THEN.
GIVEN beschreibt das Grundsetup was man benötigt um das Verhalten abzuprüfen. WHEN -> THEN beschreibt das erwartete Verhalten.
1 2 3 4 5 6 7 8 9 10 11 12 |
@Test void SHOULD_increase_deposit_balance_WHEN_positive_amount_is_deposited() { // Given: a bank account with an initial balance of $100 BankAccount account = new BankAccount(100); // When: a deposit of $50 is made int depositAmount = 50; account.deposit(depositAmount); // Then: the balance should be increased by $50, resulting in $150 assertEquals(150, account.getBalance()); } |
Schaut man sich jetzt alleine die Kommentare an ist dies eine für Business Analysten verständliche Beschreibung des Testcases.
1 2 3 |
// Given: a bank account with an initial balance of $100 // When: a deposit of $50 is made // Then: the balance should be increased by $50, resulting in $150 |
Es gibt Tools with z.B. Fitnesse die diese Informationen in einer Wiki Form darstellbar machen.
Naming der Tests
Hier gibt es viele Konventionen, mir persönlich ist nur wichtig, dass man eine Idee
davon bekommt was im Test passiert, ich habe mich für die Should ... When Konvention
entschieden.
1 2 3 |
shouldThrowExceptionWhenInputIsNull() shouldReturnDefaultValueWhenSettingIsMissing() shouldSendEmailNotificationToAllUsersWhenSystemReachesThresholdLimit() |
Meine persönlicher Testing Workflow und bewusste Trade-Offs die ich eingehe
Wenig Tooling
Als ich angefangen habe mich mit Testing zu beschäftigen wollte ich alles richtig machen, BDD Frameworks, Cucumber, etc. . Hat man hier keine Routine hält man sich stark auf, neue Frameworks oder Techniken in existierende Codebases oder Teams einzuführen ist ein langwieriger Prozess.
Ich bin dazu übergegangen kein oder wenig existierendes Tooling zu verwenden. Die wichtigsten Themen wie BDD, Test-Naming und Coverage Metriken kann man einfach im Code durch Kommentare und Funktionen der IDE (Coverage) abdecken.
Mit gutem Beispiel voran gehen
Eigentlich alle Entwickler:innen mit denen ich gesprochen habe wollen Tests schreiben. Oft sind es die äußeren Umstände die dies verhindern und fehlende Praxis.
Hier kann man einfach mit gutem Beispiel voran gehen und selbst nach bestem Wissen und Gewissen gute Arbeit abliefern. So gibt es in der Codebase immer mehr Beispiele die es anderen leichter machen sich daran zu orientieren und dann ebenfalls Tests zu schreiben.
Wenn man dann noch diese Arbeit gelegentlich vorstellt, Pair-Programmings macht und das Team-Management dabei unterstützt Tests nicht als separaten Teil eines Features (Feature + Test), sondern als Teil des Features zu kommunizieren, dann ist bereits viel gewonnen.
So wie die Maurer:in nicht eine Mauer baut die umfallen kann und danach schaut ob sie gerade ist und steht. Genauso bauen wir keine Software und schauen danach ob sie funktioniert. Tests sind unvermeidlicher Teil der Entwicklung und die Konsequenz sie nicht zu haben ist fehlerhafter und unwartbarer Code.

100% Testabdeckung … im Business Code
Ich versuche eine Testabdeckung von 100% im Business Teil der Software zu erreichen. Wenn dieser sauber geschnitten ist hat er keine Abhängigkeiten zu Infrastrukturkomponenten, und entsprechend ist es einfach und sogar erforderlich hier 100% zu erreichen um die fachlichen Anforderungen zu erfüllen.
Im Anschluss versuche ich 100% Testabdeckung im Infrastruktur “Glue-Code” zu erreichen. Komme ich an Punkte die einen großen Arbeitsaufwand bedeuten überspringe ich diese Parts und verschiebe sie auf später.
Solche größeren Blöcke können dann vernünftig in der Zeitplanung eingeplant werden.
Durch diesen gemischten Ansatz erreicht man ein sehr großes Vertrauen in die Codebase der Business-Logik, bei gleichzeitig überschaubarem Zeitaufwand.
Angenehmer Nebeneffekt: Man wird nicht frustriert durch kompliziertes Test-Setup der Infrastruktur-Tests.
Bug Test Driven Development
Während es schwierig ist von Anfang an Test-Driven zu arbeiten habe ich mir angewöhnt ausnahmslos bei Bugs test-driven zu arbeiten.
Einen Bug muss man sowieso reproduzieren. Erst reproduziere ich ihn aus User-Sicht. Dann reproduziere ich ihn aus Code-Sicht via das Schreiben eines Tests.
Dabei lerne ich bereits sehr viel über den internen Aufbau des Codes, sprich 80% der Zeit hätte ich sowieso reinstecken müssen um mir den Code anzusehen um den Bug zu fixen.
Der Bugfix selbst ist dann oft relativ schnell erledigt. Der Vorteil: ab jetzt kann dieser Bug nie wieder unbemerkt auftreten. Ein weiterer Vorteil: langsam aber stetig steigt die Test-Coverage in der gesamten Codebase.
Todo-Liste von offenen Themen
Ich führe eine kurze Todoliste von Themen die aktuell noch nicht angegangen werden können aber wichtig sind und versuche regelmäßig Punkte davon abzuarbeiten. z.B.:
- Kafka Integrationtest mit Test Containers
Konsequentes BDD und Benennung von Tests
Ich versuche, wo möglich, High-Level Tests aus Business Perspektive zu schreiben. Das heißt die Tests sollten auf Business-Service Ebene Business Cases abfragen und nicht nur auf technische Ebene die Funktionalität einzelner Funktionen.
Das hat den Vorteil, dass diese Tests die Anforderungen des Fachbereichs sicherstellen und auch nach starken Refactorings noch weiter funktionieren ohne umgebaut werden zu müssen.