Das primäre Mittel zur Qualitätssicherung in der Softwareentwicklung sind Tests. Diese sollen sicherstellen, dass eine Anwendung die von ihr geforderten Anwendungsfälle korrekt umsetzt. Wie die gesamte Softwareentwicklung hat sich natürlich auch das Testing weiterentwickelt. Wurde vor Jahrzehnten primär händisch, und damit manuell, getestet, hat mittlerweile auch hier die Automatisierung Einzug gehalten.
Neben klassischen Unittests, die sich dank Techniken wie dem Mocking einfach implementieren und automatisiert ausführen lassen, werden dabei auch Tests benötigt, die externe Ressourcen wie beispielsweise Datenbanken benötigen oder die die Anwendung per Browser testen. Diese Tests werden in der Regel als Integrations- oder End-To-End-Tests bezeichnet und befinden sich in den oberen Schichten der Testpyramide.
Im Folgenden zeigen wir, wie solche Tests, mithilfe von Containern, implementiert werden können.
Testen einer Klasse mit Datenbankzugriff
Wir wollen die Klasse AuthorRepository
testen (s. Listing 1). Diese nutzt
JDBC, um mit SQL-Befehlen einen Autor (Author
) zu speichern oder eine Liste
aller vorhandenen Autoren zurückzuliefern.
Die Tests sollen dabei prüfen, dass ein Autor nach dem Speichern eine ID enthält und dass er anschließend in der Liste aller Autoren auftaucht. Diese Tests lassen sich mit JUnit einfach ausdrücken (s. Listing 2).
Als Problem bleiben somit nun nur noch das Herstellen einer Verbindung zu einer
Datenbank und das Herstellen eines initialen Zustands für die Tests. Um einen
initialen Zustand für jeden Test zu erzeugen, haben wir uns dazu entschieden,
vor jedem Test die Tabelle authors
neu anzulegen (s. Listing 3). Dies stellt
sicher, dass wir immer mit einem komplett leeren Datenbestand starten und uns
keine von anderen oder alten Tests erzeugten Daten in die Quere kommen können.
Somit bleibt als letztes Problem, eine Verbindung zur Datenbank herzustellen. Eine Möglichkeit hierfür ist, auf eine In-Memory-Datenbank, wie beispielsweise H2, zurückzugreifen. Diese könnten wir vor jedem Test starten und uns anschließend verbinden. Der Einsatz einer In-Memory-Datenbank bringt jedoch einen Nachteil mit sich. Wir verwenden jetzt ein anderes Produkt als in Produktion. Selbst wenn beide nach außen hin komplett gleich aussehen, kann nicht garantiert werden, dass sich beide auch gleich verhalten. Wir riskieren damit, dass wir gewisse Fehler erst später finden.
Aus diesem Grund wollen wir gegen eine richtige
PostgreSQL-Datenbank testen. Dank Docker müssen wir uns nicht
lange mit einer lokalen Installation beschäftigen, sondern starten eine Instanz
mit dem Befehl docker run --name postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres
.
Anschließend können wir die Methode create
in der Testklasse ergänzen, die
eine Verbindung herstellt (s. Listing 4).
Im Grunde sind wir nun fertig, solange wir sicherstellen, dass jeder dafür sorgt, dass eine PostgreSQL-Datenbank während der Ausführung der Tests zur Verfügung steht. Aber wäre es nicht toll, wenn wir auch diesen Teil noch automatisieren könnten? Anschließend könnten die Tests laufen, ohne dass man vorher manuell einen Docker-Container starten muss. Genau dieses Problems hat sich das Testcontainers-Projekt angenommen.
Testcontainers
Wir wollen nun also unseren Test so umbauen, dass dieser Testcontainers nutzt,
um vor der Ausführung selbstständig den PostgreSQL-Container zu starten und
diesen danach auch wieder zu stoppen. Hierzu stellt Testcontainers die
JUnit-Rule (s. Kasten „JUnit-Rules“) GenericContainer
bereit. Diese lässt uns
beliebige Container vor der kompletten Testklasse oder vor jedem Test starten.
Wir fügen diese also in unserem Test hinzu und ändern die Logik der
create
-Methode, um anschließend auch diesen Container zu verwenden
(s. Listing 5).
Führen wir die Testklasse nun aus, kommt es immer wieder vor, dass einer, oder
beide, Tests fehlschlagen. Dabei wirft der Test eine ConnectException
. Hierbei
handelt es sich um ein übliches „Problem“ bei der Arbeit mit Containern.
JUnit-Rules
Mit Rules bietet JUnit in Version 4 die Möglichkeit, Aspekte zu implementieren, die vor und nach jedem Test oder der Testklasse Dinge ausführen.
Der Anbieter einer solchen Rule implementiert dazu lediglich das Interface
TestRule
mit der Methode apply
. Im Test wird diese Rule anschließend als
Instanzvariable definiert und mit der Annotation Rule
versehen, falls diese
vor jedem Test ausgeführt werden soll. Reicht es, die Rule vor und nach der
gesamten Testklasse auszuführen, definiert man sie als statische Variable und
nutzt die Annotation ClassRule
.
Container != Anwendung
Bei der normalen Interaktion mit Containern wird oft unterschlagen, dass ein gestarteter Container nicht mit einer gestarteten Anwendung innerhalb des Containers gleichzusetzen ist. In unserem Fall bedeutet dies, dass sich die innerhalb des gestarteten Containers laufende PosgreSQL-Datenbank noch in einem Zustand befindet, in der es nicht möglich ist, mit ihr zu interagieren.
Hierfür existiert innerhalb von Testcontainers das Konzept der WaitStrategy
.
Diese sorgt dafür, dass der Container erst als gestartet identifiziert wird,
wenn dieser die innerhalb der Strategie definierten Anforderungen erfüllt.
Testcontainers liefert für einige Fälle bereits fertige Strategien mit. So ist
es einfach möglich, auf die Erreichbarkeit eines bestimmten TCP-Ports oder auf
die Beantwortung von HTTP-Requests zu warten. Zudem kann man auch auf das
Vorkommen von bestimmten Log-Nachrichten prüfen. Reichen einem die bereits
fertigen Strategien nicht, ist es aber natürlich auch möglich, eigene zu
definieren.
Um nun unser Problem zu lösen, fügen wir bereits fertige Strategien hinzu und
überprüfen, ob das Log eine Zeile, die dem Pattern
.*database system is ready to accept connections.*\s
genügt, zweimal enthält
(s. Listing 6).
Nach dieser Änderung laufen beide Tests zuverlässig durch.
Fertige Container
Natürlich sind wir nicht die Einzigen, die einen PostgreSQL-Container in unseren Tests nutzen. Damit nun nicht jeder herausfinden muss, worauf er für diesen Container warten muss, und um eine höherwertige Programmierschnittstelle anzubieten, stellt Testcontainers für gängige Container bereits fertige APIs zur Verfügung.
Somit können wir anstelle des GenericContainer
direkt die Rule
PostgreSQLContainer
verwenden (s. Listing 7).
Neben dem hier gezeigten PostgreSQL-Container enthält Testcontainers noch einige andere fertige Container. So werden neben weiteren relationalen Datenbanken wie MyQSL, MSSQL Server oder Oracle XE auch Systeme wie Apache Kafka, localstack, nginx oder Hashicorp Vault unterstützt.
Für Container, die JDBC unterstützen, gibt es zudem noch eine weitere Besonderheit. Möchte man nicht mit einer JUnit-Rule arbeiten, werden spezielle JDBC URLs angeboten, bei denen Testcontainers automatisch für jede Verbindung einen neuen Container startet.
Für Fälle, in denen man mehrere Container benötigt, gibt es sogar die Möglichkeit, Docker-Compose-Definitionen zu verwenden.
End-To-End Browsertests
Neben Tests, die Datenbanken benötigen, bietet die Java-Bibliothek Testcontainers noch speziellen Support für End-to-End Browsertests mit Selenium. In diesem Fall ist es möglich, durch den Einsatz einer speziellen BrowserWebDriver JUnit-Rule einen Container zu starten, der sowohl den gewünschten Browser (z. B. Chrome oder Firefox) als auch den Webdriver für die Integration mit Selenium enthält. Der durch die JUnit-Rule gestartete Container bietet darüber hinaus auch die Möglichkeit, sich in die laufende Browser-Sitzung per VNC zu verbinden, oder Videoaufzeichnungen von fehlgeschlagenen Tests zu speichern.
Listing 8 zeigt hierbei, wie ein solcher Test aussehen kann. In diesem Beispiel starten wir mithilfe besagter JUnit-Rule einen Chrome-Browser innerhalb eines Containers und instrumentieren diesen mit Selenium, um eine Google-Suche durchzuführen. Da das Suchergebnis durch JavaScript gerendert wird, müssen wir Selenium anweisen, auf die Änderung des Seitentitels zu warten, da dies auf das Ende des Rendering-Vorgangs hinweist.
Andere Testframeworks
Testcontainers basiert aktuell auf JUnit und implementiert automatisch das
Interface TestRule
. Die Klassen lassen sich jedoch auch ohne Weiteres
außerhalb von JUnit-Tests nutzen. Hierzu muss man lediglich die Methoden start
und stop
selber aufrufen.
Zusätzlich arbeitet das Testcontainers-Team für Version 2.0 aktiv daran, die eigentliche Implementierung unabhängig von JUnit zu gestalten, um diese Abhängigkeit loszuwerden. Dies erleichtert in Zukunft die Integration von Testcontainers in weitere Testframeworks und ermöglicht den Einsatz eines „generischen“ Docker-API, ohne dass man ein Testframework in seinen Produktivcode zieht.