adesso Blog

F.I.R.S.T. ist ein Akronym, das verschiedene Prinzipien für das Schreiben von Unit-Tests zusammenfasst. Die F.I.R.S.T.-Prinzipien wurden von Robert C. Martin in Clean Code kurz vorgestellt und ich möchte sie in diesem Blog-Beitrag weiter vertiefen. Gerade beim Einstieg in die Programmierung können diese Prinzipien als Leitfaden dienen. Ziel der Prinzipien ist es, Unit-Tests verständlich, wartbar und aussagekräftig zu machen.

Dieser Blog-Beitrag richtet sich an alle neuen und erfahrenen Entwicklerinnen und Entwickler, die mehr über das Schreiben von Unit-Tests erfahren möchten.

TL;DR #

  • Fast: Unit-Tests sind schnell und benötigen nur wenige Millisekunden.
  • Indepent/Isolated: Unit-Tests sind voneinander unabhängig und können in beliebiger Reihenfolge ausgeführt werden.
  • Repeatable: Unit-Tests werden häufig und in verschiedenen Umgebungen ausgeführt und liefern zuverlässig die gleichen Ergebnisse.
  • Self Validating: Ergebnisse werden programmatisch ausgewertet und liefern ein binäres Ergebnis (success/fail).
  • Timely/Thorough: Unit-Tests werden “rechtzeitig” geschrieben, indem sie vor dem produktiven Code erstellt werden. Außerdem wird “gründlich” getestet, indem (neben den Erfolgsfällen) auch Fehlerfälle, Grenzwerte und Äquivalenzklassen getestet werden.

F - Fast

Unit-Tests sind schnell! Wenn man nach Anpassungen im Code die Unit-Tests nicht ausführen möchte, weil sie einem persönlich zu lange dauern, dann ist das ein Warnsignal, dass es sich bei den Tests nicht um Unit-Tests handelt. Denn ein Unit-Test dauert bei der Ausführung nur wenige Millisekunden und dies sollte keinen signifikanten Einfluss auf die persönliche Wahrnehmung haben. Allein das Lesen dieses Satzes dauert länger als die Zeit, die mehrere hundert Unit-Tests zur Ausführung benötigen. Wenn ein Unit-Test länger als eine Sekunde dauert, sollte man sich diesen genauer ansehen, da sich dahinter meist ein Integrationstest verbirgt. Bei einem Integrationstest wird zusätzlicher Code außerhalb unserer zu testenden Methode oder Funktion ausgeführt. Das kann sehr schnell passieren, wenn z.B. Abhängigkeiten nicht “gemockt” wurden. Mocks sind Stellvertreterobjekte/-funktionen, die nur in Unit-Tests existieren und so tun, als wären sie ein konkretes Objekt einer Klasse oder Funktion. Im schlimmsten Fall werden ohne Mocks Live-Dienste aufgerufen und bevor man sich versieht, hat man seinen ersten kleinen DOS-Angriff erfolgreich durchgeführt oder Testdaten in eine produktive Datenbank geschrieben. Es sollte also klar sein, welche Auswirkungen Tests haben können, wenn sie nicht nur den zu testenden Code ausführen.

I - Isolated / Independent

Unit-Tests sind voneinander unabhängig! Das Ziel: Unit-Tests können in beliebiger Reihenfolge ausgeführt werden und es spielt keine Rolle, welcher Unit-Test zuvor ausgeführt wurde. Dementsprechend sollten keine Variablen und Objekte zwischen den Unit-Tests geteilt werden, auch nicht das Testobjekt selbst. Generell sollten alle Abhängigkeiten von der zu testenden Methode oder Funktion betrachtet und isoliert werden, zum Beispiel indem sie durch Mocks ersetzt werden. Dadurch werden unerwünschte Seiteneffekte vermieden und die Unit-Tests werden nicht nur aussagekräftiger, sondern auch wartbarer.

