Test-Driven-Development – du wirst es dir danken

Kennst du es? Dieses schweißtreibende Gefühl ein neues Feature oder einen gefixten Bug in die Produktionsumgebung zu schicken? Haben die Änderungen die gerade eingefügt worden sind unerwünschte Nebeneffekte auf die existierende Code-Base?

Dieses mulmige Gefühl löst sich durch eine hohe Testabdeckung. Aber Vorsicht: Eine hohe Testabdeckung heißt nicht, dass das System fehlerfrei ist. Allerdings ist es ein guter Indikator für ein gesundes System und sorgt für eine gewisse Sicherheit bei Änderungen an der Code-Base.

Grundlagen: Testing

Given When Then

Das sind die drei magischen Worte die für mich einen Test ausmachen. Bei mir sieht Test-Code in der Regel immer wie folgt aus:


@Test
@DisplayName("Beschreibung")
public void givenCONDITION_whenACTION_thenRESULT() {
  condition();
  //Action
  assertThat(result, is(expectation));
}

In diesem Code-Abschnitt sehen wir einen ausdrucksstarken Funktionsnamen, eine Funktion (DSL!), die unseren Test in die von uns definierte Ausgangslage bringt und abschließend genau eine Assertion.

Kurz und knackig

Wie bei unserem Produktions-Code ist die Lesbarkeit ein wichtiger Bestandteil. Aus diesem Grund sollten unsere Tests auch mit den gleichen hohen Anspruchen geschrieben werden wie der Produktions-Code. Das bedeutet:

  • Ausdrucksstarke Funktions- und Variablennamen
  • kurze Funktionskörper

Rot-Grün Testing

Das Mantra beim TDD ist folgendes: Der Test-Code entsteht unmittelbar (wenige Sekunden) bevor der Produktions-Code entsteht. Es entsteht also zunächst ein Testszenario, das im ersten Fall natürlich fehlschlägt, da kein Produktionscode vorhanden ist (leerer Funktionskörper).

Nun ist es an uns, uns weitere Testszenarien zu überlegen (Edge- und Cornercases). Zu jedem Szenario wird wieder zunächst ein Test geschrieben, der fehlschlägt, weil die entsprechende Logik noch nicht implementiert ist und im Laufe der weiteren Entwicklung des Produktions-Codes grün wird. Auf diese Weise entstehen Test- und Produktionscode quasi simultan.

Das richtige (Test) Mindset

In vielen Unternehmen ist eine ausreichend hohe Testabdeckung immer noch nicht üblich. Auf den ersten Blick ist das vielleicht auch verständlich, denn jede Zeile „echter“ und „produktiver“ Code wird mit mehreren Zeilen getestet.

Es wird noch „schlimmer“. In Tests wird oft eine eigene DSL (Domain Specific Language) genutzt, um den Code im Testcontext lesbarer zu machen. Dadurch wird der Aufwand der in Tests fließt noch höher.

Was spricht also für das ausführliche Testing? Schließlich haben beim TDD die Tests einen so hohen Stellenwert, dass sie noch vor dem Produktivcode entstehen.

Vorteile

In meinen Augen hat TDD einige sehr gewichtige Vorteile:

Auf lange Sicht entsteht ein wartbares System

Wie bereits angesprochen erhalten wir durch eine ausreichend hohe Testabdeckung eine gewisse Zuversicht, dass wir mit unseren Änderungen an der Code-Base keine Fehler in bereits implementierten Funktionen produzieren.

Ein gut wartbares und ein schlecht wartbares System sind auf den allerersten Blick nicht sofort voneinander zu unterscheiden. Der Unterschied wird erst nach einiger Zeit klar, wenn in dem schlecht wartbaren System Feature Requests immer mehr Zeit für die Bearbeitung benötigen, während in einem gut wartbaren System die Zeit mehr oder weniger konstant bleibt.

Schlechtes Design wird früh erkannt

Während des Testens werden schlecht designte Methoden schnell enttarnt.

Tests enthüllen schlechtes Design von Anfang an.

Es ist äußerst schwierig eine schlecht designte Methode zu testen. Das fängt bereits bei der Anzahl der Assertions an. Wenn wir auf eine Methode treffen, bei der es nötig ist auf mehr als nur einen Zustand zu prüfen, dann spricht das in vielen Fällen dafür, dass die Methode zum Beispiel gegen das Single-Responsibility-Prinzip verstößt. Ebenso schwer ist es besonders große Methoden zu testen, da es dort in der Regel sehr viele Edge- und Corner Cases gibt.

Logikfehler werden schon früh erkannt

Durch unsere selbst auferlegte Regel, für jeden Edge- und Corner Case einen Test zu erstellen und erst danach den Produktions-Code dafür zu schreiben, decken wir schon sehr früh Logikfehler in unserem Produktions-Code auf. Das heißt natürlich nicht, dass es nicht möglich ist, dass wir manche Testfälle schlichtweg übersehen, aber für die Fälle an die wir gedacht haben ist unser Code definitiv korrekt.

