Testcontainers – das Schweizer Taschenmesser der Integrationstests

Testcontainers

Der Einsatz von Container-Technologien wie beispielsweise Docker hat die Software-Industrie in den vergangenen Jahren revolutioniert. Anstatt durch den Betrieb Binärartefakte installieren zu lassen, kann getreu dem DevOps-Gedanken einmalig ein Image mitsamt der notwendigen Ablaufumgebung (z. B. JDK, Application Server, …) erstellt und dann idealerweise automatisiert auf allen Umgebungen (Entwicklung, Test, Prod) als Container gestartet werden. Aber nicht nur die eigenen Anwendungen lassen sich so verpacken, man kann auch Umsysteme wie zum Beispiel Datenbanken hochfahren, um damit zu interagieren.

Denn auch bei der lokalen Entwicklung benötigt man typischerweise bereits eine Datenbank. In vielen Java Projekten werden dafür häufig In-Memory DBs wie die H2, die Apache Derby oder die HSQLDB eingesetzt. Leider verhalten sich diese leichtgewichtigen Datenbanken nicht ganz gleich zu den typischen Produktionsvarianten (MySQL, PostgreSQL, Oracle etc.). Es treten dann auf dem Test- und Produktionssystem auf einmal Fehler oder ungewollte Effekte auf, die man lokal nicht nachstellen kann. Darum ist es sinnvoll, bereits in der lokalen Entwicklung und den automatisierten Tests gegen die echten Datenbanken zu arbeiten. Die kann man sich natürlich manuell installieren. Aber das ist unnötig aufwändig und außerdem fehlt die Sicherheit, dass es auch wirklich genau die gleiche Version wie zum Beispiel in der Produktion ist. Dadurch wird man laut Murphy’s Law unweigerlich wieder in die Falle tappen, weil die Systeme auf unterschiedlichen Ständen sind. Die Ausrede “Works on my machine” hilft nicht, wenn es in der Produktion gerade lichterloh brennt. Um die Versionsgleichheit sicherzustellen, kommt man deshalb über das Konfigurieren/Skripten und Automatisieren der Infrastruktur (Infrastructure as Code) nicht herum. Und dabei können natürlich auch Container helfen.

Mit Testcontainers bekommt man nun eine Bibliothek, mit welcher aus Java heraus der Docker-Dienst (Daemon) ferngesteuert werden kann. Gerade im Rahmen der Integrationstests vereinfacht das die Verwaltung der Infrastruktur enorm und lässt die Tests zudem ohne zusätzliche Aufwände leicht automatisieren. Und so verwundert der Name “Test”Containers nicht: die Bibliothek ist initial genau für diesen Einsatzzweck entstanden.

Container starten

Aber fangen wir zunächst mal ohne ein Test-Framework an. Wir wollen einfach einen Docker-Container aus Java heraus starten. Auf der Kommandozeile würde das übrigens folgendermaßen aussehen:

$ docker run hello-world

Dabei wird ein vorgefertigtes Image aus einer Registry (Docker Hub oder eine eigene) geladen (pull). Danach wird der Container gestartet. Dieser gibt in dem speziellen Fall einige Informationen (“Hello, world!”) auf der Kommandozeile aus.

Aus Java heraus erreichen wir das gleiche Ergebnis dann so:

try(GenericContainer container = new GenericContainer("hello-world")) {
    container.withLogConsumer(new Slf4jLogConsumer(LOG));
    container.start();
}

Die Klasse GenericContainer implementiert das Interface AutoCloseable und kann dadurch innerhalb des try-with-resources-Statement instanziiert werden. Somit muss man sich nicht um das Beenden und Aufräumen des Containers kümmern. Der LogConsumer leitet die Log-Informationen aus dem Container weiter zu unserer Logging-Façade.

In der Ausgabe sieht man zunächst, dass das Image gepullt und anschließend der Container gestartet wird:

