Suche
Teil 10: Backend meets Frontend: Vaadin, Testbench and TestContainers

Testen mit Docker und jUnit5

Sven Ruppert

© Shutterstock / HelenField

Docker ist auch ein wunderbares Tool für Testumgebungen. Aber das ist nicht immer trivial. Das Open-Source-Projekt TestContainers setzt genau hier an und hilft Java-Entwicklern dabei Docker mit jUnit zu kombinieren. „Backend meets Frontend“ wirft einen Blick auf das Tool und seine Möglichkeiten.

Im letzten Teil haben wir uns angesehen, wie wir mit Docker einen Selenium Hub lokal erstellen können, um diesen dann für die Selenium- und Testbench-Tests zu verwenden. In diesem Teil werden wir uns das Open Source Tool TestContainers ansehen, das uns helfen wird, Selenium- und Testbench-Tests ablaufen zu lassen. Bei TestContainers handelt es sich um eine Serviceschicht um Docker herum, die es Java-Entwicklern einfach macht, Docker und jUnit zusammen zu verwenden. Wir werden uns speziell den Support für Selenium ansehen.

Denn die Verwendung von Docker hat auf der einen Seite Vorteile, da es sich um einen Para-Virtualisierer handelt. Unter OS X und Linux ist die Integration sehr gut; unter Windows wird es langsam immer besser. Auf der anderen Seite ist die Handhabung aber nicht immer ganz so komfortabel. Und genau hier kommt TestContainers ins Spiel. Das Projekt findet man auf GitHub.

Backend meets Frontend

In der Artikelserie Backend meets Frontend stellt Sven Ruppert (Vaadin) Konzepte und Technologien rund um das UI-Framework Vaadin vor. Sein Fokus liegt dabei auf modernem Web-Design für Java-Backend-Entwickler.

Zum ersten Teil und damit dem Start der Tutorien rund um die UI-Entwicklung mit Java geht es hier entlang. Alle Teile der Serie Backend meets Frontend finden sich hier.

Im letzten Teil haben wir einen Selenium Hub erzeugt, der aus mehreren Knoten bestand. Kernelement ist der Hub an sich, der als zentraler Einstiegspunkt dient. Dieser kennt alle seine Kindknoten und seine Spezifikationen: Im Wesentlichen, welches Betriebssystem mit welchen Browser zusammen zum Einsatz kommt. Wir haben uns die Linux-basierten Docker Images angesehen. Bei TestContainers ist es etwas anders. Hier wir mit der Stand-alone-Variante gearbeitet. Es wird demnach kein zentraler Hub gestartet, sondern immer nur der Knoten, der zum Einsatz kommen soll. Wenn wir also einen Google Chrome unter Linux testen möchten, wird genau dieser Arbeitsknoten für Selenium gestartet und alle Requests werden von dort aus erzeugt. Deswegen werden sich einige Dinge ändern, zum Beispiel die Initialisierung der WebDriver. Aber beginnen wir am Anfang.

TestContainers: Hello World

Als erstes benötigen wir die Definition der Abhängigkeiten in unserem Projekt. Dazu modifizieren wir die pom.xml wie folgt:

    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>selenium</artifactId>
      <version>1.3.0</version>
      <scope>test</scope>
    </dependency>
    
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>1.7.25</version>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>1.7.25</version>
    </dependency>    

Wir fügen zum einen das Projekt TestContainers hinzu, besser gesagt das Modul Selenium des Projekts. Zum anderen benötigen wir slf4j, um uns die Ausgaben der Container anzusehen. Wir beginnen mit einem einfachen Test, indem wir dort alle benötigten Komponenten initialisieren, verwenden und wieder freigeben.

Zu Beginn starten wir den Servlet-Container wie gewohnt mittels @BeforeEach. Nachdem der Test durchgelaufen ist, wird er wieder mittels @AfterEach gestoppt. Hier passiert also nichts spannendes Neues.

  @BeforeEach
  void setUp() {
    Main.start();
  }

  @AfterEach
  void tearDown() {
    Main.shutdown();
  }

Als nächstes werden wir uns um den Selenium-Knoten kümmern. Hierzu müssen wir eine Instanz der Klasse BrowserWebDriverContainer erzeugen. Die Konfiguration des Containers selbst erfolgt an der Instanz und lässt sich dank Fluent-API recht kompakt schreiben. Als einfachste Version beginnen wir mit dem folgenden Listing:

    BrowserWebDriverContainer webDriverContainer
        = new BrowserWebDriverContainer()
        .withDesiredCapabilities(DesiredCapabilities.chrome())

Zu beachten ist, dass es in dieser Form nur die Ausprägungen für Google Chrome und Firefox gibt.

