“Da kann nichts passieren, wir haben ja alles getestet”, hört man häufig in der Software-Entwicklung und am Ende löst eine kleine Zusatzanforderung doch eine Dominokette von Problemen aus. Klassisches Testen ist oft nicht ausreichend und durch stumpfes Wiederholen der gleichen Testcases sehr fehleranfällig. Die Lösung heißt: Test Driven Development (TDD)
Bei TDD schreibst du erst automatisierte Tests, bevor du die zugehörige Logik implementierst. In diesem Artikel erklären wir dir die Vorteile, die TDD bietet und zeigen dir unsere Tipps aus der Praxis, damit du TDD in PHP, bzw. Laravel optimal nutzen kannst.
Was ist Test Driven Development?
In der Softwareentwicklung werde automatisierte Tests oft etwas stiefmütterlich behandelt. Automatisierte Tests führen bestimmte Code-Stellen aus und prüfen dann das Ergebnis mit einer vorher angegebenen Erwartung. Dies kann zum Beispiel in Deploymentpipelines oder im Build-Prozess passieren, Entwickler*innen können aber auch während der Entwicklung diese Tests zur Unterstützung durchlaufen lassen. Unsere Erfahrung zeigt, dass die meisten Teams zwar ein Testframework im Projekt eingerichtet haben und es oft auch Tests gibt, diese aber meist veraltet sind und nicht mehr gepflegt werden.
Beim TDD werden Tests nicht nur geschrieben, sondern sind essenzieller Bestandteil des Entwicklungsprozesses und werden vor der eigentlichen Entwicklung erstellt. Es werden also Tests geschrieben, die zunächst fehlschlagen und danach wird die Logik geschrieben, bis die Tests fehlerfrei durchlaufen.
Die Vorteile von Test Driven Development
Generell ist es besser, aktuelle automatisierte Tests zu haben. Hierdurch kommt Code fehlerfreier auf das Produktivsystem, vor allem, weil negative Nebeneffekte stark reduziert werden. Wird zum Beispiel etwas implementiert, das einen Bug in einem älteren Feature erzeugen würde, fällt dies direkt auf, wenn das ältere Feature durch Tests abgedeckt ist.
Tests zuerst zu schreiben, bietet dir verschiedene Vorteile. Zunächst kannst du als Entwickler*in klar zwischen dem Nachdenken über die Logik und dem Schreiben von Code unterscheiden. Wenn du im ersten Schritt die Tests schreibst, machst du dir Gedanken über die Struktur, die Randfälle und Nebeneffekte. Beim Coden kannst du dich dann viel mehr auf den Code an sich konzentrieren, da man ja nur noch die Tests zum Durchlaufen bekommen muss. Dies bringt automatisch mit sich, dass du viel mehr mentale Ressourcen für Clean-Code-Prinzipien, Software-Architektur und Datenbank-Design nutzen kann.
Ein weiterer Vorteil ist, dass du dir mehr Gedanken über Randfälle machst. Wenn du die Tests erst nach der Logik schreibst und diese noch im Kopf hast, deckst du eben auch mit den Tests normalerweise nur diese Logik ab und vergisst oft Randfälle. Wenn du die Tests zuerst schreibst, bist du noch losgelöst von den Hürden der eigentlichen Implementierung und kannst dich auf die Business-Logik und das Konzept konzentrieren.
Zuletzt spart das Tests schreiben viel manuellen Testaufwand, auch während der Entwicklung. Das lokale Ausführen der Tests sorgt dafür, dass man sich nicht erst komplexe Test-Szenarien im Programm immer wieder zusammenklicken muss.
Zusammengefasst
- Entwickler*innen können klar die Logikplanung von der Umsetzung unterscheiden
- Randfälle werden besser abgedeckt
- Manueller Testaufwand ist geringer
Tests in PHP und Laravel
Typischerweise nutzt man in PHP das Test-Framework PHPUnit. Es gibt allerdings auch moderne Alternativen, wie zum Beispiel PEST , was mehr an Javascript-Test-Frameworks angelehnt ist. PHPUnit befindet sich derzeit in Version 9 und wird von Sebastian Bergmann entwickelt. Außerdem ist PHPUnit standardmäßig in den meisten Frameworks bereits integriert, zum Beispiel in Laravel.
Mit Laravel erhält man bereits von Haus aus Test-Komponenten für fast alle Framework-Features, in Laravel Fakes genannt. Die wichtigsten sind in der Dokumentation aufgelistet.
Hierdurch erhält man in Laravel zum Beispiel die Möglichkeit, den E-Mail-Versand effizient zu testen. Man beschreibt einfach, welche Daten man in der Mail erwartet in einem Test und führt diesen aus, sodass man nicht bei jeder kleinen Anpassung die Mail wirklich abschicken und sich in Diensten, wie zum Beispiel mailtrap.io, ansehen muss.
Sehr nützlich sind diese Fakes auch für das Queue-System. In Feature-Tests kann einfach geprüft werden, ob bestimmte Jobs mit bestimmten Parametern in der Queue abgelegt wurden und die Logik des Jobs kann wiederum mit einem einfachen Unittest abgedeckt werden.
Auch das Storage-System bringt eine Fake-Klasse mit. Wenn diese konsequent genutzt und getestet wird, kann eine Laravel-Anwendung rasend schnell auf ein anderes Datei-System umgezogen werden, zum Beispiel auf S3 von AWS, oder wie bei uns der Fall auf das äquivalente Object Storage System der Open Telecom Cloud.
Unterschied zwischen Feature- und Unit-Tests
Unit-Tests sind quasi die kleinsten Tests. Hierbei wird in der Regel nur eine Klasse, bzw. eine Methode getestet. In unserer Terminbuchungssoftware AKEYI (akeyi.de) nutzen wir dies zum Beispiel bei jeder Methode, die zum Berechnen der buchbaren Zeiträume beiträgt.
Feature-Tests dagegen testen sozusagen “einen Weg durch das Programm” von vorne bis hinten, zum Beispiel von einer Anfrage bis zu einer Antwort. Solche Tests benutzen wir für die APIs von AKEYI oder auch zum Testen mehrschrittiger Formulare mit unterschiedlichen Eingaben.
Generell sind Feature-Tests komplexer und decken oft auch Logik mit ab, die bereits einzeln in einem Unit-Test abgedeckt wurde. Beim Test Driven Development empfehlen wir vorab das Schreiben von Feature-Tests. Unit-Tests können immer dann ergänzt werden, wenn man vor einem größeren, bzw. nicht-trivialen Algorithmus sitzt.
Tipps aus der Praxiserfahrung
Strukturierung von Test-Methoden
Wir empfehlen dir generell dir zunächst Gedanken darüber zu machen, welche Logik du in deinem Programm überhaupt testen möchtest. Davon hängt die Ordnerstruktur des Tests und der Name ab. Als nächstes denkst du darüber nach, welche Eingaben die Logik bekommen kann. Wichtig ist hier, dass du auch Randfälle bedenkst, zum Beispiel könnten Nutzer auch Anfragen über die Kommandozeile oder Programme wie Postman abschicken, um Fehler zu erzeugen. Auf Frontend-Validierung kannst du dich hier also nicht verlassen.
Ebenfalls solltest du sehr lange Eingaben bedenken. Potentielle Fehler, wie den bekannten Mysql Fehler “Data too long for column ‘column_name’” solltest du am besten auch direkt mit Tests abdecken. Normalerweise schreiben wir für jeden Fall eine Methode in der Testklasse und füllen diese mit Kommentaren im Pseudocode.
Test-Methoden unterteilen wir dann generell in die drei Teile Setup, Logikaufruf und Prüfungen. Im Setup bereiten wir alles nötige vor, meist sind dies Datenbankeinträge, ggf. auch Cache- oder Sessioneinträge. Der Logikaufruf spricht meist einfach einen Endpunkt an. Die Prüfungen kontrollieren mit sogenannten Assertions sowohl das Resultat des Endpunkts, als auch die neuen, geänderten und gelöschten Einträge in Datenbank, Cache oder der Session.
Dataprovider
Um in PHPUnit Randfälle schnell abzudecken, sind Dataproviders besonders hilfreich. Dabei handelt es sich um Methoden, die mehrere Eingaben zurückgeben, die wiederum einzeln in einen geschrieben Test gegeben werden. Im Beispiel hier werden drei Terminarten in die Testmethode gegeben. Der Kanal “Videotermin” hat in AKEYI noch Unteroptionen, wie zum Beispiel “Zoom” oder “MS Teams” und deckt somit direkt einen Randfall ab. Die Testlogik bleibt jedoch die gleiche.
Validierung als Randfall
Oft gibt man sich damit zufrieden, den Optimalfall zu Testen, also zum Beispiel, dass ein Nutzer ein Formular komplett ausfüllt und dieses abschickt. Hier prüft man typischerweise, ob alles nötige in der Datenbank gespeichert wurde und fertig.
In unserer Erfahrung hat sich gezeigt, dass das Testen von Validierung und Fehlermeldungen eine essentielle Zeitersparnis liefert. Darum erstellen wir für jedes komplexere Formular mindestens drei Methoden.
Im ersten Fall gehen wirdavon aus, dass der Nutzer gar keine Daten schickt. Hier prüfen wir im Test, ob jedes erforderliche Feld eine entsprechende Fehlermeldung zurückgibt.
Im zweiten Fall geben wir nur die nötigsten Daten mit und prüfe, ob diese korrekt gespeichert wurden und eine entsprechende Antwort zurückgegeben wurde.
Im dritten Fall geben wir möglichst viele Daten mit und schaue ebenfalls, ob diese korrekt gespeichert wurden.
Die gleichen Fälle empfehlen wir auch beim Testen von API-Endpunkten.
Codestyle in Tests
Tests sind ein perfektes Mittel, um sich in Code einzuarbeiten, den man nicht selbst geschrieben hat. Normalerweise kann man hieraus die Business-Logik direkt ablesen. Oft machen wir an einer Testmethode noch eine Anmerkung, woraus sie sich ergeben hat. Dies ist beispielsweise auf dem Screenshot an der Seite über der Methode testTheChannelCanBeSetByTheRoute zu sehen.
Anders als im Code der Anwendung ist das DRY-Prinzip (don’t repeat yourself) hier eher hinderlich. Ein Test sollte möglichst für sich alleine stehen und man sollte nicht erst durch mehrere Dateien klicken müssen, um diesen zu verstehen. Wir empfehlen in Tests, im Gegensatz zu anderem Code, möglichst nicht zu Generalisieren. Die einzige Ausnahme sind hier Funktionen, die im Setup genutzt werden, um das Grundsystem zu initialisieren, sofern dies immer gleich ist.
Bei Schreiben von Tests sollte man immer im Hinterkopf behalten, dass dieser auch als eine Art Dokumentation genutzt werden kann, um sich in das Thema einzuarbeiten.
Sanity Checks als Stütze
Wir sind dazu übergegangen häufig sogenannte Sanity Checks im Setup-Teil zu nutzen. Dies sind Assertions, die lediglich bestätigen, dass die Vorbedingungen wie geplant eintreten.
In diesem Test wird zum Beispiel überprüft, dass der Status einer Campaign expired bzw. ausgelaufen ist:
Testframework aktualisieren
Es ist essentiell, dein Testframework immer auf einem aktuellen Stand zu halten. Hierdurch werden neue Entwickler*innen eher motiviert, die Tests zu nutzen. Durch moderne Testmethoden wird das Schreiben von Tests generell schneller und effizienter.
Zeitbasierte Logik
In Laravel gibt es die Funktion Carbon::setTestNow(). Diese fakt die aktuelle Zeit und lässt dich die zeitbasierte Logik einfach testen. Ist zum Beispiel der Status einer Entität abhängig von einem Zeitpunkt, so könnte jeweils die aktuelle Zeit, sowie der erwartete Status von einem DataProvider (siehe oben) übergeben werden. Mit der setTestNow-Funktion kann dieser Zeitpunkt dann gesetzt werden und der Status eines Models so für verschiedene Szenarien überprüft werden.
Fazit
Test Driven Development hilft also bei der Orientierung der Entwickler*innen, motiviert, Randfälle eher durch Tests abzudecken und erhöht deine Softwarequalität. TDD kann jederzeit auch in bestehenden Anwendungen einfach ausprobiert werden. Wenn dich die Vorteile interessieren oder du tiefergehende Fragen hast melde dich gerne bei uns, um mehr über TDD zu erfahren.