
Testcontainers ist eine mit JUnit integrierbare Bibliothek für Integrationstests, die über Maven oder Gradle in eine bestehende Testlandschaft eingebunden werden kann.
Docker-Technologie
Testcontainers setzt Docker-Container ein und erlaubt die Verwendung beliebiger Images. Entsprechend ist für die Ausführung von Tests mit Testcontainers eine Docker-Umgebung erforderlich. Je nach Umgebung erfordert das weitere Einstellungen. Auf der Webseite von Testcontainers wird beschrieben, was zu tun ist, damit Testcontainers-Tests in einer Pipeline wie z.B. der GitLab CI ausgeführt werden können. Anstelle eines Dockerfiles wird ein Container nach dem Builder-Pattern direkt im Java-Code spezifiziert.
Generische Container
Mit der Klasse GenericContainer
lässt sich ein beliebiges Docker-Image generisch verwenden, wie der nachfolgende Code verdeutlicht.
@Container
public static GenericContainer myContainer =
new GenericContainer("alpine:3.11")
.withExposedPorts(80)
.withCommand("/bin/sh", "-c", "while true; do echo \"HTTP/1.1 200 OK\r\n\r\nalles gut\r\n\" | nc -l -p 80; done");
Die Annotation @Container
kennzeichnet in einem JUnit5-Testkontext das Feld als Container. Ist die Klasse noch mit @Testcontainers
annotiert, so fährt JUnit5 diesen Container vor der Testausführung für die Verwendung in den Tests hoch.
Der Container verwendet ein Linux Alpine als Docker-Image und gibt Port 80 im Container frei. Testcontainers vergibt nach außen per default einen zufälligen Port. Der angegebene Command wird auf der Linux-Shell im Container ausgeführt.
Da der Container unmittelbar nach erfolgreicher Ausführung des Commands heruntergefahren würde, führe ich den Befehl stattdessen in einer Endlosschleife aus. Über nc (netcat) lässt sich der Text aus dem echo als Antwort auf einen eingehenden Http-Get-Request nach außen geben. Der Aufbau des Strings ist Http-konform mit Protokoll Http 1.1, Statuscode 200 und dem Body „alles gut“.
Der nachfolgende Test prüft die Erreichbarkeit des Containers ab.
@Test
public void shouldRespond() throws Exception {
// given
final String url = String.format(
"http://%s:%d",
myContainer.getContainerIpAddress(),
myContainer.getMappedPort(80));
// when
HttpResponse<String> response = HttpClient
.newBuilder()
.build()
.send(HttpRequest
.newBuilder()
.GET()
.uri(new URI(url)).build(),
HttpResponse.BodyHandlers.ofString()
);
// then
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body().trim()).isEqualTo("alles gut");
}
Im Given-Part werden über durch GenericContainer
angebotene Methoden die Docker-IP-Adresse des Containers und der durch Testcontainers generisch vergebene externe Port ermittelt. Daraus ergibt sich die Http-Adresse des über echo und netcat gestrickten „Http-Servers“.
Im When-Part wird die Http-Abfrage über den Http-Client aus dem JDK realisiert. Im Then-Part werden der Statuscode und der Body der Http-Response abgeprüft.
Vorgefertigte Container
Testcontainers bietet zahlreiche vorgefertigte Container für verschiedene Anwendungszwecke. Darunter sind SQL-Container (DB2, MariaDB, MySQL, Oracle, Postgres), MessageQueueing-Container (RabbitMQ, Kafka), die Graphendatenbank Neo4j, ElasticSearch, Nginx, Hashicorp Vault und auch ein Webdriver-Container für Tests gegen eine Webbrowser-Engine.
Um eine MySQL-Datenbank in einem Integrationstest zu verwenden, kann z.B. die Klasse MySQLContainer
verwendet werden. Der Container fährt eine MySQL-Datenbank hoch und publiziert die Zugangsdaten über Instanzmethoden wie getJdbcUrl()
, getUsername()
und getPassword()
.
Docker-Compose
Bei komplexeren Setups kann auch docker-compose verwendet werden. Hierfür bietet Testcontainers die Klasse DockerComposeContainer
, die im Konstruktor den Pfad zum docker-compose.yml entgegennimmt. Nach dem Builder-Pattern werden über die Methode withExposedService()
Services mit einem Servicenamen und Port für den Zugriff von außen zur Verfügung gestellt. Im Test wiederum kann über die Instanzmethoden getServiceHost()
und getServicePort()
der Connection String für die jeweiligen Container zusammengebaut werden.
Integrationstests mit Legacy-Systemen und Drittsystemen
In der Vergangenheit habe ich für die bidirektionale Datenübertragung zwischen einem Legacy-System und einem Drittsystem Tests geschrieben. Drittsystem bedeutet hier, dass das System außer meiner Kontrolle war. Bei dem Legacy-System handelte es sich um eine Java-Standalone-Anwendung mit einem Oracle-Backend, die in einem Docker-Container lief. Die Anwendung war stateful und lief in Produktion nicht auf Docker, was für den Test aber unerheblich war.
Für das Drittsystem, das über ein REST-Interface erreichbar war, wurde für den Integrationstest ein Mock entwickelt, der die verwendeten Aufrufe an der REST-API bereitstellte und eine sehr einfache Datenhaltung hatte. Über weitere REST-Endpunkte konnte im Integrationstest abgeprüft werden, ob die gemeldeten Daten übertragen worden sind. Um die Datenübertragung im Test anzusteuern, wurde im Integrationstest ein bestimmter Testdatenstand in das Oracle-Backend der Legacy-Anwendung eingespielt. Dieser Testdatenstand veranlasste, dass ein Job zur Übertragung der Daten gestartet wurde und die eingespielten Testdaten übertrug.
Die Umsetzung hat mir gezeigt, dass sich mit Testcontainers fast alles abtesten lässt, solange man es irgendwie in einen Docker-Container reinbekommt und über das Herstellen einer Datenlage oder das Ansteuern einer API gezielt Aktionen ausführen kann. Man sollte allerdings nicht vergessen, dass Integrationstests grundsätzlich teuer sind, vor allem wenn die integrierten Systeme selbst mehrere Sekunden oder gar Minuten benötigen, um hochzufahren und im Testkontext verwendet werden zu können.
- Webseite von Testcontainers