Die drei A’s (oder auch given, when, then)

Zur besseren Strukturierung und Auffindbarkeit von Abhängigkeiten kann die Verwendung von arrange, act, assert oder alternativ given, when, then hilfreich sein. Dabei wird ein Unit-Test in drei Bereiche unterteilt, indem diese drei Schlüsselwörter als Kommentare in den Unit-Test geschrieben werden.

Die folgenden Elemente sollten in diesen drei Bereichen enthalten sein:

arrange (oder given)

Hier findet die Testvorbereitung statt, wo Variablen definiert und alle weiteren Konfigurationen gesetzt werden. Dies ist sozusagen unsere Einführung in den Unit-Test und hilft uns, die Situation, die unser Unit-Test abdecken soll, besser zu verstehen. Wenn alle Unit-Tests die gleiche Testvorbereitung haben, können und sollten diese ausgelagert werden. Dazu bieten in der Regel alle gängigen Test-Frameworks, zum Beispiel JUnit oder Jest, Setup-Möglichkeiten an, in denen unsere Testvorbereitungen ausgelagert werden können. Bei Jest sind dies die Funktionen beforeEach und beforeAll. In JUnit die Annotationen @BeforeEach und @BeforeAll.

act (oder when)

Hier findet die eigentliche Testausführung statt. Das heißt, hier wird die zu testende Methode oder Funktion aufgerufen.

assert (oder then)

In diesem Bereich wird das Ergebnis unseres Tests programmatisch ausgewertet, indem das Ergebnis mit unserer Erwartung verglichen wird. Grundsätzlich sollten Unit-Tests immer nur ein Ergebnis erwarten und auswerten. Das heißt, ein Unit-Test enthält in der Regel nur einen assert()- oder expect()-Aufruf.

Zur besseren Verständlichkeit ein kleines Beispiel:

Angenommen, wir haben einen Online-Shop für Katzenbedarf und möchten allen Premium-Kunden einen Rabatt von 15 Prozent gewähren. Wir haben eine REST-Schnittstelle, die angesprochen werden kann, um einem Benutzer den Rabatt zu gewähren ( beispielsweise GET /v1/discount). Ich persönlich bevorzuge die Given-when-then-Schreibweise, weil wir damit die Unit-Tests vorher einfach in Prosa formulieren können:

  • Given a user with premium status
  • When this user makes a request
  • Then the user should receive 15 percent discount

Dies kann dann in einen konkreten Unit-Test wie folgt aussehen:

	
	describe('getDiscountForUser', () => {
	    let testSubject: DiscountController;
	    beforeEach(() => {
	        testSubject = new DiscountController();
	    });
	    it('should return 15 % discount when user has premium status', () => {
	        // given a user with premium status
	        const user = UserService.newWithPremiumStatus();
	        // when this user makes a request
	        const discount = testSubject.getDiscountForUser(user)
	        // then the user should receive 15 % discount
	        expect(discount.value).toEqual(15);
	    });
	    [...]
	});
	

Keine Sorge, mehr als given, when und then oder arrange, act und assert braucht man im Unit-Test nicht zu schreiben. Ich habe das hier nur gemacht, damit man den Übergang von den oben in Prosa geschriebenen Texten zur konkreten Testimplementierung besser nachvollziehen kann.

