Kolumne: Backend meets Frontend: Screenshots mit Selenium und jUnit5

UI-Tests mit Selenium und jUnit5

Sven Ruppert

© Shutterstock / HelenField

Selenium und jUnit4 ist ein solides Test-Duo und arbeitet auch in der Beispielwendung der Kolumne „Backend meets Frontend“ zuverlässig. Aber wie sieht es mit der Weiterentwicklung jUnit5 aus?

In unserem letzten Teil der Serie haben wir uns damit auseinandergesetzt, wie wir einen Screenshot in einem Fehlerfall erzeugen, wenn wir jUnit4 verwenden. Das hatte einige Tücken. Wie sieht es nun mit jUnit5 aus?

jUnit5 ist noch nicht final fertig. Aber dennoch ist es stabil genug, damit wir schon jetzt einen Blick riskieren können. Ich persönlich bin der Meinung, dass schon mit der Verwendung gestartet werden kann und sollte. Haben wir doch in dem einen oder anderen Projekt enorme Quelltextmengen basierend auf jUnit4.

Ein Lichtblick am Horizont: jUnit5

Wie sieht es nun mit jUnit5 aus? Damit wir beginnen können müssen wir dem Projekt die Abhängigkeiten zu jUnit5 hinzufügen. Die Abhängigkeit zu jUnit4 wird aber nicht entfernt!

 <properties>
    <!-- ... SNIPP -->
    <!--TDD jUnit5-->
    <junit.version>4.12</junit.version>
    <junit.jupiter.version>5.0.0-M4</junit.jupiter.version>
    <junit.vintage.version>${junit.version}.0-M4</junit.vintage.version>
    <junit.platform.version>1.0.0-M4</junit.platform.version>
  </properties>

  <dependencies>
  <!-- ... SNIPP -->
    <!--jUnit5-->
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <version>${junit.jupiter.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

Zusätzlich benötigen wir nun noch die Kombination der Abhängigkeiten und dem Surefire-Plug-in.

 <!--TDD-->
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.20</version>
        <configuration>
          <includes>
            <include>**/*Test.java</include>

          </includes>
          <properties>
            <!-- <includeTags>fast</includeTags> -->
            <excludeTags>slow</excludeTags>
          </properties>
        </configuration>
        <dependencies>
          <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-surefire-provider</artifactId>
            <version>${junit.platform.version}</version>
          </dependency>
          <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
          </dependency>
          <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>${junit.vintage.version}</version>
          </dependency>
        </dependencies>
      </plugin>

Nachdem wir das Projekt nun vorbereitet haben, schreiben wir unseren ersten jUnit5-Test. Auch hier werden die Tests mit der Annotation @Test als Test markiert. Nur muss man aufpassen, dass man bei gemischten Projekten den richtigen Import verwendet: org.junit.jupiter.api.Test;. Unser erster Test ist einfach. Wir erzeugen dafür eine Klasse mit dem Namen MyUI_JUnit5_Test. Die Schreibweise des Klassennamens ist hier wegen der Lesbarkeit mit Unterstrichen versehen.

public class MyUI_JUnit5_Test {
  @Test
  @DisplayName("My 1st Vaadin JUnit 5 test! 😎")
  void test001(TestInfo testInfo) {
    Assertions.assertTrue(true);
  }
}

Neu ist hier auf jeden Fall die Annotation @DisplayName(..), mit der dem Test ein logischer menschenlesbarer Name vergeben wird. In diesem Test lediglich, dass True == True ist. Im Gegensatz zu jUnit4 sind die Asserts in der Klasse Assertions. Man beachte das s am Ende. Nun kopieren wir aus unserem ursprünglichem Test den Quelltext und kopieren diesen in unsere Testmethode. Jetzt fehlen natürlich die definierten Methoden, Funktionen und Instanzen, die wir für den Umgang mit dem WebDriver und den Elementen erstellt haben. Sicherlich könnte man nun beginnen alles zu kopieren. Das werden wir jedoch nicht machen. Die benötigten Quelltextteile sind unabhängig von dem verwendeten Test-Framework. Demnach macht eine Extraktion auf jeden Fall Sinn.

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.

jUnit5 Lebenszyklus kurz erklärt

Wir könnten lediglich die Annotationen wechseln:

  • (org.junit) @Test zu (org.junit.jupiter.api) @Test
  • @BeforeClass/@AfterClass zu @BeforeAll/@AfterAll
  • @Before/@After zu @BeforeEach/@AfterEach
  • @Ignore zu @Disabled

Dann hat man mehr oder weniger einen jUnit5-Test. Allerdings muss man sicherlich noch die statischen Anteile von @Before-/@After-Class anpassen. Hier zeigt sich ein weiteres Mal, dass
es sich lohnt die eigenen Quelltextanteile so unabhängig wie möglich von dem verwendeten Framework zu schreiben. Wenn wir das machen, verhält sich der Test so wie man es von jUnit4 gewohnt ist.

Wie aber sieht der Lebenszyklus von jUnit5 aus? Dieser ist ähnlich dem von jUnit4, nur dass er ausschließlich über Annotationen definiert wird. Statische Methoden und ähnliches sind nicht mehr notwendig. In jUnit5 hat sich viel verändert und verbessert. Auf all dieses Neuerungen werde ich hier nicht eingehen, sondern werde mich lediglich auf die hier benötigten Dinge beschränken.

Viel Wert wurde auf die Erweiterbarkeit gelegt, sodass es nicht nur die einzelnen Phasen im Ablauf gibt, sondern auch explizit Erweiterungspunkte.

Selenium, Screenshot und jUnit5

Beginnen wir damit den Start und Stop des Servlet-Containers sicherzustellen. Der Servlet-Container soll vor einem Test gestartet und nach dem Durchlauf gestoppt werden. Hierbei soll es egal sein, ob der Test erfolgreich durchlaufen wurde oder nicht. Dieses erreichen wir, indem wir eine Extension schreiben. Das ist einfacher als es sich anhört. Hierfür muss man lediglich eine Klasse die Callback-Interfaces implementieren, die für diese Aufgabe wichtig sind. In diesem Fall sind es die beiden Punkte BeforeEachCallback und AfterEachCallback. In unserem Fall ist es einfach, da wir lediglich die passende Methode aufrufen müssen. Damit sind diese Aufrufe allerdings auch vollständig aus der Vererbung der Tests entfernt.

public class ServletContainerExtension implements BeforeEachCallback, AfterEachCallback {


  @Override
  public void beforeEach(TestExtensionContext context) throws Exception {
    System.out.println("ServletContainerExtension.beforeEach ");
    Main.start();
  }


  @Override
  public void afterEach(TestExtensionContext context) throws Exception {
    System.out.println("ServletContainerExtension.afterEach ");
    Main.shutdown();
  }

}

Um diese Extension zu aktivieren, müssen wir unseren Test lediglich auf Klassenebene oder, wenn es nur für eine Testmethode gelten soll, auf Methodenebene mit der Annotation @ExtendWith(ServletContainerExtension.class) deklarieren. Gehen wir nun einen Schritt weiter und sehen uns an, wie wir mit dem WebDriver verfahren wollen. Hier gehen wir einen kleinen Umweg. Während wir unsere ersten Tests geschrieben haben, mussten wir auf die Elemente der jeweiligen Seite zugreifen, z. B. den Button oder das jeweilige Textfeld. Adressiert wurden diese Elemente mittels ihrer ID. Benötigt wurde hierfür immer der WebDriver. Definieren wir uns dazu ein Interface mit dem Namen HasWebDriver und spendieren diesem FunctionalInterface folgende Methodensignatur:

@FunctionalInterface
public interface HasWebDriver {
  WebDriver getWebDriver();
}

Da wir eine Vaadin-Web-App schreiben, wissen wir, dass wir bestimmte Dinge wie den URL der Web-App immer wieder benötigen, um mittels WebDriver eine gewünschte Seite zu laden. Die generischen und zustandslosen Anteile dieser Servicefunktionen lagern wir in ein Interface mit dem Namen VaadinPageObject aus.

public interface VaadinPageObject extends HasWebDriver {

  String DEFAULT_PROTOCOL = "http";
  String DEFAULT_IP = "127.0.0.1";
  String DEFAULT_PORT = "8080";
  String DEFAULT_WEBAPP = "";

  String KEY_VAADIN_SERVER_PROTOCOL = "vaadin.server.protocol";
  String KEY_VAADIN_SERVER_IP = "vaadin.server.ip";
  String KEY_VAADIN_SERVER_PORT = "vaadin.server.port";
  String KEY_VAADIN_SERVER_WEBAPP = "vaadin.server.webapp";


  default BiFunction<String, String, String> property() {
    return (key, defaultValue) -> (String) getProperties().getOrDefault(key, defaultValue);
  }

  default Supplier<String> protocol() {
    return () -> property().apply(KEY_VAADIN_SERVER_PROTOCOL, DEFAULT_PROTOCOL);
  }

  default Supplier<String> ip() {
    return () -> property().apply(KEY_VAADIN_SERVER_IP, DEFAULT_IP);
  }

  default Supplier<String> port() {
    return () -> property().apply(KEY_VAADIN_SERVER_PORT, DEFAULT_PORT);
  }

  default Supplier<String> webapp() {
    return () -> property().apply(KEY_VAADIN_SERVER_WEBAPP, DEFAULT_WEBAPP);
  }


  default Supplier<String> baseURL() {
    return () -> protocol().get() + "://" + ip().get() + ":" + port().get() + "/";
  }

  default Supplier<String> url() {
    return () -> baseURL().get() + webapp().get();
  }

  default Supplier<String> urlRestartApp() {
    return () -> url().get() + "?restartApplication";
  }
}

Hier habe ich mich für den Weg entschieden, dass bestimmte Werte wie die IP oder der Port als System Property zur Verfügung stehen. Hier kann man natürlich auch eine andere Technik verwenden. Das ist allerdings nicht Gegenstand dieses Teils der Serie. Wir werden uns anderen Optionen zu einem späteren Zeitpunkt zuwenden.

Kommen wir nun zu dem Teil, in dem wir die Instanz der Klasse WebDriver halten müssen. Hier haben wir erstmals eine Klasse, die das Interface VaadinPageObject implementiert. Damit man diese nicht aus Versehen erzeugt, wurde sie abstract gehalten.

public abstract class AbstractVaadinPageObject implements VaadinPageObject {

  private WebDriver webDriver;

  public AbstractVaadinPageObject(WebDriver webDriver) {
    this.webDriver = webDriver;
  }

  @Override
  public WebDriver getWebDriver() {
    return webDriver;
  }


  public void switchToDebugMode() {
    webDriver.get(url().get() + "?debug&restartApplication");
  }

  public void restartApplication() {
    webDriver.get(urlRestartApp().get());
  }

  public void loadPage() {
    webDriver.get(url().get());
  }

  public Function<String, WebElement> element
      = (id) -> elementFor()
      .apply(webDriver, id)
      .orElseThrow(() -> new RuntimeException("WebElement with the ID " + id + " is not available"));
}

Hierbei handelt es sich immer noch um eine generische Klasse. Das kann man auch unschwer an den Methoden selbst erkennen. Um jetzt genau die Elemente zu adressieren, die wir in unserem Test basierend auf dem UI (MyUI) definiert haben, müssen wir von der Klasse AbstractVaadinPageObject erben. In unserem Fall nennen wir die Klasse MyUIPageObject.

public class MyUIPageObject extends AbstractVaadinPageObject {

  public Supplier<WebElement> button = () -> element.apply(BUTTON_ID);
  public Supplier<WebElement> output = () -> element.apply(OUTPUT_ID);
  public Supplier<WebElement> inputA = () -> element.apply(INPUT_ID_A);
  public Supplier<WebElement> inputB = () -> element.apply(INPUT_ID_B);

  public MyUIPageObject(WebDriver webDriver) {
    super(webDriver);
  }
}

Hier sind nur noch die fachlich relevanten Elemente vorhanden, technische Aspekte wurden fast
vollständig ausgeblendet – wenn wir von dem Konstruktor einmal absehen. Mit diesem PageObject kann man nun elegant Tests schreiben.

  private MyUIPageObject pageObject;

  @Test
  @DisplayName("My 1st Vaadin JUnit 5 test! 😎")
  void test001(TestInfo testInfo) {

    pageObject.loadPage();

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

    final WebElement btn = pageObject.button.get();
    btn.click();
    String value = pageObject.output.get().getAttribute("value");
    Assertions.assertEquals("10", value);
  }

Innerhalb des Tests verwenden wir die Instanz mit dem Namen pageObject. Nun stellt sich die Frage, wie wir an diese Instanz gelangen und wie das Management der Instanz unseres WebDrivers ist. Um an die Instanz zu gelangen, verwenden wir den mitgelieferten Injection-Mechanismus.

  @BeforeEach
  void init(@Selenium WebDriver webDriver) {
    System.out.println("MyUITest.init.webDriver = " + webDriver);
    this.pageObject = new MyUIPageObject(webDriver);
  }

Hier soll also vor jeden Aufruf einer Testmethode die Methode init durchlaufen werden. Diese wiederum benötigt eine Instanz der Klasse WebDriver. Den Parameter haben wir mit der Annotation @Selenium markiert. Die Annotation @Selenium haben wir selbst erstellt. Wichtig hierbei ist, das diese zur Laufzeit ausgewertet werden kann.

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

Nun fehlt nur noch der Erzeugungsmechanismus und der richtige Zeitpunkt. Sehen wir uns erst die Erzeugung an. Hierbei handelt es sich um die Implementierung des Interface ParameterResolver. Das Vorgehen ist recht einfach. Es gibt eine Methode, die feststellt, ob dieser Resolver für den gerade verarbeiteten Parameter einer Methode zuständig ist. Hier wird schlicht und ergreifend die Annotation, in unserem Fall @Selenium, mittels Reflection ausgewertet.

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

Ist der ParameterResolver nun zuständig, wird die Methode ausgeführt, die für das Erzeugen der Instanz vorgesehen ist. Ob dabei eine Instanz jedesmal neu erzeugt wird oder ob man auf eine bestehende zurückgreift, muss der Entwickler selbst entscheiden. Für den Fall, dass man eine Instanz mehrfach verwenden möchte, gibt es in jUnit5 einen Store. Hierbei handelt es sich um einen Key/Value Store. Es gibt zum einen den Default-Store, zum anderen kann man aber auch eigene Namespaces definieren. Selbiges machen wir in unserem Beispiel. Es wird für jede Methode ein eigener Namespace erzeugt. Dabei ist zu beachten, dass man den Store von nicht mehr benötigten Elementen befreien sollte.

  static Function<ExtensionContext, Namespace> namespaceFor() {
    return (ctx) -> Namespace.create(WebDriverSeleniumExtension.class,
                                     ctx.getTestClass().get().getName(),
                                     ctx.getTestMethod().get().getName());
  }

@Override
  public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
    System.out.println("WebDriverSeleniumExtension.resolve ");
    final Namespace namespace = namespaceFor().apply(extensionContext);
    final ExtensionContext.Store store = extensionContext.getStore(namespace);
    return store.getOrComputeIfAbsent("webdriver", key -> newWebDriver().get().get());
  }

Wir speichern also die Instanz der Klasse WebDriver in diesem Store. Um nun einen Screenshot zu erstellen, wenn der Test fehlgeschlagen ist, werten wir den Kontext aus, der uns nach dem Testdurchlauf mittels AfterTestExecutionCallback zur Verfügung gestellt wird. Das Vorgehen ist recht einfach: Hole den WebDriver aus dem Store und hole aus dem TestExtensionContext die Information, ob eine Exception geworfen worden ist. Wenn dem so ist, erstelle einen Screenshot. Entferne die Instanz WebDriver auf jeden Fall aus dem Store und beende diese.

  @Override
  public void afterTestExecution(TestExtensionContext context) throws Exception {
    System.out.println("WebDriverSeleniumExtension.afterTestExecution ");

    final WebDriver webdriver = context
        .getStore(namespaceFor().apply(context))
        .get("webdriver", WebDriver.class);

    context.getTestException()
           .ifPresent(e -> takeScreenShot().accept(webdriver));

    webdriver.close();
    webdriver.quit();

    context
        .getStore(namespaceFor().apply(context))
        .remove("webdriver");

  }

Alles zusammen ergibt nun unsere WebDriverSeleniumExtension.

public class WebDriverSeleniumExtension implements ParameterResolver, AfterTestExecutionCallback {

  static Function<ExtensionContext, Namespace> namespaceFor() {
    return (ctx) -> Namespace.create(WebDriverSeleniumExtension.class,
                                     ctx.getTestClass().get().getName(),
                                     ctx.getTestMethod().get().getName());
  }

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

  @Override
  public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext)
      throws ParameterResolutionException {
    System.out.println("WebDriverSeleniumExtension.resolve ");
    final Namespace namespace = namespaceFor().apply(extensionContext);
    final ExtensionContext.Store store = extensionContext.getStore(namespace);
    return store.getOrComputeIfAbsent("webdriver", key -> newWebDriver().get().get());
  }

  @Override
  public void afterTestExecution(TestExtensionContext context) throws Exception {
    System.out.println("WebDriverSeleniumExtension.afterTestExecution ");

    final WebDriver webdriver = context
        .getStore(namespaceFor().apply(context))
        .get("webdriver", WebDriver.class);

    context.getTestException()
           .ifPresent(e -> takeScreenShot().accept(webdriver));

    webdriver.close();
    webdriver.quit();

    context
        .getStore(namespaceFor().apply(context))
        .remove("webdriver");
  }

}

Nun sollen beide Extensions für unseren Test aktiviert werden. Wichtig zu wissen ist an dieser Stelle, dass die Reihenfolge, in der die Annotationen im Quelltext verwendet werden, dann auch der Reihenfolge der Abarbeitung entsprechen.

//Order is importand top / down
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(WebDriverSeleniumExtension.class)
public class MyUITest {


  @BeforeEach
  void init(@Selenium WebDriver webDriver) {
    System.out.println("MyUITest.init.webDriver = " + webDriver);
    this.pageObject = new MyUIPageObject(webDriver);
  }

  private MyUIPageObject pageObject;

  @Test
  @DisplayName("My 1st Vaadin JUnit 5 test! 😎")
  void test001(TestInfo testInfo) {

    pageObject.loadPage();

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

    final WebElement btn = pageObject.button.get();
    btn.click();
    String value = pageObject.output.get().getAttribute("value");
    Assertions.assertEquals("10", value);
  }
}

Fazit

Wir haben uns in diesem Teil mit den Grundzügen von jUnit5 beschäftigt und sind nun in der Lage
zu bestimmten Zeitpunkten auf die Ausführung der Tests zu reagieren. In unserem Fall erstellen wir einen Screenshot ausschließlich im Fehlerfall. Das Management der Ressourcen, wie der Servlet-Container und der WebDriver, sind generisch gelöst. Die Tests selbst sind demnach fast vollständig frei von diesen Informationen. Ebenfalls haben wir uns kurz mit dem PageObject-Pattern beschäftigt. Aber dazu wird es in den nächsten Teilen noch mehr geben.

Den Quelltext findet ihr auf GitHub. 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
  1. Christian Stein2017-08-09 09:55:06

    Geschickte Verwendung des "Context" und "Resolver" Konzepts von JUnit 5, Sven! :)

    Paul Hammant hat hier etwas änhliches gemacht: https://github.com/paul-hammant/JUnit5_DependencyInjection_WebDriver

    Cheers

Schreibe einen Kommentar

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