Docker dir einen

Keine Einführung in Docker: Tests mit JUnit 5, Testcontainers und Vaadin

Sven Ruppert

© Shutterstock / MOLPIX

Docker hier, Docker da, am besten gleich als Kubernetes-Cluster. Das ist für diejenigen, die sich in der Zukunft mit den Großen messen wollen. Man weiß ja nie, wie schnell man skalieren muss und Milliardär wird. Aber da gibt es noch den Rest, der mit den ganz gewöhnlichen Szenarien klarkommen muss. Keine Sorge, dieser Artikel ist keine Einführung in Docker.

Seien wir bescheiden und nehmen an, dass wir nur ein paar Docker-Container benötigen, um unsere Herausforderungen zu meistern. Docker als Basis zu nehmen, wenn man mit verschiedenen Systemen eine Komposition aufbauen muss, ist eine rundweg solide Entscheidung. Schnell ist der Downloadlink gefunden und dank unserer hervorragenden Internetinfrastruktur findet man sich schnell als stolzer Docker-Installateur wieder. Ein paar Klicks auf die richtige Stelle am Bildschirm und schon läuft die Basisinfrastruktur.

Und jetzt? Ja, jetzt kommt der lästige Teil, die Verwendung von Docker selbst. Aber wollte man eigentlich nicht nur mal eben eine Datenbank in einer PostgreSQL-Instanz ausprobieren?

Der manuelle Weg könnte dann so aussehen: Nachdem der Befehl docker pull postgres auf der Kommandozeile ausgeführt worden ist, befindet sich das Image auf dem lokalen Rechner und ist bereit, gestartet zu werden. Ein docker run –name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -d postgres erzeugt dann einen Container, startet ihn und setzt das Passwort auf mysecretpassword. Ein docker ps zeigt, dass der Container läuft. Voller Optimismus startet man nun das SQL-Werkzeug seiner Wahl und möchte sich zu der frisch geschlüpften PostgreSQL verbinden. Und hier beginnt auch schon das Dilemma.

Eine Verbindung à la jdbc:postgresql://localhost:5432/postgres funktioniert nicht. Da war doch noch was? Genau, die Ports sind nicht nach draußen freigegeben. Und so geht das dann erst einmal weiter. Nachdem man alles im Internet zusammengesucht hat, stellt sich die Frage, wie man alle Kommandos so in das Projekt einbinden kann, dass es unfallfrei verwendbar ist. Und dann war da noch das Dilemma mit der CI-Strecke.

API Summit 2019
Thilo Frotscher

API-Design – Tipps und Tricks aus der Praxis

mit Thilo Frotscher (Freiberufler)

Golo Roden

Skalierbare Web-APIs mit Node.js entwickeln

mit Golo Roden (the native web)

Testcontainers

Da man mit diesen Herausforderungen nicht allein auf der Welt ist, besteht die Chance, dass dieses Problem schon andere so richtig genervt hat und daraus Lösungen entstanden sind. Eine dieser möglichen Lösungen ist das Projekt Testcontainers. Als Erstes müssen dem Projekt die notwendigen Abhängigkeiten hinzugefügt werden. Wird Maven verwendet, kann das folgendermaßen aussehen:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.10.6</version>
  <scope>test</scope>
</dependency>

Möchte man nun einen Docker-Container starten, kann man mit der Klasse GenericContainer beginnen. Der Konstruktor erwartet den Namen eines Image, in unserem Fall postgres. Da die Klasse das Interface Autoclosable implementiert, ist ein explizites stop() nicht notwendig, um den Container wieder ordnungsgemäß zu beenden:

try (GenericContainer container = new GenericContainer("postgres")) {
  container.start();
  // ... use the container
  // no need to call stop() afterwards
}

Wenn man das nun innerhalb einer main-Methode startet, bekommt man etwa die in Listing 1 gezeigten Meldungen zu sehen.