[main] INFO 🐳 [hello-world:latest] - Pulling docker image: hello-world:latest. Please be patient; this may take some time but only needs to be done once.
[tc-okhttp-stream-1662592920] INFO 🐳 [hello-world:latest] - Starting to pull image
[tc-okhttp-stream-1662592920] INFO 🐳 [hello-world:latest] - Pulling image layers:  0 pending,  0 downloaded,  0 extracted, (0 bytes/0 bytes)
[main] INFO 🐳 [hello-world:latest] - Creating container for image: hello-world:latest
[main] INFO 🐳 [hello-world:latest] - Starting container with ID: becc21374f848218f2d628821539ed11641d7166360fd8a9480ecd487d5eb538
[main] INFO 🐳 [hello-world:latest] - Container hello-world:latest is starting: becc21374f848218f2d628821539ed11641d7166360fd8a9480ecd487d5eb538
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT: 
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT: Hello from Docker!
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT: This message shows that your installation appears to be working correctly.
[...]
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT: For more examples and ideas, visit:
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT:  https://docs.docker.com/get-started/
[tc-okhttp-stream-832145584] INFO de.sippsack.Main - STDOUT: 
[main] INFO 🐳 [hello-world:latest] - Container hello-world:latest started in PT7.183139S

Eigene Images bauen

Neben dem Einsatz von vorgefertigten Images kann man aber über eine DSL auch selbst Images on-the-fly erstellen. Das geht entweder über ein vordefiniertes Dockerfile, welches sich im Klassenpfad oder irgendwo im Filesystem befinden muss:

try (GenericContainer container = new GenericContainer(
        new ImageFromDockerfile("my-container")
              .withFileFromClasspath("Dockerfile", "Dockerfile"))
.withLogConsumer(new Slf4jLogConsumer(LOG))) {
   container.start();
}

Alternativ kann der Inhalt des Dockerfiles sogar ad-hoc über Java Code erzeugt werden. Das nachfolgende Beispiel zeigt, wie man dynamisch Docker Images im Code erstellt und den Container anschließend startet:

try (GenericContainer container = new GenericContainer(
         new ImageFromDockerfile("my-nginx")
                .withDockerfileFromBuilder(builder ->
                             builder
                                   .from("alpine:3.2")
                                   .run("apk add --update nginx")
                                   .cmd("nginx", "-g", "daemon off;")
                                   .build()))
      .withExposedPorts(80)) {
          container.start();

          String url = String.format("http://%s:%s/", container.getHost(), 
                               container.getFirstMappedPort());
          System.out.println(callHttpEndpoint(url));
      }

In diesem Fall wird auf dem Basis-Image Alpine ein nginx installiert und ausgeführt. Der Container gibt den Port 80 frei und wir können darüber auf den Webserver innerhalb des Containers zugreifen. Lokal wird der Port auf einen zufälligen Wert gemappt, und den müssen wir zunächst mit container.getFirstMappedPort() abfragen. Über einen einfach HTTP-Aufruf geben wir zu guter Letzt die Startseite des nginx auf der Konsole aus.

Kommunikation

Das Abbilden auf zufällige Ports soll Portkonflikte vermeiden. Startet man Testcontainers im Rahmen der Integrationstests, sollte man nicht darüber nachdenken müssen, ob möglicherweise eine laufende Instanz gerade den Standard-Port blockiert.

Ein Container kann übrigens auch mehrere Ports freigeben, da withExposedPorts durch eine variable Argumentenliste mit beliebig vielen Parametern umgehen kann. Will man dann explizit für einen bestimmten freigegebenen Port das zufällig generierte Pendent erfragen, muss man den Container-Port als Key verwenden:

Integer mappedPort = container.getMappedPort(80);

Für den häufig auftretenden Fall, dass genau ein Port freigegeben wurde, reicht natürlich auch der Aufruf der Convenience Methode getFirstMappedPort().

Wichtig ist, dass man nicht nur den gerade gültigen Port auf der Host-Seite erfragt, sondern auch die Host-URL programmatisch ermittelt:

final String url = String.format("http://%s:%d", container.getHost(), container.getFirstMappedPort());

Die Methode getHost() liefert dabei in den meisten Fällen zwar ‘localhost’ zurück. Aber in bestimmten Szenarien, wie z. B. auf Continuous Integration Servern, wird ggf. ein anderer Name geliefert. Neben getHost() kann man auch getContainerIpAddress() verwenden.

Natürlich darf man auch mehrere Container starten, die dann über ein Docker-Netzwerk miteinander kommunizieren können:

try (
	Network network = Network.newNetwork();

	GenericContainer containerA = new GenericContainer("nginx:1.17.10")
	     	.withCopyFileToContainer(MountableFile.forClasspathResource("index.html"), 
                       "/usr/share/nginx/html/index.html")
		.withNetwork(network)
		.withNetworkAliases("nginx");
	GenericContainer containerB = new GenericContainer() // Image: alpine
		.withNetwork(network)
		.withLogConsumer(new Slf4jLogConsumer(LOG))
		.withCommand("top");
) {
	containerA.start();
	containerB.start();
	System.out.println(containerB.execInContainer("wget", "-O", "-", 
                 "http://nginx").getStdout());
}