Durch die Aufteilung des Unit-Tests in diese drei Bereiche wird die Lesbarkeit erhöht, was uns wiederum hilft, Abhängigkeiten aufzudecken, insbesondere wenn das Test-Setup mehr als nur eine Zeile Code ist. Im Prinzip können wir unseren Unit-Test wie eine kleine Geschichte lesen. Wir haben im given-Bereich eine Einleitung, in der sich unsere Geschichte (beziehungsweise unser Unit-Test) aufbaut. Danach steigt die Spannung bis zum Höhepunkt im when-Bereich, in dem eine konkrete Handlung (die Testausführung) stattfindet. Nun neigt sich die Geschichte im then-Bereich dem Ende zu und wir vergleichen die Ergebnisse der Handlung mit den Erwartungen, die wir vorher an unsere Geschichte gestellt haben. Zugegebenermaßen ist dies keine besonders spannende Geschichte, da wir bereits im Vorfeld Vermutungen anstellen, die sich am Ende unserer Geschichte bewahrheiten sollen. Aber genau das wollen wir ja bei einem Unit-Test: Erwartungen, die sich erfüllen. Letztendlich sollte ein guter Unit-Test so einfach zu verstehen sein wie eine einfache Geschichte. Es sollte also keine plötzlichen Site-Stories (Sprünge im Code) geben, die mich als Leser zwingen, in der Geschichte hin und her zu springen, um das Gesamtbild zu verstehen, sondern ich möchte einen Unit-Test einfach von oben nach unten durchlesen und verstehen können.

R - Repeatable

Unsere Unit-Tests sollen häufig und in verschiedenen Umgebungen ausgeführt werden können und dabei immer die gleichen Ergebnisse für die gleichen Eingaben liefern. Das bedeutet, dass ein Unit-Test

  • unabhängig vom Zeitpunkt der Ausführung,
  • unabhängig von zuvor ausgeführten Unit-Tests und
  • unabhängig von der Umgebung

immer das gleiche nachvollziehbare Ergebnis zurück.

S - Self Validating

Unit-Tests sollen das Ergebnis programmatisch auswerten. Wir wollen nicht nach jeder Testausführung das Ergebnis selbst auswerten müssen, indem wir zum Beispiel in Logfiles schauen. Zumal gut getestete Projekte leicht auf mehrere tausend Unit-Tests kommen. Man stelle sich vor, man müsste bei dieser Anzahl von Unit-Tests die Ergebnisse manuell auswerten. Außerdem sollte ein Unit-Test ein explizites und binäres Feedback geben, also success oder fail und nichts dazwischen. Manchmal ist die Welt nur schwarz und weiß, oder rot und grün.

T - Timely / Thorough

Robert C. Martin definiert das “T” in F.I.R.S.T. als “timely”. Das bedeutet, dass Unit-Tests vor dem produktiven Code geschrieben werden sollten. Dadurch wird das Schreiben von Unit-Tests einfacher und der produktive Code wird zwangsläufig testbar. Es ist einfach schwieriger, produktiven Code von Anfang an testbar zu schreiben, als wenn man schon vorher einen Unit-Test hat, der einen zwingt, den produktiven Code testbar zu machen.

Im Laufe der Zeit hat sich neben dem “T” noch ein weiteres Prinzip eingeschlichen, nämlich “Thorough”. “Thorough” bedeutet “gründlich”. Wir schreiben unsere Unit-Tests also gründlich. Das erreichen wir, indem wir nicht nur Testfälle für die Erfolgsfälle schreiben, sondern auch für die Fehlerfälle, die Grenzfälle und die Äquivalenzklassen.

Was dieses Prinzip letztlich bedeutet, soll das folgende Beispiel verdeutlichen:

Nehmen wir eine bekannte Kata (eine kleine abgeschlossene Übung) “FizzBuzz”. Die Aufgabe lautet

  • für natürliche Zahlen, die durch 3 teilbar sind, “fizz” auf der Konsole ausgegeben wird,
  • für natürliche Zahlen, die durch 5 teilbar sind, “buzz” auf der Konsole ausgeben,
  • für natürliche Zahlen, die sowohl durch 3 als auch durch 5 teilbar sind, soll “fizzbuzz” auf der Konsole ausgegeben werden und
  • für alle anderen Zahlen soll die jeweilige Zahl selbst auf der Konsole ausgegeben werden.