Nachdem man die Instanz des Containers erzeugt hat, kann man zu einem späteren Zeitpunkt mit dem Startprozess beginnen. Hierfür ruft man die Methode start() auf. Damit man den Verlauf beim Start des Containers auf der Konsole verfolgen kann, muss man noch einen LogConsumer zur Verfügung stellen. In diesem Projekt verwende ich slf4j mit dem simple-Logger, der alles auf STDOUT schreibt. Wichtig zu wissen ist, das der LogConsumer dem Container erst nach dem Start hinzugefügt werden kann. Ansonsten bekommt man in der derzeitigen Version die Fehlermeldung, dass die Container-ID (noch) nicht vorhanden ist.

    webDriverContainer.start();

    Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LoggerFactory.getLogger(this.getClass()));
    webDriverContainer.followOutput(logConsumer);

Nach dem Testdurchlauf muss der Container wieder gestoppt werden.

webDriverContainer.stop();

Da wir nun den Servlet-Container und den Selenium-Knoten haben, können wir mit dem Test selbst beginnen. Als erstes benötigen wir eine Instanz vom Typ WebDriver. Hier ist es am einfachsten, auf die Servicemethoden vom BrowserWebDriverContainer zurückzugreifen. Die Methode getWebDriver() liefert eine fertig initialisierte Instanz. Der URL inklusive der Ports wird vollständig generiert. Man selbst muss nichts mehr machen. Der WebDriver wird beim Beenden des Containers auch ordnungsgemäß geschlossen. Der Test an sich sieht dann wieder genau so aus wie bisher.

    RemoteWebDriver webDriver = webDriverContainer.getWebDriver();
    MyUIPageObject pageObject = new MyUIPageObject(webDriver);
    pageObject.loadPage();

    pageObject.inputA.get().sendKeys("5");
    pageObject.inputB.get().sendKeys("5");

    pageObject.button.get().click();
    String value = pageObject.output.get().getAttribute("value");
    Assertions.assertEquals("10", value); // will fail 😉
//    Assertions.assertEquals("55", value);

Nun haben wir unseren ersten Test, der mittels TestContainers auf einem Selenium-Knoten durchgeführt wird. Es wird also Zeit dies  ein wenig aufzuräumen und für größere Projekte vorzubereiten.

Einsatz in einem Projekt

Wir möchten uns nun damit beschäftigen, wie man die bisher angewandten Konzepte in einem Projekt einsetzen kann. Deswegen müssen wir uns wieder mit den Lebenszyklen von jUnit5 auseinandersetzen. Wir verwenden hier den Ansatz jUnit5 mit eigenen Erweiterungen (Extensions) zu erweitern. Bisher haben wir dies mit dem Starten und Stoppen unseres Servlet-Container realisiert. Zur Erinnerung hier nochmals der Quelltext:

public class ServletContainerExtension implements BeforeEachCallback, AfterEachCallback {

  @Override
  public void beforeEach(TestExtensionContext context) throws Exception {
    Main.start();
  }

  @Override
  public void afterEach(TestExtensionContext context) throws Exception {
    Main.shutdown();
  }
}

Der gesamte Zyklus auf einen Blick (Quelle: http://junit.org/junit5/docs/current/user-guide/)

Den Selenium-Knoten starten wir ebenfalls für jeden Test neu. Damit verhindern wir, dass sich Fehler aus dem vorherigen Test in dieser Ebene auf den nächsten Test auswirken können. Erzeugen wir als erstes eine Klasse mit dem Namen TestcontainersExtension und implementieren die Interfaces für die beiden Ereignisse BeforeEachCallback und AfterEachCallback.

Ziel ist es, vor jedem Testdurchlauf den Selenium-Knoten zu starten und diese Instanz im Store zu speichern. Wir haben hier indirekt auch den WebDriver erzeugt. Diese Instanz legen wie ebenfalls in den Store (zur Erinnerung zum Thema Store).

public class TestcontainersExtension
    implements BeforeEachCallback, AfterEachCallback {

  public static final String WEBDRIVER = "webdriver";
  public static final String TESTCONTAINER = "testcontainer";

  public static Function<ExtensionContext, BrowserWebDriverContainer> testcontainer() {
    return (context) -> store().apply(context).get(TESTCONTAINER, BrowserWebDriverContainer.class);
  }

  public static BiConsumer<ExtensionContext, BrowserWebDriverContainer> storeTestcontainer() {
    return (context, webDriver) -> store().apply(context).put(TESTCONTAINER, webDriver);
  }

  public static Consumer<ExtensionContext> removeTestcontainer() {
    return (context) -> store().apply(context).remove(TESTCONTAINER);
  }

  public static Function<ExtensionContext, Supplier<WebDriver>> webdriver() {
    return (context) -> store().apply(context).get(WEBDRIVER, Supplier.class);
  }

  public static BiConsumer<ExtensionContext, Supplier<WebDriver>> storeWebDriver() {
    return (context, webDriver) -> store().apply(context).put(WEBDRIVER, webDriver);
  }

  public static Consumer<ExtensionContext> removeWebDriver() {
    return (context) -> store().apply(context).remove(WEBDRIVER);
  }

  @Override
  public void beforeEach(TestExtensionContext context) throws Exception {
    BrowserWebDriverContainer webDriverContainer
        = new BrowserWebDriverContainer()
        .withDesiredCapabilities(DesiredCapabilities.chrome()); // only one per container

    webDriverContainer.start();

    Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LoggerFactory.getLogger(this.getClass()));
    webDriverContainer.followOutput(logConsumer);

    storeTestcontainer().accept(context, webDriverContainer);
    storeWebDriver().accept(context, webDriverContainer::getWebDriver);
  }

  @Override
  public void afterEach(TestExtensionContext context) throws Exception {
    testcontainer().apply(context).stop();
    removeTestcontainer().accept(context);
    removeWebDriver();
  }
}

Mit diesem Schritt haben wir die Infrastruktur final erstellt, die wir für diesen Test benötigen. Nun fehlt noch das Erzeugen des PageObject, um den Test zu formulieren. Hier verwenden wir wieder die Möglichkeit, uns einen Parameter bei dem Methodenaufruf unserer Testmethode von jUnit5 injizieren zu lassen. Dazu benötigen wir einen Marker (Annotation), um den Parameter für einen ParameterResolver erkenn- und identifizierbar zu machen. In unserem Fall ist das @PageObject.

@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface PageObject {
}

Damit können wir den Parameter unserer Testmethode markieren.

void test001(@PageObject Supplier<MyUIPageObject> pageObjectSupplier)

Ich habe mich hier explizit für einen Supplier entschieden, damit das PageObject erst bei der Durchführung der Testmethode erzeugt wird. Wo aber wird nun die Instanz unseres Parameters erzeugt? Hierfür benötigen wir eine Implementierung des Interface ParameterResolver. Dort gibt es wieder die Methode zur Identifizierung und die zum Erzeugen der Instanz selbst.

public class PageObjectExtension implements ParameterResolver, BeforeAllCallback {

  @Override
  public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
    return parameterContext.getParameter().isAnnotationPresent(PageObject.class);
  }

  @Override
  public Supplier<MyUIPageObject> resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
    return () -> new MyUIPageObject(webdriver().apply(extensionContext).get());
  }

  @Override
  public void beforeAll(ContainerExtensionContext context) throws Exception {
    System.setProperty(KEY_VAADIN_SERVER_IP, ipSupplierLocalIP.get());
  }
}

In unserem Beispiel nutze ich den ParameterResolver ebenfalls, um die eigene IP als Property zu setzen, da ein PageObject diese verwendet, um Selenium mitzuteilen, welche Adresse für einen Request zu verwenden ist. Siehe Interface VaadinPageObject in diesem Projekt. Der Test selbst kann dann wieder wie in den vorherigen Teilen geschrieben werden.

@VaadinTest
public class MyUITest {

  @DisplayName("functional style")
  @Test
  void test001(@PageObject Supplier<MyUIPageObject> pageObjectSupplier) {

    final MyUIPageObject pageObject = pageObjectSupplier.get();

    pageObject.loadPage();
    pageObject.inputA.get().setValue("5");
    pageObject.inputB.get().setValue("5");

    pageObject.button.get().click();
    String value = pageObject.output.get().getAttribute("value");
    Assertions.assertEquals("55", value);
  }
}

Fazit

Das Projekt TestContainers bietet noch viel mehr als nur Selenium-Knoten zu starten. Es lohnt auf jeden Fall ein Blick in die originalen Quellen. Wir werden uns in den nächsten Teilen auch immer mal wieder mit TestContainers beschäftigen.

Was wir nun erreicht haben, ist die Möglichkeit, programmatisch die gesamte Testinfrastruktur zu erzeugen, ohne dass wir unsere gewohnte Java-Umgebung verlassen müssen. Der Start und das Stoppen der Container ist recht schnell. Auf meinem Laptop benötigt der Start aller Komponenten aus der IDE heraus rund zehn Sekunden.

Wir haben in den letzten Teilen verschiedene Möglichkeiten kennengelernt, wie man sich dem Thema Testen von Vaadin-Anwendungen nähern kann. Was an dieser Stelle noch fehlt, ist der Umgang mit der Windows-Welt. Das wird in einem der nächsten Teile behandelt. Ebenfalls werden wir uns wesentlich intensiver mit dem Vaadin-Framework selbst auseinandersetzen.

Den Quelltext findet ihr hier. Bei Fragen und Anregungen einfach melden unter sven@vaadin.com oder per Twitter @SvenRuppert.

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

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.