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.

package de.mvitz.js.tc;

import java.sql.*;
import java.util.*;

import static java.sql.Statement.RETURN_GENERATED_KEYS;

public class AuthorRepository {

  private static final String INSERT_STATEMENT =
    "INSERT INTO authors (name) VALUES (?)";

  private static final String READ_ALL_STATEMENT =
    "SELECT id, name FROM authors";

  private final Connection connection;

  public AuthorRepository(Connection connection) {
    this.connection = connection;
  }

  public void save(Author author) throws SQLException {
    try (PreparedStatement pstmt = connection
        .prepareStatement(INSERT_STATEMENT, RETURN_GENERATED_KEYS)) {
      pstmt.setString(1, author.getName());

      int affectedRows = pstmt.executeUpdate();
      if (affectedRows == 0) {
        throw new SQLException("Could not save author");
      }

      try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
        if (generatedKeys.next()) {
          author.setId(generatedKeys.getLong(1));
        } else {
          throw new SQLException("Could not save author");
        }
      }
    }
  }

  public List<Author> findAll() throws SQLException {
    final List<Author> authors = new ArrayList<>();
    try (PreparedStatement pstmt = connection
        .prepareStatement(READ_ALL_STATEMENT);
        ResultSet rs = pstmt.executeQuery()) {
      while (rs.next()) {
        Author author = new Author(rs.getString("name"));
        author.setId(rs.getLong("id"));
        authors.add(author);
      }
    }
    return authors;
  }

}
Listing 1: AuthorRepository

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).

package de.mvitz.js.tc;

import org.junit.Test;
import java.sql.*;
import java.util.List;

import static org.junit.Assert.assertEquals;

public class AuthorRepositoryTest {

  @Test
  public void save_should_set_id() throws Exception {
    withConnection(connection -> {
      AuthorRepository sut = new AuthorRepository(connection);
      Author author = new Author("Kevin");

      sut.save(author);
      assertEquals(1, author.getId());
    });
  }

  @Test
  public void findAll_should_contain_saved_author() throws Exception {
    withConnection(connection -> {
      AuthorRepository sut = new AuthorRepository(connection);

      Author author = new Author("Michael");
      sut.save(author);

      List<Author> authors = sut.findAll();
      assertEquals(1, authors.size());
      assertEquals("Michael", authors.get(0).getName());
    });
  }

  private void withConnection(ConnectionConsumer c) throws Exception {
    Connection connection = create();
    c.executeWith(connection);
  }

  private interface ConnectionConsumer {
    void executeWith(Connection connection) throws Exception;
  }

}
Listing 2: Testklasse für das AuthorRepository

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.

public class AuthorRepositoryTest {

  ...

  private void withConnection(ConnectionConsumer c) throws Exception {
    Connection connection = create();
    setup(connection);
    c.executeWith(connection);
  }

  private void setup(Connection c) throws SQLException {
    String dropStmt = "DROP TABLE IF EXISTS authors";
    String createStmt =
      "CREATE TABLE authors (id SERIAL, name varchar(255))";
    try (PreparedStatement drop = c.prepareStatement(dropStmt);
        PreparedStatement create = c.prepareStatement(createStmt)) {
      drop.execute();
      create.execute();
    }
  }

  ...

}
Listing 3: Setup für die Datenbank

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).

public class AuthorRepositoryTest {

  ...

  private Connection create() throws Exception {
    String url = "jdbc:postgresql://localhost:5432/postgres";
    String user = "postgres";
    String password = "mysecretpassword";
    return DriverManager.getConnection(url, user, password);
  }

  ...

}
Listing 4: Verbindung zur Datenbank herstellen

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).

public class AuthorRepositoryTest {

  @ClassRule
  public static GenericContainer postgres =
    new GenericContainer("postgres:latest")
      .withEnv("POSTGRES_PASSWORD", "mysecretpassword");

  ...

  private Connection create() throws Exception {
    String host = postgres.getContainerIpAddress();
    int port = postgres.getMappedPort(5432);
    String url = "jdbc:postgresql://"+host+":"+port+"/postgres";
    String user = "postgres";
    String password = "mysecretpassword";
    return DriverManager.getConnection(url, user, password);
  }

  ...
}
Listing 5: Benutzung von GenericContainer

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.

JUnitRules

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).

public class AuthorRepositoryTest {

  static String WAIT_PATTERN =
    ".*database system is ready to accept connections.*\\s";

  @ClassRule
  public static GenericContainer postgres =
    new GenericContainer("postgres:latest")
      .withEnv("POSTGRES_PASSWORD", "mysecretpassword")
      .waitingFor(forLogMessage(WAIT_PATTERN, 2));

  ...

}
Listing 6: Erweiterung um unsere WaitStrategy

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).

public class AuthorRepositoryTest {

  @ClassRule
  public static PostgreSQLContainer postgres =
    new PostgreSQLContainer()
      .withPassword("mysecretpassword");

  ...

  private Connection create() throws Exception {
    String url = postgres.getJdbcUrl();
    String user = postgres.getUsername();
    String password = postgres.getPassword();
    return DriverManager.getConnection(url, user, password);
  }

  ...

}
Listing 7: Verwendung des fertigen PostgreSQLContainer

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.

public class SeleniumTest {
  @Rule
  public BrowserWebDriverContainer chrome =
    new BrowserWebDriverContainer()
      .withDesiredCapabilities(DesiredCapabilities.chrome())
      .withRecordingMode(RECORD_ALL, new File("build"));

  @Test
  public void searchGoogle() {
    WebDriver driver = chrome.getWebDriver();

    driver.get("https://www.google.de");

    WebElement searchInputField = driver.findElement(By.name("q"));
    searchInputField.sendKeys("Testcontainers");
    searchInputField.submit();

    new WebDriverWait(driver, 10).until(
      webDriver -> webDriver.getTitle()
        .toLowerCase().startsWith("testcontainers")
    );

    Assert.assertEquals("Testcontainers - Google-Suche",
      driver.getTitle());
    driver.quit();
  }
}
Listing 8: Browser-Tests mit Selenium und BrowserWebDriverContainer

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.

Conclusion

Für eine vollständige Abdeckung aller Testaspekte werden auch Integrationstests, also Tests, die mit externen Systemen interagieren, benötigt. Handelt es sich bei diesen Systemen um Datenbanken, so bietet sich der Einsatz von Testcontainers für diese an. Die Tests laufen anschließend gegen eine wirklich richtige Instanz der gewählten Datenbank und erhöhen somit das Vertrauen, dass der so getestete Code auch wirklich funktioniert.

Abseits von Datenbanken lassen sich Testcontainers auch für Tests, die einen laufenden Browser benötigen, einsetzen. Vor allem der Aufwand, den passenden Browser lokal zu installieren, entfällt hierbei und bietet somit einen klaren Vorteil.

Auch außerhalb von Tests kann sich der Einsatz von Testcontainers anbieten, um programmatisch per Java Container zu starten.

Um den Code ausführen zu können, ohne ihn abtippen zu müssen, ist er auf GitHub zu finden.