Nachteile

Natürlich gibt es auch einige negative Aspekte beim TDD. Insbesondere der, kurzfristig betrachtet, höhere Aufwand.

Es entsteht eine neue DSL nur für Tests

Wir erinnern uns kurz an den Aufbau unserer Tests: Given-When-Then. In der „Given“ Phase bringen wir unser System in einen von uns definierten State. Es ist gut möglich, dass wir eine Vielzahl von Funktionen ausführen müssen, die im Testkontext eine nicht unbedingt lesbare Sammlung an Aufrufen ergibt, um unser Testobjekt in den gewünschten Zustand zu bringen.

Stellen wir uns vor, wir entwickeln ein Smart Home System. Es werden eine Vielzahl von Sensoren im System verwaltet und sobald die Sensoren einen bestimmten Zustand erreichen, soll ein bestimmter Alarm ausgelöst werden.


@Test
@DisplayName("Ein Feueralarm soll ausgelöst werden, wenn der CO Sensor einen hohen CO Wert misst und der Wärmesensor über 50 Grad erreicht")
public void givenCOAndHeatSensor_whenCOValueTooHighAndHeatTooHigh_thenTriggerFireAlert() {
  coSensor.setValue(90);
  heatSensor.setValue(50);

  Alert alert = listener.getLastAlert();

  assertThat(alert, is(not(nullValue()));
  assertThat(alert.type, is(ALERT_TYPE.FIRE_ALERT));
}

Noch ist der Test relativ übersichtlich. Durch die Beschreibung des Tests wird den meisten, die den Code lesen, klar sein, dass die Sensorwerte schon in Ordnung (beziehungsweise zu hoch ;-)) sein werden. Aber interessiert uns als Leser des Tests wirklich, welche Werte die einzelnen Sensoren annehmen? Was bedeutet 90 bei dem CO Sensor? Ist der Temperatur Sensor in Celsius oder Fahrenheit? Was ist, wenn noch deutlich mehr Sensoren für einen Feueralarm benötigt werden? Wie wäre es mit folgendem Vorschlag:


@Test
@DisplayName("Ein Feueralarm soll ausgelöst werden, wenn der CO Sensor einen hohen CO Wert misst und der Wärmesensor über 50 Grad erreicht")
public void givenCOAndHeatSensor_whenCOValueTooHighAndHeatTooHigh_thenTriggerFireAlert() {
  houseCaughtFire();

  Alert alert = listener.getLastAlert();

  assertThat(alert, is(not(nullValue()));
  assertThat(alert.type, is(ALERT_TYPE.FIRE_ALERT));
}

Für den Leser des Tests wird eindeutig klar, dass das System in einen Zustand gebracht wird, in dem ein Feuer im Haus simuliert wird. Hinter der Methode können sich nun auch noch 10 weitere Sensoren befinden die in einen bestimmten Zustand gebracht werden sollen.

Durch eine solche DSL in der Testumgebung, die sich mit Sicherheit von der DSL in der Produktionsumgebung unterscheiden wird, erzeugen wir lesbaren und wartbaren Testcode.

Kurzfristige Bindung von Ressourcen

Kurzfristig betrachtet bindet TDD viele Ressourcen, da wir mindestens 50% mehr Code schreiben, als ohne die Tests. Außerdem fließt Zeit in die genaue Analyse von Edge- und Corner Cases.

Die eigenen Ansprüche automatisiert einhalten

Wir haben uns bis jetzt hohe Standards auferlegt und das ist auch gut so. Damit wir uns auch daran halten, ist es eine gute Idee einen Prüfprozess in unsere CI Pipeline einzubauen.

SonarQube

SonarQube ist eine Software zur statischen Codeanalyse. Es werden Code Smells erkannt, Sicherheitslücken aufgezeigt und vieles mehr.

Ein wichtiger Baustein von SonarQube ist das sogenannte Quality Gate. Es ist frei definierbar, aber SonarQube stellt gute Default Grenzen bereit. Im Standard wird das Quality Gate nicht passiert, wenn neuer Code zu weniger als 80% getestet wurde, Bugs implementiert wurden oder Sicherheitslücken entstanden sind.

Der SonarQube-Scanner kann dann zum Beispiel den Code eines Merge Requests analysieren und sollte das Quality Gate fehlschlagen den Merge verhindern.

Fazit

Langfristig gesehen hat TDD meiner Meinung nach ausschließlich Vorteile. Auch wenn es kurzfristig für einige Entwickler/Unternehmen vielleicht verlockend ist, die Zeit lieber in Produktions-Code, anstatt in Tests zu investieren, dann fehlt dort in jedem Fall die Weitsicht wartbare, zukunftsorientierte Systeme zu erstellen.

Lob, Kritik, Anregungen?

Wir freuen uns auf Ihre Meinung


Ältere Posts dieses Autors