This article is also available in English
In vielen Projekten und Codebasen, die ich in den vergangenen Jahren einsehen durfte, war die zu lange Laufzeit von Tests ein Thema. Auf der einen Seite wollen wir eine akzeptable Testabdeckung erreichen, wie hoch das auch immer ist, auf der anderen Seite wollen wir auf die Ausführung nicht zu lange warten. Ein klassisches Spannungsfeld.
Gerade in Spring Boot basierten Anwendungen habe ich dabei die Erfahrung gemacht, dass zu viele Tests existieren, die einen Anwendungskontext erzeugen. Allein das Hochfahren dieser zahlreichen Anwendungskontexte kann schon zu langen Testlaufzeiten führen. Vor allem, wenn hierbei auch noch Datenbanken hochgefahren oder migriert werden.
Deswegen werden wir uns im Folgenden mehrere Möglichkeiten anschauen, wie wir in Spring Boot-Anwendungen Tests schreiben können, und dabei für jede Möglichkeit betrachten, wie dies die Laufzeit unserer Tests beeinflusst.
Unittests ohne Anwendungskontext
Die erste Methode besteht darin, reine Unittests, ohne Spring-Anwendungskontext, zu schreiben. Hierbei entfällt der Overhead des Anwendungskontextes und die Tests sind sehr schnell. Diese Methode ist vor allen bei Komponenten möglich und sinnvoll, bei denen wir die pure Logik testen wollen und die nicht durch Aspekte, wie beispielsweise Transaktionen, erweitert werden.
Da Spring Beans in der Regel bereits den Prinzipien Inversion of Control und Dependency Injection folgen, ist das Schreiben von reinen Unittests mit wenig Aufwand verbunden. Wir können die zu testende Klasse über deren Konstruktor erzeugen und Abhängigkeiten als Parameter oder über Setter injizieren.
Soll für eine Abhängigkeit im Test nicht die reale Implementierung verwendet
werden, können wir diese durch Mocks oder Stubs ersetzen.
Dies ist immer dann sinnvoll, wenn die reale Implementierung zu kompliziert zu
erzeugen ist, für den Test ungewünschte Seiteneffekte hat oder ihn verlangsamt,
weil beispielsweise eine externe Verbindung aufgebaut wird. Listing 1 zeigt, wie
solch ein reiner Unittest für eine Spring Bean Greeter
aussehen kann.
Da diese Tests einfach und schnell sind, lohnt es sich, meiner Meinung nach, diese immer als erste Wahl zu betrachten. Da dies voraussetzt, dass ohne Anwendungskontext sinnvoll getestet werden kann, hat dies auch Einfluss auf den Produktionscode. Ich vermeide es deswegen zum Beispiel, komplexere Logik in Controllern zu implementieren. Diese validieren bei mir die übergebenen Parameter, wandeln bei Bedarf noch Typen um und rufen anschließend eine fachliche Klasse auf. Dieses Vorgehen ist unabhängig von den Tests auch aufgrund von Separation of Concerns sinnvoll. Listing 2 zeigt einen solchen Controller.
Damit der Controller simpel bleibt, ist die Funktionalität, dass der übergebene
Name von Whitespace am Anfang und Ende bereinigt wird, siehe Listing 3, auch
bewusst in der fachlichen Klasse Greeter
implementiert und nicht Bestandteil
des Controllers.
Auch wenn solche reinen Unittests meine erste Wahl sind, gibt es Funktionalität, die ich testen möchten, welche einen Anwendungskontext benötigt. Wie wir diese testen können, wollen wir uns nun anschauen.
Tests mit Anwendungskontext
In unserer Anwendung sollen bei jedem Aufruf der greet
-Methode der Wert des
übergebenen Parameters und die ermittelte Begrüßung auf die Standardausgabe
geschrieben werden. Diese Anforderung wurde mit einem Aspekt
GreeterLoggingAspect
umgesetzt.
Da dieser Aspekt von Spring um unsere Klasse gewebt wird, benötigen wir, um diese Funktionalität zu testen, einen Test mit Anwendungskontext. Listing 4 zeigt, wie ein solcher Test mit den Hilfsmitteln aus dem Spring Framework geschrieben werden kann.
Offensichtlich reicht eine einzelne Annotation, @SpringJunitConfig
, um einen
Anwendungskontext zu erzeugen und diesen im Test zur Verfügung zu haben. Dies
gelingt dadurch, dass diese Annotation wiederum mit zwei weiteren Annotationen
versehen ist. Die Annotation @ExtendWith(SpringExtension.class)
sorgt dafür,
dass eine Spring spezifische JUnit 5-Erweiterung aktiviert
wird. Diese hat dadurch Zugriff auf den gesamten Lebenszyklus der Tests. Somit
kann die Erweiterung Dinge vor und nach der Testausführung erledigen und auch
Felder oder Methodenparameter, welche in den Tests genutzt werden können, zur
Verfügung stellen. Die zweite Annotation @ContextConfiguration
erlaubt es zu
spezifizieren, welche Konfigurationen, Beans und weitere Komponenten mit in den
Anwendungskontext des Tests aufgenommen werden sollen.
Während der Ausführung eines so annotierten Tests passiert nun Folgendes. Das
Testframework, in unserem Fall JUnit 5, findet und startet den Test. Die
aktivierte Spring-Erweiterung wird somit an den passenden Stellen im
Lebenszyklus des Tests aufgerufen. Diese Erweiterung erzeugt nun für jede
Testklasse einen TestContextManager
. Dieser wiederum ist dafür verantwortlich,
einen TestContext
zu erzeugen und während der Testausführung zu aktualisieren.
Dazu werden TestExecutionListener
an bestimmten Punkten im Test,
beispielsweise vor und nach jeder Testmethode, von diesem benachrichtigt. Diese
Listener sind dabei unabhängig vom spezifischen Testframework und verrichten
spezifische Arbeit, wie beispielsweise das Injizieren von Abhängigkeiten aus dem
Anwendungskontext oder das Zurückrollen einer Transaktion.
Um einen TestContext
zu erzeugen, nutzt der TestContextManager
einen
TestContextBootstrapper
. Dieser ist zum einen dafür verantwortlich, die
benötigten TestExecutionListener
zu finden und zu registrieren. Außerdem
erzeugt dieser auch, mithilfe von einem ContextLoader
, den Anwendungskontext
für den Test. Da das Erzeugen eines Anwendungskontextes teuer ist, wird dieser
zusätzlich in einen Cache gepackt, um dann für andere Tests, welche einen
identischen Anwendungskontext benötigen, wiederverwendet zu werden. In der
Dokumentation kann dabei nachgelesen werden, wie der Cache-Key
berechnet wird und auch wie von außen Einfluss auf den Cache genommen werden
kann.
Da die @ContextConfiguration
innerhalb der @SpringJunitConfig
nicht, durch
Parameter, konfiguriert wurde und auch an unserem Test keine weitere Annotation
vorhanden ist, läuft unser Test mit der Standardkonfiguration. In dieser wird
unsere statische innere Klasse gefunden und, da diese indirekt über
@TestConfiguration
mit @Configuration
annotiert ist, in den
Anwendungskontext aufgenommen. Dadurch dass diese, mittels
@EnableAspectJAutoProxy
, den Spring Support für Aspekte aktiviert und über
@Import
und @Bean
die drei für uns relevanten Beans spezifiziert, kann der
Test ausgeführt werden. Die Testmethode aus Listing 5 zeigt dabei, dass diese
Beans wirklich über Spring geladen wurden und dass dabei nicht auf die in Spring
Boot vorhandene Autokonfiguration für Aspekte zurückgegriffen wurde.
Die meisten Spring-Anwendungen basieren heute jedoch auf Spring Boot und verlassen sich auf die dort definierten Konventionen und Autokonfigurationen. Deswegen gibt es dort auch eine erweiterte Unterstützung für Tests, die wir uns im Folgenden anschauen wollen.
Spring Boot-Tests
Der erste Weg und die erste Annotation, die bei einer Suche nach Tests mit
Spring Boot auftaucht, ist meistens @SpringBootTest
. Im Prinzip ersetzt diese
die vorherige @SpringJunitConfig
. Es wird jedoch ein anderer, Spring Boot
spezifischer, TestContextBootstrapper
verwendet. Dieser kennt die Spring
Boot-Konventionen und das Konzept von Autokonfiguration. Deswegen lädt ein so
annotierter Test, siehe Listing 6, auch den gesamten, bis auf wenige Ausnahmen,
Anwendungskontext, so wie das auch bei einem Start der Anwendung passieren
würde. In diesem Fall schlägt dieser Test auch genau deswegen fehl. Dadurch dass
der gesamte Anwendungskontext geladen wird, wird auch der Pool für
Datenbankverbindungen initialisiert und versucht, eine Verbindung zur, nicht
laufenden, Datenbank aufzubauen.
Wir umgehen hier das Problem durch das Hinzufügen einer weiteren Annotation,
nämlich @AutoConfigureTestDatabase
. Diese sorgt dafür, dass beim Erzeugen des
Anwendungskontextes nicht die konfigurierte Verbindung genutzt wird, sondern
ersetzt diese durch eine Verbindung zu einer im Speicher laufender Datenbank.
Dadurch können wir auch zusätzlich auf das Mocken des GreetingTextRepository
verzichten. Somit haben wir in Listing 7 einen integrativen Test, welcher das
richtige Repository nutzt und dadurch einen wirklichen Aufruf auf der Datenbank
durchführt.
Der Test zeigt jedoch auch, dass Komponenten, hier der Controller aus Listing 2, geladen werden, die wir im Test überhaupt nicht benötigen. Gerade in größeren, realen Anwendungen mit vielen Komponenten und Tests sorgt das in Summe dafür, dass die Laufzeit der gesamten Testsuite wächst. Vor allem das Initialisieren von Datenbanken, inklusive dem Ausführen von Migrationen, hat hieran einen deutlichen Anteil.
Um trotzdem schnelle Tests mit Anwendungskontext schreiben zu können, betrachten wir als Nächstes die von Spring Boot bereitgestellten Test Slices.
Spring Boot Test Slices
Wir wollen nun auch noch einen Test für den Controller aus Listing 2 schreiben.
Da dieser, wie bereits gesagt, nur validiert und eine Typumwandlung durchführt
und dann an den Greeter
übergibt, brauchen wir hier keinen kompletten
Integrationstest. Uns reicht es, den Greeter
zu mocken und zu verifizieren,
dass der korrekte Wert übergeben wird.
Theoretisch könnten wir das auch mit einem reinen Unittest erreichen. Dort würden wir den Controller selbst mittels Konstruktor erzeugen und die Methode aufrufen. Dabei würden wir jedoch die durch Spring Web MVC hinzugefügten Aspekte, wie das Request-Routing oder die Konvertierung von und in JSON, nicht mehr testen.
Deshalb stellt uns Spring Boot, mit @WebMvcTest
, einen fertigen Test Slice zur
Verfügung, der nur Komponenten in den Anwendungskontext aufnimmt, die für unsere
Web-Schicht von Bedeutung sind. Das sind unter anderem Controller, Filter und
Advices. Der in Listing 8 zu sehende Test kommt somit mit einem deutlich
kleineren Anwendungskontext aus und ist auch schneller als ein äquivalenter Test
mit @SpringBootTest
.
Auch für das auf dem JdbcTemplate
basierende GreetingTextRepository
, siehe
Listing 9, gibt es mit @JdbcTest
einen fertigen Test Slice. Dieser stellt
standardmäßig eine Datenbank im Speicher zur Verfügung, führt die notwendigen
Datenbankmigrationen aus und unterstützt Transaktionen. Somit ist der Test,
siehe Listing 10, für unser Repository auch schnell geschrieben.
Neben diesen beiden Test Slices bringt Spring Boot noch weitere mit. Eine komplette Übersicht über diese bietet die Dokumentation. Diese erklärt auch für jeden Slice, welche Komponenten standardmäßig inkludiert und welche Autokonfigurationen geladen werden. Sollte einmal kein fertiger Test Slice vorhanden sein, ist es auch möglich, einen eigenen zu schreiben. Dies wollen wir uns anhand unseres Repositories nun noch anschauen.
Eigenen Test Slice erstellen
Der bisherige Test für das GreetingTextRepository
testet dieses, wie bereits
gesagt, in Verbindung mit einer Datenbank im Speicher und nicht in Kombination
mit einer richtigen PostgreSQL. Dafür wollen wir noch einen weiteren Test,
siehe Listing 11, schreiben.
Hierzu haben wir eine eigene Annotation @PostgresRepositoryTest
, siehe Listing
12, definiert. Diese Annotation ist wiederum mit vielen weiteren Annotationen
annotiert, welche dafür sorgen, dass in Kombination alles funktioniert.
@ExtendWith(SpringExtension. class)
haben wir bereits kennengelernt, es sorgt
dafür, dass die JUnit 5 Spring-Erweiterung aktiviert wird.
Durch @BootstrapWith(SpringBootTestContextBootstrapper.class)
ersetzen wir den
standardmäßigen TestContextBootstrapper
durch einen Spring Boot spezifischen.
Dieser verwendet unter anderem den im Folgenden mit
@TypeExcludeFilters(PostgresRepositoryTypeExcludeFilter.class)
konfigurierten
Filter. Dieser Filter, siehe Listing 13, sorgt dafür, dass standardmäßig alle
mit @Repository
annotierten Komponenten in den Anwendungskontext aufgenommen
werden, es sei denn, der Parameter repositories
unserer Annotation wurde
gesetzt, dann werden nur die dort angegebenen Komponenten aufgenommen.
Mittels @ImportAutoConfiguration
und
@OverrideAutoConfiguration(enabled = false)
sorgen wir noch dafür, dass die in
der Datei
META-INF/spring/de.mvitz.spring.test.slices.PostgresRepositoryTests.imports,
siehe Listing 14, definierten Autokonfigurationen geladen werden. Zuletzt fügen
wir über @ContextConfiguration
noch einen eigenen
ApplicationContextInitializer
, siehe Listing 15, zum Anwendungskontext hinzu
und sorgen mit @Transactional
dafür, dass jede Testmethode automatisch eine
Transaktion erzeugt, die am Ende zurückgerollt wird.
Die eigentliche Logik findet dabei in unserem Initializer statt. Dieser startet, mittels Testcontainers, einen Container mit einer PostgreSQL-Datenbank und sorgt dafür, dass dieser auch wieder gestoppt wird. Außerdem setzt er die Spring Boot relevanten Properties für die Datenbankverbindung. Da der Initializer am Anfang der Erzeugung eines Anwendungskontextes ausgeführt wird, geschieht das alles, bevor die Komponenten erzeugt werden, die diese Properties auslesen. Für diesen konkreten Fall, Repository-Test gegen eine Testcontainer-Datenbank, gibt es mit Sicherheit einfachere Lösungen als einen eigenen Test Slice. Dieser Slice ist deswegen eher ein, hoffentlich, gut nachvollziehbares Beispiel, als ein fehlender Slice.