Zusätzlich nehmen wir noch die Bedingung auf, dass nur Zahlen zwischen 1 und 30 berechnet werden sollen. Bei Zahlen außerhalb unseres Intervalls soll ein Fehler ausgegeben werden.

Aus dieser Aufgabe ergeben sich nun diverse Testfälle:

Positiv-Testfälle
  • Given is number 2. When fizzbuzz function executes, Then the answer should be “2”.
  • Given is number 3. When fizzbuzz function executes, Then the answer should be “fizz”.
  • Given is number 5. When fizzbuzz function executes, Then the answer should be “buzz”.
  • Given is number 15. When fizzbuzz function executes, Then the answer should be “fizzbuzz”.
Fehlerfall-Testfälle
  • Given is number -10. When fizzbuzz function executes, Then an error should be thrown.
  • Given is number 310. When fizzbuzz function executes, Then an error should be thrown.
Grenzwert-Testfälle
  • Given is number 1. When fizzbuzz function executes, Then the answer should be “1”.
  • Given is number 30. When fizzbuzz function executes, Then the answer should be “fizzbuzz”.
  • Given is number 0. When fizzbuzz function executes, Then an error should be thrown.
  • Given is number 31. When fizzbuzz function executes, Then an error should be thrown.
Testfälle für Äquivalenzklassenabdeckung
  • Given is number 6. When fizzbuzz function executes, Then the answer should be “fizz”.
  • Given is number 10. When fizzbuzz function executes, Then the answer should be “buzz”.

Man sieht an diesem Beispiel sehr gut, wie viele Testfälle für eine so einfache Aufgabe geschrieben werden können, um diesem Prinzip gerecht zu werden. Wir haben hier Erfolgs-, Fehler- und Grenzwerttestfälle geschrieben und noch weitere, um alle Äquivalenzklassen abzudecken. Bitte beachten Sie, dass die hier aufgeführten Testfälle nicht in Stein gemeißelt sind. Es gibt natürlich andere Zahlen, die man hier hätte wählen können. Auch die Anzahl der benötigten Testfälle ist variabel. Denn Fakt ist, dass man nicht jedes Szenario testen kann und auch nicht möchte. Das würde einfach viel zu lange dauern und irgendwann ist der Mehrwert der Unit-Tests auch nicht mehr gegeben, da diese ja auch gewartet werden müssen. Aus diesem Grund versuchen wir so viele sinnvolle Tests wie möglich zu schreiben, aber gleichzeitig so wenige wie nötig. Um dies zu erreichen, kann man sich beim Schreiben von Testfällen folgende Fragen stellen:

  • Sind alle wichtigen positiven Testfälle vorhanden?
  • Sind alle wichtigen negativen Testfälle vorhanden?
  • Sind alle Grenzwerte abgedeckt?
  • Gibt es weitere Äquivalenzklassen, die ich noch testen sollte?

Fazit

Nach dem Verinnerlichen und Verstehen dieser Prinzipien sollte das Schreiben von Unit-Tests etwas leichter von der Hand gehen. Dennoch sollte man sich immer bewusst sein, dass diese Prinzipien nicht dogmatisch angewendet werden sollten. Man kann sich in einem Projekt auch für andere Prinzipien entscheiden oder diese Prinzipien abwandeln, wichtig ist hier das Verständnis im Team. Letztendlich finden sich die Test-F.I.R.S.T.-Prinzipien in Projekten mit gut strukturierten Unit-Tests immer wieder, wenn auch eventuell in abgewandelter Form.

Bild Dennis-Karim  Stern

Autor Dennis-Karim Stern

Dennis-Karim Stern ist als Senior Software Engineer bei adesso in Essen tätig. Dort unterstützt er den Bereich Public mit seinen Erfahrungen in der Entwicklung von Full-Stack-Anwendungen im Java- und JavaScript-Kosmos.

Kategorie:

Softwareentwicklung

Schlagwörter:

CleanCode

Testing

Diese Seite speichern. Diese Seite entfernen.