Nichts für Deserteure – die 5 besten Tricks für das Test-Driven-Development mit Spring Boot

Durch die Strukturen des Spring Frameworks und eine konsequente Strategie beim Aufbau des Ökosystems eignet sich Spring Boot optimal für Test-Driven-Development (TDD) als Vorgehen bei der Softwareentwicklung. Im folgenden Artikel zeige ich euch 5 Best Practices für einen reibungslosen Start!
Wenn ich an meine ersten Schritte in der professionellen Softwareentwicklung zurückdenke, war der Arbeitsalltag von viel Wartezeit geprägt. Bei der Implementierung neuer Features wurde gecodet, kompiliert, die ausführbare Datei irgendwo hin kopiert, gestartet, angemeldet und dann „von Hand“ getestet. Noch schlimmer war das beim Finden der Quelle von Bugs. Heute ist das glücklicherweise undenkbar…
In Anbetracht von schnellen Lieferzyklen (tägliche Produktivdeployments anstatt einem Release pro Quartal) ist es gar nicht mehr möglich, komplexe Systeme mit manuellen Tests ausreichend abzusichern. Aufmerksamen LeserInnen wird nicht entgangen sein, dass sich auch in diesem Blog immer wieder viel um Teststrategien im Kontext von Spring Boot dreht.
Anlass genug, um ein paar Best Practices vorzustellen. Aus diesem Grund habe ich euch heute meine besten fünf Tipps und Tricks rund um das Testen mit Spring Boot vorbereitet. Denn heutzutage ohne automatisierte Tests zu arbeiten, grenzt eigentlich an Fahnenflucht...
Tipp 1: Integriere den Maven Wrapper in deine Projekte
Die Erstellung eines neuen Spring Boot Projekts ist mittlerweile wirklich eine Sache weniger Klicks: Auf https://start.spring.io/ stellt man sich komfortabel das Projekt und die wesentlichen Abhängigkeiten zusammen und lädt das Projektgerüst herunter. Ich nutze als Build- und Dependency-Management-Tool zumeist Maven, habe aber auch mit gradle gute Erfahrungen gemacht. Für beide Tools liefert Spring Boot einen Wrapper mit, der eine lokale Installation unnötig macht. Eine etwas ausführlichere Anleitung der ersten Schritte findet sich im Artikel „Spring Boot für Einsteiger“.
Warum ich das als Tipp im Kontext automatisierter Tests aufführe? Blicken wir noch einmal 15 Jahre in die Vergangenheit: Konzepte und Bibliotheken für Unit-Tests gab es damals schon. Ganz ehrlich gesagt hat sich sogar an den Grundkonzepten relativ wenig geändert. Gescheitert ist das regelmäßige Testen damals jedoch oft daran, dass der lokale Testlauf kompliziert einzurichten war. Daher wurden die Tests unregelmäßig ausgeführt und funktionierten irgendwann gar nicht mehr.
Die Wrapper entschärfen dieses Problem wesentlich: Ich brauche mittlerweile auf meiner Entwicklungsmaschine nur noch ein Java Development Kit (JDK) in der passenden Version installiert. Nach dem Herunterladen kann ich das Projekt dann mittels Wrapper sofort starten – und eben auch die Tests ausführen!
Auf Linux und Mac geht das so:
./mvnw clean test
Und für in der Windows-Eingabeaufforderung:
mvnw.cmd clean test
Weil diese Hürde so gering ist, werden die Tests potenziell ständig ausgeführt. Nach jedem Checkout, vor dem Beginn eines Bugfixes, vor dem Push, etc…
Tipp 2: Nutze den Maven target Ordner
Wo wir bereits beim Thema Maven sind: Ich weiß nicht, warum, aber ich lese viel zu wenig über den „target“ Ordner. Maven nutzt diesen Ordner, um build Dateien abzulegen, beispielsweise kompilierte Java Dateien. Oder auch gesamte gebaute Artefakte. Um immer wieder einen sauberen Stand herstellen zu können, dient das Maven Goal clean, das wir oben bereits gesehen haben. Damit wird der gesamte target Ordner einmal geleert.
Aber wie können wir dies für unsere automatisierten Tests nutzen? Von Zeit zu Zeit kommen wir nicht umhin, auch Daten im Dateisystem abzulegen oder sie von dort aus zu lesen. Wer jetzt bereits innerlich zusammenzuckt, weil das Projekt sowohl auf Windows, wie auch auf Linux entwickelt wird – dranbleiben, die Lösung kommt gleich!
Es hat sich in der Praxis bewährt, Tests, die das lokale Filesystem benötigen auf den target Ordner zugreifen zu lassen und von dort aus mit relativen Pfaden zu arbeiten. Welche Vorteile das mit sich bringt? Zum einen ganz banal den oben bereits angekündigten Cleanup: Durch ein einfaches clean ist der Ordner wieder leer. Das macht die Arbeit deutlich leichter, wenn mal was schief geht und man nicht von Hand aufräumen muss.
Ein weiterer, nicht ganz so offensichtlicher Vorteil: Glücklicherweise kann auf den Entwicklungsmaschinen nicht jede Applikation einfach irgendwohin schreiben. Das heißt, dass es Sinn macht, dem Test einen Ort zu garantieren, an dem die Applikation Schreibrechte auf das Filesystem besitzt. Das ist mit dem target Ordner gegeben, sonst würde die Kompilierung gar nicht funktionieren ;-) Nicht zuletzt ist die Verwendung des target Ordners natürlich auch unabhängig vom Betriebssystem.
Tipp 3: Mache dich mit dem Spring Konfigurationsmanagement vertraut
Spring Boot hat die Prinzipien „Inversion of Control“ und „Convention over Configuration“ stark verinnerlicht und wirklich bis in die letzte Ecke ausgereizt. Herausgekommen ist dabei unter anderem ein wirklich durchdachtes Konfigurationskonzept. Einfach gesagt fordere ich aus meiner Applikation einen Konfigurationsparameter per Name an und Spring Boot garantiert mir, dass der Wert zur Laufzeit zur Verfügung steht. Woher dieser kommt, interessiert mich nicht, das Framework kümmert sich darum.
Wie das funktioniert? Jeder Parameter kann über bis zu 17 Stufen überschrieben werden. Während die unteren Stufen Konfigurationen innerhalb des Projekts sind, besteht als letzte Stufe die Möglichkeit per Umgebungsvariable auf der ausführenden Maschine alles „weg zu bügeln“. In Kombination mit Spring Profilen gibt es für eigentlich jede Anforderung an das Konfigurationsmanagement eine halbwegs elegante Möglichkeit zur Umsetzung.
Was das mit den Tests zu tun hat? Zum einen entfallen wirklich sämtliche Abfragen im Code, in denen ein „Schalter“ umgelegt wird, zum Beispiel um URLs für verschiedene Umgebungen zu verwalten. Solche Konstrukte sind sehr fehleranfällig. Und zwar insbesondere, falls vor dem Livedeployment manuell Anpassungen durchgeführt werden müssen (an die jüngeren LeserInnen: Ja, so haben wir das früher gemacht!).
Der andere Aspekt geht in Richtung Security: Ich kann ein komplett gebautes und getestetes Projekt haben, das beispielsweise das Passwort der Produktivdatenbank gar nicht kennt. Dieses wird erst auf der Produktivumgebung von außen zur Verfügung gestellt. Alles ohne explizite Codeänderung! Das Passwort macht nicht die Runde und vor allem werden nicht aus Versehen mal alle Unit-Tests gegen die Live-Datenbank ausgeführt ;-) Hat es auch alles schon gegeben...
Tipp 4: Teste deine Applikation als Blackbox
Softwaresysteme stehen heute in den seltensten Fällen alleine auf der grünen Wiese. Sie rufen sich gegenseitig auf, fragen Daten an oder schreiben gegen die APIs anderer Services. Schon das banale Konstrukt einer React Single Page Application mit einem Spring Boot Backend hat diese Struktur.
Um wirklich unabhängig zu bleiben ist es im oben genannten Beispiel wichtig, dass sich das Backend gegenüber dem Frontend konsistent verhält und seine Schnittstellen optimalerweise abwärtskompatibel belässt. Die lässt sich relativ aufwändig mit einem Integrationstest sicherstellen, aber Spring Boot bietet noch eine einfacherer Lösung. Während für einen Integrationstest das gesamte Produkt installiert verfügbar sein muss, lässt sich eine Spring Boot Applikation auch für einen Unit-Test komplett hochfahren und wie von außen aufrufen.
Nachdem wir hier bisher relativ viel Theorie beleuchtet haben, an dieser Stelle ein Codebeispiel. Nehmen wir einen rudimentären REST Endpunkt, der eine Liste von Todos (bzw. Tasks) zurückgibt. Dieser hat die folgende Struktur:
@GetMapping(“tasks“)
public ResponseEntity<List<Task>> getTasks() {
...
}
Würden wir das Projekt jetzt bauen und lokal starten, wäre der Endpunkt unter http://localhost:8080/tasks verfügbar. Aber wir wollen eine Ebene früher angreifen. Dazu definieren wir im Projekt eine Testklasse:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class TasksControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testGetTasks() throws Exception {
mockMvc.perform(get(“/tasks“))
.andExpect(status().isOk())
// further checks here
;
}
}
Der Zauber liegt hier in der Komponente MockMvc. Auch wenn der Name vielleicht etwas verwirrend ist, ist das Tool sehr praktisch! Innerhalb eines entsprechend annotierten Tests feuert MockMvc „richtige“ Http Calls gegen den im Hintergrund hochgefahrenen Container ab. Während der gezeigte Test noch sehr rudimentär ist (es wird nur getestet, dass der Status 200 OK geliefert wird) bestehen diverse weitere Möglichkeiten, die Antwort zu prüfen.
Zum Beispiel kann ein beliebig komplexes JSON Objekt mithilfe von JSONPath traversiert und geprüft werden. Das ist sehr elegant, da die Prüfung nur auf der Struktur stattfindet. Das Objekt muss nie deserialisiert werden, ich benötige also keine Java-Klasse zur Repräsentation. Damit ist wirklich eine maximale Unabhängigkeit der Tests von der Implementierung gewährleistet. Im Optimalfall kann wirklich der komplette Implementierungscode ausgetauscht werden, ohne dass eine Zeile Testcode angefasst werden muss und anders herum.
Eine konsequente Umsetzung dieses Ansatzes ermöglicht es, die Applikation quasi pausenlos kompilierbar halten zu können – und natürlich am laufenden Band die Tests auszuführen ;-) Was denn sonst...
Tipp 5: Differenziere zwischen Integrationstests und integrierten Tests
Hand aufs Herz: Wer hat beim Lesen kurz gestockt? Ich gebe zu, dass die Begrifflichkeiten auf den ersten Blick kaum zu unterscheiden sind. Aber es liegen Welten dazwischen… Integrationstests sind ein sinnvolles Werkzeug in der Testautomatisierung. Sie prüfen, ob sich eine Applikation in ein reales Umfeld integrieren lässt, also korrekt mit externen Schnittstellen interagiert. Das können andere Services oder zum Beispiel auch eine Datenbank sein. Wie weiter oben bereits geschrieben sind diese Tests ggf. sehr aufwändig in der Umsetzung, aber dadurch nicht minder sinnvoll.
Aber was sind jetzt integrierte Tests? Ich gebe zu, dass der Begriff nicht von mir stammt… Für einen tieferen Einblick empfehle ich euch diesen Blogpost, hier ist auch ein Video eines Talks verlinkt: https://blog.thecodewhisperer.com/permalink/integrated-tests-are-a-scam
Nähern wir uns dem Begriff mit der Frage an, wie man integrierte Tests erkennt. Demnach liegt ein integrierter Test vor, wenn der Erfolg eines Tests von einer externen Komponente abhängt. Einfaches Beispiel: Meine Applikation nutzt einen SSO Provider, zum Beispiel Keycloak. Grundsätzlich kann ich dort einen User anlegen, den ich von meinen Unit-Tests aus verwendet. Aber alle meine Tests werden fehlschlagen, falls mein Keycloak down ist. Die Tests gelten also als integriert. Und es muss noch nicht mal Keycloak down sein, es genügt, dass ich im ICE sitze und durch einen Tunnel fahre. Drei von fünf Aufrufen könnten in einen Timeout laufen, mein Testergebnis wäre nicht zu gebrauchen.
Ziel sollte sein, möglich überhaupt keine integrierten Tests zu haben. Hierfür bieten sich verschiedene Ansätze an, die jeweils auch wieder einen eigenen Post rechtfertigen würden ;-) Zum einen kann durch das geschickte überschreiben von Spring Beans erreicht werden, dass bestimmte Anfragen innerhalb von Tests die Applikation gar nicht verlassen. Nachteil ist, dass die externe Schnittstelle halb selbst innerhalb der eigenen Applikation nachgebaut werden muss.
Etwas eleganter geht das dann mit Tools wie Mockito. Diese verwenden die bestehenden Spring Beans, sie „überschreiben“ aber bestimmte Aufrufe. Grob gesagt: Ich konfiguriere Mockito so, dass beim Aufruf der Methode authenticate mit der User-ID 12345 nicht die externe Schnittstelle aufgerufen wird, sondern ein festgelegter User geliefert wird. Natürlich muss auch hier ein klein wenig Struktur in der eigenen Applikation nachgebaut werden. Optimalerweise gibt es dann noch einen Integrationstest, der diesen „Nachbau“ auch noch prüft, denn das Konstrukt ist natürlich nur so lange gut, wie die externe Abhängigkeit sich genauso verhält wie der eigene Nachbau ;-)
Noch einen Schritt weiter außen setzt das Tool Wiremock an: Hier wird eine Http Schnittstelle überschrieben. Analog zu Mockito wird für URLs und Payloads definiert, welche Antworten geschickt werden sollen. Auch hier ergibt sich etwas Struktur in der Applikation, aber der Nachbau ist noch schöner separiert.
Ran an die Keyboards!
Soweit meine fünf Tipps für heute! Beim Schreiben ist mir noch einmal aufgefallen, wie viel sich dadurch erleichtern lässt und wie viel Sicherheit sich durch die konsequente Verwendung automatisierter Tests gewinnen lässt! Ich wünsche viel Spaß beim Coden, es gibt eigentlich keine Ausreden mehr, um sich vor den Tests zu drücken ;-) Und keine Angst, Testgetriebene Entwicklung mit Spring Boot macht wirklich Spaß!

Danke für's lesen.
Julius Mischok ist Geschäftsführer der Mischok GmbH in Augsburg. Seine Kernaufgaben sind Prozessentwicklung, sowie Coaching und Schulung der Entwicklungsteams. Aktuell fokussiert sich seine Arbeit auf die Frage, wie Software schnell und mit einer maximalen Wertschöpfung produziert werden kann. Julius hat Mathematik studiert und entwickelt seit fast zwei Jahrzehnten Java. Seine Erfahrung brachte er unter anderem in Softwareprojekten für BMW, Audi, Hilti, Porsche, Allianz, Bosch, und viele mehr ein.