[main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy - Loaded org.testcontainers.dockerclient.UnixSocketClientProviderStrategy from ~/.testcontainers.properties, will try it first
[main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy - Will use 'okhttp' transport
[main] INFO org.testcontainers.dockerclient.UnixSocketClientProviderStrategy - Accessing docker with local Unix socket
[main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy - Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
[main] INFO org.testcontainers.DockerClientFactory - Docker host IP address is localhost
[main] INFO org.testcontainers.DockerClientFactory - Connected to docker: 
  Server Version: 18.09.2
  API Version: 1.39
  Operating System: Docker for Mac
  Total Memory: 1998 MB
[main] INFO org.testcontainers.utility.RegistryAuthLocator - Credentials not found for host (quay.io) when using credential helper/store (docker-credential-osxkeychain)
[main] INFO org.testcontainers.DockerClientFactory - Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
        ℹ︎ Checking the system...
        ✔ Docker version should be at least 1.6.0
        ✔ Docker environment should have more than 2GB free disk space
[main] INFO 🐳 [postgres:latest] - Creating container for image: postgres:latest
[main] INFO 🐳 [postgres:latest] - Starting container with ID: 56e82d812e89dd0c8017c2d52989e4a0005f818a2072f6d6355012b66dee7b18
[main] INFO 🐳 [postgres:latest] - Container postgres:latest is starting: 56e82d812e89dd0c8017c2d52989e4a0005f818a2072f6d6355012b66dee7b18
[main] INFO 🐳 [postgres:latest] - Container postgres:latest started

Sobald der Container gestartet wird, werden einige notwendige Rahmenparameter überprüft. Ist das angegebene Image nicht auf dem Zielsystem vorhanden, wird es automatisch nachgeladen. Da wir zu Beginn den PostgreSQL-Container einmal auf der Kommandozeile per Hand gestartet haben, war das in diesem Fall nicht mehr notwendig.

PostgreSQL-Container

Allerdings haben wir hiermit nur die halbe Strecke geschafft. Auch hier muss man sich mit dem Container und dessen Konfiguration auseinandersetzen. Das Ziel ist ja immer noch, einen PostgreSQL-Container so zu starten, dass wir Zugriff auf das RDBMS haben. Testcontainers liefert für einige RDBMS vorgefertigte Klassen. Für PostgreSQL wird die Klasse PostgreSQLContainer eingesetzt. Der Ablauf ist aber genauso wie vorher beim generischen Container (Listing 2).

public static void main(String[] args) {
  try (PostgreSQLContainer container = new PostgreSQLContainer()
                                           .withDatabaseName("foo")
                                           .withUsername("foo")
                                           .withPassword("secret")) {
    container
        .start();

    final String jdbcUrl = container.getJdbcUrl();
    System.out.println("jdbcUrl = " + jdbcUrl);
    // ... use the container
    // no need to call stop() afterwards
  }
}

Was hier ebenfalls mitgeliefert wird, sind Methoden, die den Umgang mit dem RDBMS erleichtern. So findet man die Methoden zum Setzen des Benutzernamens, Passworts und Datenbanknamens. Um sich später mittels JDBC mit der Instanz zu verbinden, kann man sich den JDBC-URL von der jeweiligen Containerinstanz geben lassen.

Der JDBC-URL war in diesem Fall jdbcUrl = jdbc:postgresql://localhost:32779/foo. Was hier auffällt, ist Port 32779, der nicht der normalerweise von PostgreSQL verwendete Port ist (das ist nämlich Port 5432). Das Testcontainers-Team hat also Wert darauf gelegt, dass man ohne Mehraufwand eine beliebige Anzahl von gleichen Containern starten kann und diese sich mit den Ports nicht gegenseitig in die Quere kommen.

Nachdem die Container erzeugt, gestartet und gestoppt werden können und auch der JDBC-URL zur Verfügung steht, ist es an der Zeit, eine erste Verbindung zum System aufzubauen. Der pom.xml werden die Abhängigkeiten für den PostgreSQL-JDBC-Treiber und für einen JDBC Connection Pool hinzugefügt. Als JDBC Connection Pool kommt hier der Open-Source-Vertreter mit dem Namen Hikari zum Einsatz (Listing 3).

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.2.5</version>
</dependency>

<dependency>
  <groupId>com.zaxxer</groupId>
  <artifactId>HikariCP</artifactId>
  <version>3.3.1</version>
</dependency>

Um nun eine aktive Verbindung aufzubauen und die Testabfrage gegen das System zu stellen, wird zuerst ein Connection Pool erzeugt:

private static DataSource createDataSource(JdbcDatabaseContainer container) {
  HikariConfig hikariConfig = new HikariConfig();
  hikariConfig.setJdbcUrl(container.getJdbcUrl());
  hikariConfig.setUsername(container.getUsername());
  hikariConfig.setPassword(container.getPassword());
  return new HikariDataSource(hikariConfig);
}

und mit einer Verbindung aus dem Pool wird eine Test-SQL-Abfrage an das RDBMS gesendet (Listing 4).

private static ResultSet performQuery(DataSource ds , String sql)
    throws SQLException {
  Statement statement = ds
      .getConnection()
      .createStatement();
  statement.execute(sql);
  ResultSet resultSet = statement.getResultSet();
  resultSet.next();
  return resultSet;
}

    final DataSource dataSource = createDataSource(container);
    ResultSet resultSet = performQuery(dataSource , container.getTestQueryString());

    int resultSetInt = resultSet.getInt(1);
    System.out.println("resultSetInt = " + resultSetInt);

Testcontainers liefert auch die Service-Methode container.getTestQueryString(), mittels der man unabhängig vom verwendeten RDBMS eine Testabfrage gegen das System formulieren kann.

Datenbankschema und Daten

Bisher wurden nur einzelne Teile zusammengesetzt, nun aber beginnt die Definition der projektspezifischen Elemente, an dieser Stelle die Definition eines Datenbankschemas, das dem zu lösenden Problem adäquat sein soll. Um nun die Datenbank mit einer gewissen Struktur zu initialisieren, hat sich das Werkzeug Flyway immer wieder als nützlich erwiesen. Um Flyway zu verwenden, benötigen wir folgende Abhängigkeit in unserer pom.xml:

<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-core</artifactId>
  <version>5.2.4</version>
</dependency>

Flyway bietet verschiedene Wege, den Funktionsumfang zu nutzen. Neben den Plug-ins für Maven und Gradle kann man auch programmatisch vorgehen. Um erst einmal von dem Build-Werkzeug so unabhängig wie möglich zu sein, werden wir den letztgenannten Weg beschreiten. Damit Flyway mit der Datenbank interagieren kann, muss natürlich zuerst eine Verbindung mittels JDBC hergestellt werden:

// create the Flyway instance and point it to the database
Flyway flyway = Flyway.configure()
                      .dataSource(container.getJdbcUrl(),
                                  container.getUsername(),
                                  container.getPassword())
                      .load();

// start the migration
flyway.migrate();

Flyway selbst sucht beim Aufruf der Methode migrate nach einen Verzeichnis src/main/resources/db/migration bzw. im Klassenpfad nach dem Verzeichnis /db/migration. Darin enthaltene SQL-Skripte werden in lexikografischer Reihenfolge ausgeführt. Das Namensschema besteht aus einer Versionsnummer mit vorangestelltem V, zwei Unterstrichen und einem semantischen Bezeichner. Der semantische Bezeichner ist das, was dann in der von Flyway erzeugten Versionstabelle im Schema als Info geführt werden wird.

Für die Anwendung werden nun zwei Versionen erzeugt. Die erste Version konfiguriert das System selbst, indem es die PostgreSQL-Erweiterung crypto hinzufügt. Die zweite Version erzeugt die Tabellen zum Speichern der Benutzername-Passwort-Kombinationen. Diese beiden SQL-Skripte kommen unter main/resources in das Verzeichnis db/migration. Um danach noch die Tabellen mit Werten zum Testen zu füllen, wird unter dem Verzeichnis test/resources/db/migration ein SQL-Skript hinzugefügt, das nur Demodaten einfügt.

In diesem Beispiel habe ich alle produktiven Skripte mit Versionsnummern V00x und alle Skripte, die für die Durchführung von Tests notwendig sind, mit Versionsnummern V90x belegt (Listings 5 bis 7):

V000\_\_init\_core\_system.sql

CREATE EXTENSION pgcrypto;
V001\_\_init\_login\_tables.sql

CREATE TABLE login
(
  id       serial              NOT NULL,
  email    varchar(255)        NOT NULL,
  username varchar(255) UNIQUE NOT NULL,
  password varchar(255)        NOT NULL,
  deleted  boolean DEFAULT FALSE,
  PRIMARY KEY (id)
);

comment on table login
  is 'Users/Logins that are able to login into the webapp';

create index login_email_index
  on login (email);

CREATE VIEW v_active_logins AS
SELECT *
FROM login l
WHERE l.deleted = false;

comment on view v_active_logins
  is 'Active Logins, that are able to log into the app';
V901\_\_init\_test\_login\_tables.sql

-- Default Data for tests
INSERT INTO login(email, username, password, deleted)
VALUES('admin@xx.xx', 'admin', 'admin', FALSE);

INSERT INTO login(email, username, password, deleted)
VALUES('user@xx.xx', 'user', 'user', FALSE);

INSERT INTO login(email, username, password, deleted)
VALUES('deleted@xx.xx', 'deleted', 'deleted', TRUE);

Einen einfachen Zugriff bzw. Test, ob die Daten nun auch in der Tabelle vorhanden sind, kann man dann genauso realisieren wie bei der Testabfrage. Natürlich wird ein anderer SQL Select verwendet:

final String email = performQuery(dataSource ,
                     "SELECT email "
                     + "FROM login "
                     + "WHERE username='admin'")
     .getString("email");
System.out.println("email = " + email);

„LoginService“

Um in der Demoanwendung einen Log-in-Mechanismus zu implementieren, erzeugen wir als Nächstes die Klasse LoginService. Diesem Service spendieren wir die Methode zum Überprüfen der Username-Passwort-Kombination. Nun haben wir also eine erste Implementierung, die Zugriff auf die Postgres haben soll (Listing 8).

public boolean checkLogin(String username , String password) {

  if (Objects.isNull(username)) return false;
  if (Objects.isNull(password)) return false;

  try {
    final int count = performQuery(dataSource ,
                         "SELECT count(*) "
                         + "FROM login "
                         + "WHERE username='" + username + "' "
                         + "AND password='" + password + "'")
        .getInt(1);
    return count == 1;
  } catch (SQLException e) {
    e.printStackTrace();
    return false;
  }
}

Um das Verhalten dieses Service zu überprüfen, schreiben wir mittels JUnit 5 einen Test (Listing 9). Die Abhängigkeiten für JUnit 5 sind wie gehabt in die pom.xml einzutragen.

<!--jUnit5-->
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.4.0</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.4.0</version>
    <scope>test</scope>
  </dependency>

Der Test muss nun sicherstellen, dass das Datenbankmanagementsystem vor dem Test erzeugt und initialisiert wurde. Was wir vorher manuell gemacht haben, können wir jetzt in den Lebenszyklus von JUnit 5 einbauen. Damit wird es für jeden Test erneut ausgeführt, und wir können sicher sein, dass die Tests sich untereinander nicht beeinflussen. Das Thema parallele Testausführung lasse ich hier absichtlich außen vor.

JUnit 5 bietet das Konzept der Extensions, auf das ich hier nur sehr kurz eingehen werde. Eine Klasse, die die Interfaces BeforeEachCallback und AfterEachCallback implementiert, kann man verwenden, um Dinge genau vor und nach der Durchführung eines Tests ablaufen zu lassen. In unserem Fall kopieren wir das Erzeugen des Containers in die Implementierung der Methode beforeEach, ein explizites Beenden des Containers kommt dann in die Methode afterEach. Um Informationen zwischen diesen Extensions auszutauschen, verwendet man den ExtensionContext. Diesen kann man sich wie eine Map vorstellen, die zwischen den Aufrufen durchgereicht wird (Listing 10).

public class PersistenceExtension
    implements
    BeforeEachCallback,
    AfterEachCallback {

  @Override
  public void beforeEach(ExtensionContext extensionContext) throws Exception {

    PostgreSQLContainer container = new PostgreSQLContainer().withDatabaseName("foo")
                                         .withUsername("foo")
                                         .withPassword("secret");
    container.start();
    // create the Flyway instance and point it to the database
    Flyway flyway = Flyway.configure()
                          .dataSource(container.getJdbcUrl(),
                                      container.getUsername(),
                                      container.getPassword())
                          .load();

    // start the migration
    flyway.migrate();

    extensionContext
        .getStore(ExtensionContext.Namespace.GLOBAL)
        .put(JdbcDatabaseContainer.class.getSimpleName() , container);

  }

  @Override
  public void afterEach(ExtensionContext extensionContext) throws Exception {

    extensionContext
        .getStore(ExtensionContext.Namespace.GLOBAL)
        .get(JdbcDatabaseContainer.class.getSimpleName() , JdbcDatabaseContainer.class)
        .stop();
  }
}

Nun müssen wir noch an die Informationen für die Verbindung zum RDBMS herankommen, um dann innerhalb des Tests damit arbeiten zu können. Hierzu definieren wir einen ParameterResolver, der in der Lage ist, basierend auf dem Container eine DataSource zu erzeugen und dem Test als Parameter zur Verfügung zu stellen (Listing 11).

public class DataSourceParameterResolver implements ParameterResolver {

  @Override
  public boolean supportsParameter(ParameterContext parameterContext ,
                                   ExtensionContext extensionContext)
      throws ParameterResolutionException {
    final Class<?> type = parameterContext.getParameter().getType();
    return DataSource.class.isAssignableFrom(type);
  }

  @Override
  public Object resolveParameter(ParameterContext parameterContext ,
                                 ExtensionContext extensionContext)
      throws ParameterResolutionException {

    final JdbcDatabaseContainer c = extensionContext
        .getStore(GLOBAL)
        .get(JdbcDatabaseContainer.class.getSimpleName() , JdbcDatabaseContainer.class);

    return createDataSource(c.getJdbcUrl() , c.getUsername() , c.getPassword());
  }

  private static DataSource createDataSource(
         String jdbcURL , 
         String username , 
         String password) {
         
    HikariConfig hikariConfig = new HikariConfig();
    hikariConfig.setJdbcUrl(jdbcURL);
    hikariConfig.setUsername(username);
    hikariConfig.setPassword(password);
    return new HikariDataSource(hikariConfig);
  }
}

Auch hier bedienen wir uns der Quelltextfragmente, die wir zuvor geschrieben haben. Anschließend können wir unseren Test mit den beiden Extensions annotieren, um sie zu aktivieren (Listing 12).

@ExtendWith(PersistenceExtension.class)
@ExtendWith(DataSourceParameterResolver.class)
public class LoginServiceTest {
  @Test
  void test001(DataSource dataSource) {
    final boolean checkLogin = new LoginService(dataSource)
        .checkLogin("admin" , "admin");
    Assertions.assertTrue(checkLogin);
  }

  @Test
  void test002(DataSource dataSource) {
    final boolean checkLogin = new LoginService(dataSource)
        .checkLogin("admin" , "XXXX");
    Assertions.assertFalse(checkLogin);
  }
}

Es würde den Umfang dieses Artikels sprengen, hier auf die Funktionsweise von JUnit 5 einzugehen. Allerdings kann man schon jetzt sehr deutlich sehen, dass mit sehr geringem Aufwand eine Lösung produziert werden kann, die unabhängig von Buildsystemen wie Maven oder Gradle das kompakte Schreiben von Tests mit involvierter Infrastruktur ermöglicht. Aber setzen wir noch eins drauf.

Der Servlet-Container

Als Nächstes integrieren wir den Servlet-Container Apache Meecrowave. Um den Servlet-Container zu starten, benötigen wir nur ein paar Zeilen Quelltext (Listing 13) und die dazugehörige Abhängigkeit in der pom.xml (Listing 14).

<dependency>
  <groupId>org.apache.meecrowave</groupId>
  <artifactId>meecrowave-core</artifactId>
  <version>${meecrowave.version}</version>
  <scope>compile</scope>
</dependency>
public class BasicTestUIRunner {
  private BasicTestUIRunner() {
  }

  public static DataSource datasource;
  public static Meecrowave meecrowave;

  public static void main(String[] args) {
    if (args.length != 3) throw new RuntimeException(" arguments needed...");
    start(args[0] , args[1] , args[2]);
  }

  public static void start(String jdbcURL , 
                           String username , 
                           String password) {

    datasource = createDataSource(jdbcURL , 
                                  username , 
                                  password);
    
    meecrowave = new Meecrowave(new Meecrowave.Builder() {
      {
//        randomHttpPort();
        setHttpPort(8080);
        setHost(localeIP().get());
        setTomcatScanning(true);
        setTomcatAutoSetup(true);
        setHttp2(true);
      }
    });
    meecrowave
        .bake();
  }

  public static void stop() {
    meecrowave.close();
  }

  private static DataSource createDataSource(String jdbcURL , 
                                             String username , 
                                             String password) {
    HikariConfig hikariConfig = new HikariConfig();
    hikariConfig.setJdbcUrl(jdbcURL);
    hikariConfig.setUsername(username);
    hikariConfig.setPassword(password);
    return new HikariDataSource(hikariConfig);
  }
}

Innerhalb des Startvorgangs wird hier auch die DataSource erzeugt und als statisches Attribut vorgehalten. Das ist ein Tribut an die Einfachheit dieser Demo. In produktiven Quelltexten wird man sicherlich auch diese Funktionen trennen. Wichtig ist auf jeden Fall: Das RDBMS muss verfügbar sein, bevor der Servlet-Container gestartet werden kann. Das wird hier mit Absicht gemacht, um eine Reihenfolge zu erzwingen, die wir nachfolgend in den Tests berücksichtigen müssen.

Ziel ist es nun, den Startvorgang so zu verändern, dass zuerst das RDBMS aktiviert wird und nachfolgend der Servlet-Container. Auch hier kommen wieder Extensions um Einsatz (Listing 15).

public class ServletContainerExtension
    implements
    BeforeEachCallback,
    AfterEachCallback {

  @Override
  public void beforeEach(ExtensionContext extensionContext) throws Exception {
    final JdbcDatabaseContainer container = extensionContext
        .getStore(ExtensionContext.Namespace.GLOBAL)
        .get(JdbcDatabaseContainer.class.getSimpleName() , JdbcDatabaseContainer.class);

    BasicTestUIRunner.start(
        container.getJdbcUrl() ,
        container.getUsername() ,
        container.getPassword()
    );
  }

  @Override
  public void afterEach(ExtensionContext extensionContext) throws Exception {
    BasicTestUIRunner.stop();
  }
}

Vaadin und Selenium

Als Web-Framework kommt Vaadin zum Einsatz und soll innerhalb eines Apache Meecrowave gestartet werden. Die Abhängigkeiten für Vaadin Flow kommen wieder in die pom.xml (Listing 16).

<dependencyManagement>
  <dependencies>
    <!--Vaadin -->
    <dependency>
      <groupId>com.vaadin</groupId>
      <artifactId>vaadin-bom</artifactId>
      <version>${vaadin.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <!--Vaadin -->
  <dependency>
    <groupId>com.vaadin</groupId>
    <artifactId>vaadin</artifactId>
  </dependency>
  <dependency>
    <groupId>com.vaadin</groupId>
    <artifactId>vaadin-lumo-theme</artifactId>
  </dependency>
</dependencies>

Die Oberfläche ist schnell geschrieben und verwendet den LoginService (Listing 17).

@Route(NAV_LOGIN_VIEW)
public class LoginViewOO extends Composite {

  public static final String NAV_LOGIN_VIEW = "login";
  public static final String USERNAME = "username";
  public static final String PASSWORD = "password";
  public static final String BTN_LOGIN = "btnLogin";
  public static final String BTN_CANCEL = "btnCancel";

  private final TextField username = new TextField();
  private final PasswordField password = new PasswordField();
  private final Button btnLogin = new Button();
  private final Button btnCancel = new Button();

  private final LoginService loginService = new LoginService(BasicTestUIRunner.datasource);

  public LoginViewOO() {
    VerticalLayout groupV = new VerticalLayout(
      new HorizontalLayout(username , password) ,
      new HorizontalLayout(btnLogin , btnCancel));

    groupV.setDefaultHorizontalComponentAlignment(START);
    groupV.setSizeUndefined();

    username.setPlaceholder("username");
    username.setId(USERNAME);
    password.setPlaceholder("password");
    password.setId(PASSWORD);

    btnLogin.setText("Login");
    btnLogin.setId(BTN_LOGIN);
    btnLogin.addClickListener(e -> {
      boolean ok = loginService
        .checkLogin(username.getValue() ,
                    password.getValue());
      username.setValue("");
      password.setValue("");
      UI
        .getCurrent()
        .navigate((ok)
                  ? MainView.class
                  : LoginViewOO.class);
    });

    btnCancel.setText("Cancel");
    btnCancel.setId(BTN_CANCEL);

    HorizontalLayout content = getContent();
    content.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
    content.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
    content.setSizeFull();
    content.add(groupV);
  }
}

Die Herausforderung besteht jetzt darin, einen JUnit-Test zu schreiben, der den gesamten Stack testet. Die Anwendung selbst kann schon im Test gestartet werden. Allerdings benötigen wir nun ein Element, das für den Test notwendig ist: Selenium, ein Werkzeug, mit dem man Webanwendungen testen kann und das wie ein ferngesteuerter Browser funktioniert. Hier kommt wieder Testcontainers zum Einsatz, da es für diesen Einsatzzweck vorgefertigte Container zur Verfügung stellt.

Selenium und JUnit 5

Auch hier arbeiten wieder wir mit einer Extension, um den passenden Container zu erzeugen (Listing 18).

public class WebdriverExtension implements
    BeforeEachCallback,
    AfterEachCallback {

  @Override
  public void beforeEach(ExtensionContext extensionContext) throws Exception {
    
    final BrowserWebDriverContainer container = new BrowserWebDriverContainer()
        .withCapabilities(new ChromeOptions())
        .withRecordingMode(RECORD_ALL , new File("./target/"));

    extensionContext
        .getStore(GLOBAL)
        .put(BrowserWebDriverContainer.class.getSimpleName() , container);
  }

  @Override
  public void afterEach(ExtensionContext extensionContext) throws Exception {

    final BrowserWebDriverContainer container = extensionContext
        .getStore(GLOBAL)
        .get(BrowserWebDriverContainer.class.getSimpleName() , BrowserWebDriverContainer.class);

    final String uniqueId = extensionContext.getUniqueId();
    final String name = extensionContext.getRequiredTestMethod().getName();

    container.afterTest(new TestDescription() {
      @Override
      public String getTestId() { return uniqueId; }

      @Override
      public String getFilesystemFriendlyName() { return name; }
    } , Optional.empty());

    container
        .stop();
  }
}

Wichtig an dieser Stelle ist, dass der WebDriver innerhalb des Tests zur Verfügung stehen muss, damit der Test ihn verwenden kann. Das wird wieder mit einem ParameterResolver realisiert. Hier nur ein Auszug aus den Quelltexten, da doch einige Teile recht repetitiv sind (Listing 19).

@Override
public Object resolveParameter(ParameterContext parameterContext ,
                               ExtensionContext extensionContext)
    throws ParameterResolutionException {

  final BrowserWebDriverContainer container = extensionContext
      .getStore(GLOBAL)
      .get(BrowserWebDriverContainer.class.getSimpleName() , BrowserWebDriverContainer.class);

  final String property = System.getProperty("os.name");
  final boolean osx = property.contains("Mac OS X");

  return new WebDriverInfo(container.getWebDriver() ,
                           (osx) ? "host.docker.internal" : localeIP().get(),
                           container.getVncAddress());
}

Man beachte an dieser Stelle, dass hier ein Info-Objekt zurückgeliefert wird, also nicht nur der WebDriver selbst. Eine Docker-spezifische Angelegenheit ist die IP, mit der man von einem Container aus wieder auf den Host zugreifen kann. Unter macOS muss man den Pseudohost host.docker.internal verwenden. Die Adresse eines VNC-Hosts wird zusätzlich mitgeliefert. Wenn man sich während des Tests mit der Adresse verbindet, kann man den Test verfolgen.

Der Fullstack-Test

Um nun abschließend einen Fullstack-Test zu erzeugen, benötigen wir noch den Test an sich. In diesem Beispiel verwende ich Testbench von Vaadin, das ein erweitertes Modellmapping von Selenium auf Vaadin-Komponenten bietet. Die Dependency für die pom.xml ist in Listing 20 dargestellt.

<dependency>
  <groupId>com.vaadin</groupId>
  <artifactId>vaadin-testbench</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>org.seleniumhq.selenium</groupId>
      <artifactId>selenium-server</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Die Testklasse für die Tests der Web-App wird dann mit den folgenden Extensions annotiert:

@ExtendWith(PersistenceExtension.class)
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(WebdriverExtension.class)
@ExtendWith(WebDriverParameterResolver.class)

Die Reihenfolge ist hier von Bedeutung. Die Extensions werden genau in dieser Reihenfolge abgearbeitet. Also beim Aufbau der Tests in der Reihenfolge:

@ExtendWith(PersistenceExtension.class)
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(WebdriverExtension.class)

Und nach dem Test in der Reihenfolge:

@ExtendWith(WebdriverExtension.class)
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(PersistenceExtension.class)

Die Parameter-Resolver habe ich hier außen vor gelassen, da sie nur zu einem bestimmten Zeitpunkt etwas liefern müssen und keinen Status halten bzw. verwalten.

Damit haben wir nun die vorerst letzten Extensions geschrieben, um den Technologiestack vollständig testen zu können. Der gesamte Test, der einmal einen Log-in-Vorgang durchführt, sieht dann aus wie in Listing 21.

@ExtendWith(PersistenceExtension.class)
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(WebdriverExtension.class)
@ExtendWith(WebDriverParameterResolver.class)
public class VaadinAppTest {

  //notwendig, da Testbench noch nicht für JUnit 5 ausgelegt ist - hack
  private final TestBenchTestCase testCase = new TestBenchTestCase() {};

  @Test
  void test001(WebDriverInfo webDriverInfo) {
    System.out.println("webDriverInfo = " + webDriverInfo.getVncAdress());

    testCase.setDriver(webDriverInfo.getWebdriver());
    webDriverInfo.getWebdriver()
                 .get("http://" + webDriverInfo.getHostIpAddress() + ":7777/");

    testCase.$(TextFieldElement.class).id(LoginViewOO.USERNAME).setValue("admin");
    testCase.$(PasswordFieldElement.class).id(LoginViewOO.PASSWORD).setValue("admin");
    testCase.$(ButtonElement.class).id(LoginViewOO.BTN_LOGIN).click();

    final String text = testCase.$(SpanElement.class).id(MainView.SPAN_ID).getText();
    Assertions.assertEquals(MainView.TEXT , text);
  }
}

Fazit

Wir haben gesehen, dass man mittels Testcontainers und JUnit 5 sehr schnell und recht einfach komplexe Systeme aufbauen kann. Am Beispiel eines Fullstack-Tests einer Webanwendung können wir sehen, dass der Zeitaufwand (Laufzeit) für einen Test, der die gesamte Infrastruktur erzeugt und initialisiert, sich im unteren Sekundenbereich befindet. Hat man das so weit unter Kontrolle, kann man beginnen, die einzelnen Komponenten auszulagern und weiter zu optimieren. Zum Beispiel kann es ausreichend sein, dass etwa der Persistence Layer nur einmalig pro Klasse oder eventuell sogar für den gesamten Testlauf neu erzeugt wird. Ein wichtiges Kriterium, das ich hervorheben möchte, ist, dass diese Lösung vollständig unabhängig vom Build-System ist.

Selbstverständlich befindet sich das Projekt auf GitHub und steht zum Nachlesen bereit. Wer Fragen oder Anregungen hat, kann sich gerne über Twitter oder per E-Mail an mich wenden.

Happy Coding!

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: