Heutzutage hat sich das Schreiben von automatisierten Tests weitestgehend durchgesetzt. Die meisten Frameworks bieten deshalb bereits von Hause aus Support an, um eine mit ihnen geschriebene Anwendung zu testen.
Auch Spring Boot bietet uns eine Menge an Konzepten an, um Tests auf allen Ebenen der Testpyramide schreiben zu können. Vom Unit- bis zum Integrationstest finden sich kleine Helferklassen oder Annotationen, die uns eine Menge an Arbeit abnehmen und so dazu beitragen, dass wir uns in Tests auf das Notwendigste konzentrieren können: Aufsetzen von Zustand/Testdaten, Ausführen der zu testenden Teile, Überprüfen des Ergebnisses.
Natürlich kann Spring Boot dabei nicht für jeden Anwendungsfall bereits eine fertige Lösung mitbringen. Bei diesen Fällen sind nun wir gefragt, die vorhandenen Mittel so zu nutzen, dass wir auch diese Anwendungsfälle abdecken können. Wie dies, vor allem in Zusammenarbeit mit JUnit 5, aussehen kann, wollen wir uns im Folgenden an drei Anwendungsfällen aus meinen letzten Projekten anschauen.
Testen von und mit Zeit
Beim Arbeiten mit Zeit gibt es immer Herausforderungen. Es gibt alleine eine Vielzahl an Annahmen, die wir über Zeit haben, die sich leider als falsch herausstellen. Die beiden Posts „Falsehoods programmers believe about time“ und „More falsehoods programmers believe about time; ‚wisdom of the crowd’ edition” alleine liefern hierzu bereits insgesamt über 100 Beispiele.
Unser erster Anwendungsfall ist jedoch weitaus weniger komplex. Wir möchten beim Aufruf der URI /time die aktuelle Zeit, in welcher Zeitzone auch immer, als formatierten Text ausgeben. Die Webanwendung bauen wir mit Spring Boot und der Controller, siehe Listing 1, ist, vorausgesetzt Spring Boot ist bekannt, schnell geschrieben. Starten wir die Anwendung nun und rufen http://localhost:8080/time auf, erhalten wir als Antwort die aktuelle Zeit passend formatiert.
Wir wollen nun nicht nach jeder Änderung erneut manuell testen müssen, dass wir nichts kaputt gemacht haben. Deswegen schreiben wir einen Test mit JUnit 5 und nutzen den von Spring Boot bereitgestellten Web-MVC-Test-Support. Listing 2 zeigt den fertigen Test. Dieser Test schlägt leider fehl, denn selbst bei einem in der Zukunft gewählten Datum als Assertion ist die Wahrscheinlichkeit, den Test genau in diesem Augenblick auszuführen, nur sehr gering.
Was können wir also tun, um doch einen zuverlässig laufenden Test zu erhalten, ohne die Aussagekraft des Tests zu verringern. Wir könnten nicht auf ein konkretes Datum prüfen, sondern nur prüfen, ob das Ergebnis im korrekten Format ausgegeben wird. Dies würde funktionieren, weicht allerdings das Kriterium auf, dass es sich bei dem Datum um die aktuelle Zeit handelt.
Die, meiner Meinung nach, bessere Lösung besteht demnach darin, dafür zu sorgen,
dass wir innerhalb der Anwendung eine fixe Zeit setzen. Glücklicherweise hat das
Java Datetime API genau diesen Anwendungsfall bereits vorgesehen und stellt uns
hierzu die Abstraktion Clock
zur Verfügung. Wir nutzen diese nun innerhalb des
Controllers, siehe Listing 3, um den aktuellen Zeitpunkt zu erhalten.
Nun müssen wir zusätzlich dafür sorgen, dass es innerhalb des Spring-Contexts
eine Bean des Typs Clock
gibt. Listing 4 fügt diese hierzu programmatisch
innerhalb der mit @SpringBootApplication
annotierten Klasse Application
hinzu. Anschließend können wir durch die Verwendung einer @TestConfiguration
innerhalb unseres Tests, siehe Listing 5, die definierte Clock
durch eine, nur
für den Test gültige, Clock
überschreiben, welche ein fixes Datum zurückgibt.
Nach dieser Änderung kann der Test jederzeit erfolgreich ausgeführt werden. Gibt
es allerdings mehrere Tests, die unter Umständen auch noch verschiedene feste
Zeitpunkte benötigen, reicht diese Lösung nicht ganz aus. Zu diesem Zweck
schaffen wir uns die eigene Abstraktion WithLocalDateTime
. Der Test kann nun
wie in Listing 6 gezeigt formuliert werden.
Die eigene Annotation WithLocalDateTime
hat dabei zwei Aufgaben. Zum einen
sorgt sie dafür, dass wie bisher auch die von der Anwendung definierte Bean vom
Typ Clock
mit einer eigenen Implementierung überschrieben wird. Zum anderen
registriert sie eine zusätzliche JUnit5-Erweiterung, die vor jedem Test dafür
sorgt, die Zeit auf das angegebene Datum zu setzen und nach dem Test die
originale Uhr wiederherstellt. Listing 7 zeigt ausgewählte relevante Stellen aus
der Implementierung.
Über @ImportAutoConfiguration(WithLocalDateTime.ClockConfiguration.class)
wird
sichergestellt, dass Spring die Konfiguration findet. Innerhalb der
Konfiguration definieren wir erneut eine Definition des Typs Clock
. In diesem
Fall nutzen wir allerdings keine fixe Uhr, sondern eine eigene Implementierung,
bei der wir die Uhr, die genutzt werden soll, zur Laufzeit austauschen können.
Dazu bietet die Klasse DelegatingClock
einen Getter und Setter für die
zugrunde liegende Clock
an. Alle in Clock
definierten Methoden delegiert
unsere Klasse an die gesetzte Clock
.
Weil wir auch hier die in der Anwendung definierte Clock
überschreiben,
brauchen wir genau wie vorher das Property
spring.main.allow-bean-definition-overriding=true
, welches wir über die
@TestPropertySource
-Annotation spezifizieren. Durch die
@ExtendWith
-Annotation sorgen wir zudem dafür, dass die Klasse
WithLocalDateTimeExtension
als JUnit-Erweiterung registriert wird. Da diese
die beiden Interfaces BeforeEach
- und AfterEachCallback
implementiert,
können wir Code vor und nach jedem Test ausführen. Hierzu gucken wir, ob der
Test wirklich mit der Annotation WithLocalDateTime
versehen wurde, werten
beide Felder der Annotation aus und manipulieren die im Spring-Context
vorhandene Uhr, welche nun vom Typ DelegatingClock
ist.
Die hier gezeigte Lösung funktioniert und hat sich im Projekt als hilfreich erwiesen. Es gibt zudem noch zwei mögliche Erweiterungen. Zum einen wäre es möglich, auch zu erlauben, einzelne Testmethoden zu annotieren, um dort eine andere Zeit zu erhalten. Außerdem wäre es noch praktisch, sich das so gesetzte Datum oder die ausgetauschte Uhr auch als Parameter in die Testmethode reinreichen zu lassen.
Wenn In-Memory-Datenbanken nicht mehr ausreichen
Spring Boot macht es uns einfach, in Tests eine H2, Derby oder HSQL In-Memory-Datenbank zu verwenden. Dies hat den Vorteil, dass die Tests schnell laufen und wenig Setup notwendig ist. Wird allerdings spezifische SQL-Syntax für die in Produktion genutzte Datenbank eingesetzt, ist dieser Weg nicht mehr möglich und ein anderer muss her.
Unsere Webanwendung soll eine zweite URI /person unterstützen. Unter dieser sollen voneinander separiert die Namen von Personen ausgegeben werden. Die Namen sollen in einer PostgreSQL-Datenbank verwaltet werden. Um die Struktur der Datenbank aufzusetzen, nutzen wir Flyway. Die initiale Migrationen ist in Listing 8 zu sehen.
Um die Namen anzuzeigen, nutzen wir direkt innerhalb des Controllers das von
Spring zur Verfügung gestellte JdbcTemplate
und die Namen trennen wir durch
ein Komma voneinander. Listing 9 zeigt die erste Implementierung des Controllers.
Auch hierzu schreiben wir wieder einen Test, siehe Listing 10. Dieser fährt die Anwendung im Gegensatz zum Web-MVC-Test jedoch wirklich hoch und führt einen richtigen HTTP-Request gegen die Anwendung aus. Als Datenbank nutzen wir die In-Memory-Variante von H2.
Die so vorhandene und getestete Lösung soll nun erweitert werden. Obwohl wir es
besser wissen, siehe, wollen wir die Namen nun im Format
Nachname, Vorname
und durch Semikolon getrennt ausgeben. Hierzu soll sich die
Struktur der existierenden Tabelle ändern. Es soll eine Spalte für den Vor- und
eine für den Nachnamen geben. Existierende Personen sollen dabei migriert
werden. Zu diesem Zweck schreiben wir eine zweite, in Listing 11 zu sehende
Migration.
Nachdem wir in PersonController
und PersonControllerTests
die SQL-Ausdrücke
so geändert haben, dass wir die beiden neuen Spalten firstname und lastname
lesen/schreiben, stellen wir fest, dass der Test nicht mehr läuft. Der in
Listing 12 zu sehende Auszug aus dem Stacktrace lässt darauf schließen, dass H2
die Syntax unserer neuen Migration nicht versteht.
Wir müssen nun entweder die Migration so ändern, dass diese auch innerhalb einer H2-Datenbank ausführbar ist, oder wir sorgen dafür, im Test eine richtige PostgreSQL-Datenbank zur Verfügung zu haben.
An dieser Stelle entscheiden wir uns für einen Mittelweg und setzen auf das
Projekt Embedded PostgreSQL. Um dies in den Tests nutzen
zu können, gehen wir erneut den Weg über eine eigene Annotation
WithEmbeddedPostgres
. Diese ersetzt im Test die von Spring Boot
bereitgestellte @AutoConfigureTestDatabase
-Annotation. Um zu verstehen, wie
wir dort eine Instanz der Embedded-PostgreSQL-Datenbank starten, schauen wir uns
die Implementierung dieser Annotation in Listing 13 an.
Die Annotation sorgt dafür, dass eine zusätzliche Konfiguration
EmbeddedPostgresConfiguration
von Spring erkannt wird. Zudem wird
sichergestellt, dass diese Konfiguration vor der eigentlichen
DataSourceAutoConfiguration
ausgeführt wird. Innerhalb der Konfiguration wird
eine Bean des Typs EmbeddedPostgres
definiert und zusätzlich über einen
BeanFactoryPostProcessor
dafür gesorgt, dass die Datenbank relevanten
Properties von Spring Boot durch die zur Embedded-PostgreSQL-Datenbank passenden
ersetzt werden.
Die zweite Konfiguration EmbeddedPostgresDependencyConfiguration
sorgt
zusätzlich dafür, dass die DataSource
-Bean von der EmbeddedPostgres
-Bean
abhängt und somit in jedem Fall erst nach dieser erzeugt wird. Dies ist
notwendig, damit der PostProcessor die Properties setzen kann, bevor eine
DataSource
erzeugt wird.
Migrationen testen
Wie im letzten Abschnitt zu sehen, können auch Migrationen relativ komplex werden. Es wäre demnach super, wenn wir diese auch einzeln testen können. Hierzu müssten wir erst auf den Stand vor der zu testenden Migration migrieren, Daten einfügen, die zu testende Migration ausführen und anschließend die nun migrierten Daten validieren.
Auch dies lässt sich mit ein wenig Code und einer eigenen JUnit-Erweiterung
lösen. Listing 14 zeigt einen Test für die zweite Migration unter Verwendung der
eigenen MigrationTest
-Annotation. Innerhalb der Annotation kann spezifiziert
werden, welche Migrationen getestet werden. Die Erweiterung sorgt anschließend
dafür, dass der Test als Methodenparameter ein MigrationTestTemplate
übergeben
bekommt. Dieses bietet die beiden Methoden beforeMigration
und
afterMigration
an.
Beim Aufruf von beforeMigration
wird die Datenbank auf den Stand von
fromVersion
gesetzt und anschließend der übergebene Lambda-Ausdruck
aufgerufen. In diesem können wir einen initialen Datenbestand aufsetzen. Rufen
wir anschließend afterMigration
auf, werden die zu testenden Migrationen
ausgeführt, indem die Datenbank auf den Stand von toVersion
migriert wird.
Auch hier wird wieder ein Lambda-Ausdruck übergeben, um den Stand nach der
Migration zu testen. Listing 15 zeigt Auszüge aus der Implementierung der
MigrationTest
-Annotation.
Zusätzlich zu den bereits bekannten Interfaces Before
- und AfterEachCallback
wird hier zusätzlich ParameterResolver
implementiert. Über dieses Interface
ist es möglich, den Testmethoden zusätzliche Objekte als Parameter zu übergeben.
In diesem konkreten Fall setzen wir in beforeEach
die gesamte Datenbank auf
einen initialen leeren Zustand zurück. Dies ist notwendig, weil Spring bereits
vorher die Migrationen bis zum aktuellsten Zustand durchgeführt hat. Genau aus
diesem Grunde stellen wir in afterEach
auch wieder diesen Zustand her. Hierzu
löschen wir erneut alle Datenbankobjekte und führen eine Migration auf den
aktuellsten Zustand durch.
Mittels resolveParameter
erzeugen wir zudem für jede Testmethode eine neue
Instanz von MigrationTestTemplate
, damit der Test dieses nutzen kann, um die
Migrationen zu testen.
Fazit
Nicht alle Anforderungen, die beim Testen von mit Spring Boot umgesetzten Webanwendungen entstehen, sind von Hause aus gelöst. Spring Boot gibt uns jedoch bereits eine Menge von Bestandteilen an die Hand, um auch diese Probleme zu lösen. In Verbindung mit eigenen JUnit5-Erweiterungen entstehen hierbei Abstraktionen, die gut wiederverwendbar sind und in den Tests klar verständlich ausdrücken, was erwartet wird.
Um dies zu veranschaulichen, haben wir die drei konkreten Anwendungsfälle „Testen von Zeit“, „Testen mit eingebetteter PostgreSQL-Datenbank“ und „Testen von Flyway-Migrationen“ betrachtet und jeweils eine mögliche Lösung gesehen, wie dies konkret umsetzbar ist.
Natürlich gibt es noch viele weitere Anwendungsfälle, die sich mit dieser Kombination lösen lassen. Mir fällt zum Beispiel noch das Aufräumen der Datenbank nach einem Test, das Starten anderer Systeme oder das Aufzeichnen von geloggten Nachrichten ein. Ich bin mir aber sicher, dass es noch viele weitere Möglichkeiten gibt.
Der vollständige Quellcode zu allen Listings kann auf GitHub angesehen oder heruntergeladen werden. Ich freue mich zudem über sämtliche Fragen oder Anmerkungen.