In diesem Szenario werden zwei Container hochgefahren. Innerhalb von Container B führen wir ein Shell Kommando aus, welches über den internen Netzwerk-Aliasnamen nginx wiederum die Startseite des in Container A laufenden Webservers abruft. Dazu wurde ein Netzwerk angelegt und beide Container wurden diesem zugewiesen.

Testen von DB-Zugriffen

Einer der Haupteinsatzzwecke bleibt das Starten einer Datenbank innerhalb eines Docker Containers für die Integrationstests. Im Falle einer PostgreSQL sähe das folgendermaßen aus:

PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer()
	.withDatabaseName("shop")
	.withUsername("shop")
	.withPassword("secret");

postgreSQLContainer.start();

System.out.println("PostgreSQL running: " + postgreSQLContainer.isRunning());

doDbTransaction(postgreSQLContainer);

postgreSQLContainer.stop();

Neben dem GenericContainer gibt es eine ganze Menge vorgefertigter Container-Typen in Form von speziellen Klassen, die man als eine Art Plugin (Modul) hinzufügen kann. Das Projekt-Team stellt zum Beispiel auch Module für andere relationale Datenbanken (Oracle, MySQL, MS SQL, …) und verschiedene NoSQL DBs (Cassandra, Neo4j, Couchbase, …) sowie für Kafka, RabbitMQ, Elasticsearch/Solr und auch Selenium Webdriver zur Verfügung. In einer Maven-Konfiguration sieht das dann so aus:

<dependency>
	<groupId>org.testcontainers</groupId>
	<artifactId>postgresql</artifactId>
	<version>${testcontainers.version}</version>
	<scope>test</scope>
</dependency>

Die eigentlich Abhängigkeit zum Testcontainers Kern (z. B. “org.testcontainers:testcontainers:1.14.2”) erfolgt in diesem Falle transitiv durch das postgresql-Modul.

Der Vorteil des spezifischen PostgreSQL-Containers gegenüber dem allgemeinen GenericContainer liegt darin, dass wir über Zugriffsmethoden notwendige kontextabhängige Parameter setzen können bzw. müssen, die dann intern über Umgebungsvariablen beim Start des Containers weitergegeben werden. In unserem Fall handelt es sich um den Datenbank-Namen und die Benutzer-Credentials. Das Starten des Containers blockiert in dem Fall, so dass wir bei der nächsten Anweisung bereits eine laufende Datenbank vorfinden. Über die Container-Instanz können wir die gültige JDBC-URL abfragen, daraus eine Datenbank-Connection erzeugen, an unsere Datenzugriffsschicht delegieren und diese somit gegen eine echte PostgreSQL-DB richten. Und dafür mussten wir weder etwas installieren, noch uns selbst im Vorfeld um das manuelle Starten eines Docker Containers auf der Kommandozeile kümmern:

private static void doDbTransaction(PostgreSQLContainer postgreSQLContainer) throws 
   SQLException {
	System.out.println("connect to " + postgreSQLContainer.getJdbcUrl());
	Properties props = new Properties();
	props.setProperty("user", "shop");
	props.setProperty("password", "secret");

	try (Connection conn = DriverManager.getConnection(postgreSQLContainer.getJdbcUrl(), 
           props)) {
		ProductDao productDao = new ProductDao(conn);
		for (int i = 1; i <= 10; i++) {
			productDao.save(i, "Product" + i);
		}
		System.out.println("nunber of rows: " + productDao.countAll());
	}
}

Testen

Apropos Testen, bisher wurde in allen Beispielen der jeweilige Testcontainer per Anweisung gestartet und teilweise auch wieder gestoppt. Tatsächlich lässt sich die Bibliothek aber auch gut in existierende Test-Frameworks integrieren. In der aktuellen Version 1.x ist sie dabei sogar noch relativ eng an JUnit gekoppelt.

Ab der Version 2.0 möchte man davon unabhängiger werden und getrennte Dependencies für den Core und als Erweiterung Adapter zu den verschiedenen Testframeworks anbieten. Aus Anwendersicht bremst uns das aber im Moment nicht aus. Die Integration mit JUnit 4 und 5 ist bereits sehr gut, zudem wird auch noch Spock unterstützt. Weitere Test-Frameworks lassen sich mit etwas Eigeninitiative auch integrieren. Man muss dann in deren Konfiguration nur die Container selbst starten und letztlich wieder aufräumen.

Bei der Integration wird der Lebenszyklus der Container an die Tests gebunden. Man muss sich also nicht mehr manuell um das Starten kümmern. In JUnit 4 sieht das so aus:

public class NginxContainerTest {

    private final static Network network = Network.newNetwork();

    @Rule
    public NginxContainer nginx = new NginxContainer<>()
            .waitingFor(new HttpWaitStrategy())
            .withNetwork(network)
            .withNetworkAliases("nginx");

    @Test
    public void testNginxContainer() throws IOException, InterruptedException {
        URL baseUrl = nginx.getBaseUrl("http", 80);

        assertThat("An HTTP GET from the Nginx server returns the index.html from the custom 
           content directory",
                callHttpEndpoint(baseUrl.toString()),
                containsString("Welcome to nginx!")
        );
    }

    private static String callHttpEndpoint(String url) throws IOException, 
           InterruptedException {
              HttpClient httpClient = HttpClient.newBuilder().build();
              HttpRequest mainRequest = HttpRequest.newBuilder()
                  .uri(URI.create(url))
                  .build();

              HttpResponse<String> response = httpClient.send(mainRequest, 
                  HttpResponse.BodyHandlers.ofString());
              return response.body();
    }
} 

Dabei werden die Rule-Instanzen vor dem Ausführen jeder Test-Methode erzeugt und damit die Container immer frisch initialisiert und gestartet. Nach der Testmethode wird zudem wieder aufgeräumt und der Container gestoppt und auch das Image wird standardmäßig gelöscht. Das kann man aber auch deaktivieren. Möchte man einen Container nur einmal starten und für alle Test-Methoden einer Klasse wiederverwenden, kann man die @ClassRule verwenden.

In JUnit 5 sieht es etwas anders aus. Statt Rules verwendet man hier den Extension Mechanismus. Man muss dazu die Klasse mit @Testcontainers und die Container Member Variablen mit @Container annotieren. Das nachfolgende Beispiel zeigt das einfache Einbinden eines Redis Cache:

@Testcontainers
public class RedisContainerTest {

    private RedisBackedCache underTest;

    @Container
    public GenericContainer redis = new GenericContainer<>("redis:5.0.3-alpine")
            .withExposedPorts(6379);

    @BeforeEach
    public void setUp() {
        Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379));
        underTest = new RedisBackedCache(jedis, "test");
    }

    @Test
    public void testSimplePutAndGet() {
        underTest.put("test", "example");

        String retrieved = underTest.get("test", String.class).orElse("N/A");
        assertEquals("example", retrieved);
    }
}

In Spock kann man die Tests durch die Groovy Syntax lesbarer gestalten, das Grundprinzip der Einbindung der Testcontainers Bibliothek bleibt aber gleich:

@Testcontainers
class DatabaseTest extends Specification {

    @Shared
    PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer()
            .withDatabaseName("foo")
            .withUsername("foo")
            .withPassword("secret")

    def "database is accessible"() {

        given: "a jdbc connection"
        HikariConfig hikariConfig = new HikariConfig()
        hikariConfig.setJdbcUrl(postgreSQLContainer.jdbcUrl)
        hikariConfig.setUsername("foo")
        hikariConfig.setPassword("secret")
        HikariDataSource ds = new HikariDataSource(hikariConfig)

        when: "querying the database"
        Statement statement = ds.getConnection().createStatement()
        statement.execute("SELECT 1")
        ResultSet resultSet = statement.getResultSet()
        resultSet.next()

        then: "result is returned"
        int resultSetInt = resultSet.getInt(1)
        resultSetInt == 1
    }
}

Container für lokale Selenium Tests

Das Starten von Datenbanken, Web- bzw. Messaging-Servern und anderen Middleware-Komponenten als Docker Container ist schon eine feine Angelegenheit. Man kann aber auch seine eigene Anwendung genau wie später in Produktion in einem Docker-Container hochfahren und dann von außen betesten. Damit kommen wir zur Spitze der Testpyramide, den Ende-zu-Ende Tests. Dafür bietet sich bei Webanwendungen Selenium an, bei REST-Ressourcen könnte man mit RestAssured und bei SOAP-Webservices mit Postman- oder SoapUI-Skripten arbeiten.

Wir wollen uns an dieser Stelle ein Selenium Beispiel anschauen. Der Webdriver läuft dabei in einem eigenen Docker-Container. Das Ausführen von unterschiedlichen Browsern (Chrome, Firefox, etc.) lässt sich rein konfigurativ von außen steuern, sodass man sehr unkompliziert verschiedene Konstellationen und Szenarien ausführen kann.

In der nachfolgenden Methode wird der Port mitgegeben, über den die zu betestende Anwendung, die in einem eigenen Docker-Container läuft, erreichbar ist:

private static void useSeleniumContainer(int port) {
	Testcontainers.exposeHostPorts(port);
	try (BrowserWebDriverContainer chromeContainer = new BrowserWebDriverContainer()
			.withCapabilities(new ChromeOptions())) {
		chromeContainer.start();
		RemoteWebDriver webDriver = chromeContainer.getWebDriver();
		final String url =
			String.format("http://host.testcontainers.internal:%d/", port);
		System.out.println(url);
		webDriver.get(url);

		System.out.println(webDriver.findElementByTagName("h1").getText());
	}
}

Damit der Webdriver aus dem Container auf den anderen Container zugreifen darf, muss man den betreffenden Port freischalten: Testcontainers.exposeHostPorts(port). Anschließend kann ein vorgefertigter Webdriver-Container gestartet werden. In diesem Beispiel wird der Chrome-Browser verwendet. Über die standardisierte URL host.testcontainers.internal und dem freigegebenen Port kann dann ein Get-Aufruf ausgeführt und die zurückgelieferte DOM-Struktur auf die Existenz bestimmter Elemente geprüft werden. Kombiniert man das Ganze noch mit dem Page Object Pattern, lassen sich gut les- und wartbare Oberflächentests entwickeln.

Zusammenfassung

Testcontainers ist eine überaus nützliche Java Bibliothek, mit der man programmatisch Docker Images erzeugen und den Lebenszyklus von Containern steuern kann. Insbesondere im Rahmen von Integrations- und Ende-zu-Ende-Tests wird die Verwaltung von Infrastrukturdiensten stark vereinfacht. Man muss weder komplexe Setups auf den Entwicklungsrechnern ausführen, noch muss man auf die echten Services zugunsten von zwar leichtgewichtigen, aber fragilen In-Memory Instanzen verzichten. Außerdem kann man über Infrastructure as Code einfacher und effizienter sicherstellen, dass auf allen Systemen die gleichen Versionen verwendet werden, wodurch schwierig nachvollziehbare Fehler vermieden werden. Ein weiterer Anwendungsfall ist das Erstellen von UI-Akzeptanz-Tests mit Selenium-kompatiblen, containerisierten Web-Browsern. Jeder Test kann dabei eine frische Browser-Instanz ohne Verlauf (Zustand) und mit variierenden Plugin-Installationen bzw. Browser-Versionen nutzen. Sogar das Aufnehmen von Snapshots im Fehlerfall oder das Recording von ganzen Videos zur besseren Nachvollziehbarkeit der Tests ist möglich.

Einzig bei Unit-Tests, welche die breite Basis der Testpyramide repräsentieren, kann uns Testcontainers nicht unterstützen. An dieser Stelle sei aber nochmal darauf hingewiesen, dass auch trotz des Einsatzes der Testcontainers Bibliothek Unit Tests geschrieben werden sollten. Das notwendige Vorgehen (Entkopplung, Mocking, …) übersteigt den Rahmen dieses Artikels, kann aber in diversen anderen Quellen im Internet nachgelesen werden.

Als Voraussetzungen muss Docker lokal installiert sein und mindestens Java 8 verwendet werden. Unter Linux ist die Unterstützung derzeit aufgrund des nativen Docker-Supports am besten. Bei MacOS läuft es auch sehr stabil. Unter Windows gibt es noch ein paar Kinderkrankheiten, die aber vermutlich in der nächsten Zeit verschwinden.

Neben der Variante für Java gibt es mittlerweile verschiedene Portierungen von Testcontainers für andere Sprachen wie Go, Python und Node.js. Weitere Infos und auch eine sehr gute Dokumentation findet man auf der Projektseite. Der Quellcode und viele Beispiele können auf Github eingesehen werden.

Short URL for this post: https://wp.me/p4nxik-3JI
This entry was posted in Java and Quality and tagged , , , . Bookmark the permalink.

Leave a Reply