Backend meets Frontend: Selenium und Screenshots mit jUnit4

Regeln für UI-Tests mit jUnit4 erstellen

Sven Ruppert

© Shutterstock / HelenField

Zu einem guten UI-Test gehören Screenshots dazu, um feststellen zu können, was der Nutzer eigentlich genau getrieben hat. Damit unzählige Tests nicht das System zumüllen, sollten Screenshots aber nur im Fehlerfall erstellt werden. Die Test-Kombination aus „Backend meets Frontend“ Vaadin und Selenium wird deswegen noch um jUnit4 und seine Rules erweitert.

Im letzten Teil der Serie haben wir uns damit auseinandergesetzt, wie man zu definierten Zeitpunkten einen Screenshot anfertigen kann, der zeigt, wie das UI aus Sicht des gerade ablaufenden Tests aussieht. Das kann hilfreich sein. Allerdings ist es oft effizienter, wenn man den Screenshot nur im Fehlerfall erstellt. Wie kann man nun genau den Zeitpunkt erfassen und was ist ein möglicher Weg auch genau dann einen Screenshot zu erstellen?

Der manuelle Weg

Abb. 1: So sieht unser UI zurzeit aus

Beim manuellen Weg werden wir einfach selbst die notwendigen Codeteile schreiben, um einen Fehlerfall abzufangen und dann einen Screenshot zu erstellen. Hierzu ist nicht viel Neues zu lernen. Es kann der normale Weg gegangen werden und ein try() catch(){} kommt zum Einsatz.

Kurz zur Wiederholung: Ziel ist es, aus zwei Eingabefeldern die jeweiligen Werte auszulesen und das Ergebnis (die Summe beider Zahlen) in das rechte Ergebnisfeld zu schreiben. Ausgelöst wird die Berechnung durch das Drücken eines Buttons. Die Implementierung des ActionListeners im Button sah wie folgt aus:

button.addClickListener(
        event -> output.setValue(inputA.getValue() + inputB.getValue()));

Und oh Wunder, wir haben hier einen Bug. Natürlich sind auch noch viele andere Dinge zu beachten. Hier aber wird davon ausgegangen, das der Benutzer fehlerfrei eine Zahl eingibt. Das ist selbstverständlich fern ab der Realität. Aber dazu kommen wir noch. Der Test, den wir geschrieben haben, schlägt fehl, da er bei der Eingabe der beiden Zahlen Fünf auch ein richtiges Ergebnis erwartet.

    String value = output.get().getAttribute("value");
    Assert.assertEquals("10", value);

Kommen wir nun zu der Aufnahme des Bildschirminhaltes. Wenn wir nun selbst feststellen wollen, dass ein Fehler stattgefunden hat, müssten wir bei der Quelltextzeile Assert.assertEquals("10", value); den Fehlschlag detektieren. jUnit4 verwendet hierfür einen AssertError extends Error, was es uns ermöglicht mit einem einfach try/catch zu arbeiten.

    try {
      Assert.assertEquals("10", value);
    } catch (Error e) {
      takeScreenShot();
      throw e;
    }

Wenn wir nun den Test ausführen, erhalten wir in dem Verzeichnis target/ einen Screenshot. Das ist natürlich nicht komfortabel. Und es macht auch wenig Sinn nun überall diese try/catch-Blöcke
manuell zu erstellen.

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.

Die erste Rule für jUnit4

Um Querschnittsthemen zu realisieren, gibt es in jUnit4 einen Mechanismus, der es erlaubt, Rules zu definieren. Diese sind vom Gedanken her ähnlich den Aspekten oder Interceptors. Es werden demnach Blöcke definiert, die sich um einen bestehenden Block herumlegen. Um eine Rule in jUnit4 zu erstellen, muss man das Interface MethodRule implementieren. Dieses werden wir nun auch realisieren und eine Klasse ScreenshotTestRule erzeugen. Übrigens: Das Interface MethodRule ist ein FunctionalInterface mit der Methodendeklaration Statement apply(Statement base, FrameworkMethod method, Object target);. Nur leider ist das Statement eine abstrakte Klasse, obwohl es nur eine Methodendeklaration enthält. Warum das so ist? Keine Ahnung, aber es ist schade, da wir ab dieser Stelle keine Lambdas mehr verwenden können.

Sehen wir uns erst einmal die Implementierung unserer Rule an. Was passieren soll ist ja kurz erklärt:
Führe einen Test aus, wenn dieser fehlschlägt erzeuge einen Screenshot.

public class ScreenshotTestRule implements MethodRule {

  protected Optional<WebDriver>; driverOptional;

  public void setDriverOptional(Optional<WebDriver>; driverOptional) {
    this.driverOptional = driverOptional;
  }

  public Statement apply(final Statement statement,
                         final FrameworkMethod frameworkMethod,
                         final Object o) {

    return new Statement() {
      @Override
      public void evaluate() throws Throwable {
        try {
          statement.evaluate();
        } catch (Throwable t) {
          System.out.println("ScreenshotTestRule.evaluate -> catch !! ");
          captureScreenshot(frameworkMethod.getName());
          throw t; // rethrow to allow the failure to be reported to JUnit
        }
      }

      public void captureScreenshot(String testname) {
       // snipp
      }
    };
  }
}

Die Implementierung zum Erstellen des Screenshot kommt uns ja schon bekannt vor. Neu ist hier der Teil, der ein Statement erzeugt. Da es sich um eine abstrakte Klasse handelt, muss hier der
klassische Weg mittels new Statement()... gegangen werden. Ich bitte zu beachten, dass ich ein System.out.println hier mit eingebracht habe. Das benötigen wir gleich noch. Um diese Rule nun zu aktivieren, wird in der Klasse BaseSeleniumTest ein public-Attribut mit genau diesem Type definiert.

  @Rule
  public ScreenshotTestRule screenshotTestRule = new ScreenshotTestRule();

Das ein Attribut definiert und mit einer Instanz versehen werden muss, hat Vor- und auch Nachteile. Allerdings macht dies es uns leicht, die Rule noch mit Informationen zu versorgen. In unserem Fall, mit der Instanz des aktuell verwendeten WebDrivers. Um diese Instanz zu setzen, können wir nachdem die Initialisierung durchlaufen worden ist, in der Methode mit der Annotation @Before die Verbindung herstellen.

    screenshotTestRule.setDriverOptional(driver);

Nun haben wir soweit alles zusammen und starten unseren Test. Aus diesem sollte noch der manuell angelegt try/catch-Block entfernt werden. Wenn wir den Test laufen lassen, schlägt dieser wie bekannt fehl. Wenn wir nun nachfolgend prüfen, ob wir einen Screenshot erzeugt haben, müssen wir leider feststellen, das dem nicht so ist. Stattdessen sehen wir eine Fehlermeldung:

Abb. 2: Die Vererbungshierarchie

e = org.openqa.selenium.NoSuchSessionException: Session ID is null. Using WebDriver after calling quit()?

Was ist hier passiert? Um das einfach zu visualisieren, können wir in den Methoden die mit @After annotiert sind mit System.out.println eine kleine Nachricht erzeugen. Als kurze Erinnerung: die technischen Aspekte des Starten und Stoppen des verendeten Servlet-Containers wurde in die Klasse BaseTest ausgelagert und die Selenium-basierten Elemente in die Klasse BaseSeleniumTest. Daraus ergab sich das Vererbungsmodell aus Abbildung 2. Es ergeben sich demnach die folgenden Implementierungen der Methoden tearDown:

  @After // class BaseTest
  public void tearDown() throws Exception {
    System.out.println("BaseTest.tearDown !! ");
    Main.shutdown();
  }

  @Override
  @After // class BaseSeleniumTest
  public void tearDown() throws Exception {
    System.out.println("BaseSeleniumTest.tearDown !! ");
    // kill webdriver / Browser here
    driver.ifPresent(d -> {
      d.close();
      d.quit();
    });
    driver = Optional.empty();
    super.tearDown();
  }

In beiden Methoden wurde eine kleine Nachricht mit System.out erzeugt. Wenn wir nun den Test laufen lassen, wird deutlich was passiert.

BaseSeleniumTest.tearDown !! 
BaseTest.tearDown !! 
ScreenshotTestRule.evaluate -> catch !! 
e = org.openqa.selenium.NoSuchSessionException: Session ID is null. Using WebDriver after calling quit()?

Bei einem Fehlschlag in einem Test werden erst die Ressourcen wieder freigegeben. Genau dafür ist die Annotation @After ja vorhanden. Nur leider passt das nun nicht mit unserer Rule zusammen. Was kann nun gemacht werden? Wenn man nun die Dokumentation zu dem Interface MethodRule ansieht, erfährt man, dass dieses Interface mit jUnit 4.7 eingeführt worden ist aber schon mit jUnit 4.9 durch das Interface TestRule ersetzt wurde. Nur leider ist das Interface nicht mit @Deprecated markiert worden. Also setzen wir als erstes unsere Implementierung auf die Verwendung von TestRule um. Dieses Interface ist auch ein FunctionalInterface. Jedoch hat sich die Methodendefinition ein klein wenig geändert.

Statement apply(Statement base, Description description);

Wie man sehen kann, bekommt man hier eine Instanz der Klasse Description, die z. B. den Methodennamen der Testmethode liefert. Weitergebracht hat es uns nicht wirklich, da der Lebenszyklus immer noch der selbe ist. Wenn man sich diesen noch genauer ansieht, bekommt man einen Punkt, den man nutzen kann, wenn man möchte. Nachfolgend habe ich in einer Testklasse zwei Tests definiert, um ausschließlich den Lebenszyklus beobachten zu können:

public class DemoTests {

  @BeforeClass public static void beforeclass() { out.println("beforeclass"); }
  @AfterClass public static void afterclass() { out.println("afterclass"); }

  @Before public void setUp() { out.println("setUp"); }
  @After public void tearDown() { out.println("tearDown - orig"); }

  @Test public void test001() { out.println("test001 - non failing"); }

  @Test public void test002() {
    out.println("test002 - failing");
    fail();
  }

  @Rule
  public TakeScreenShotRule takeScreenShotRule = new TakeScreenShotRule();

  public static class TakeScreenShotRule implements TestRule {

    public class ScreenshotStatement extends Statement {

      private Statement baseStatement;

      public ScreenshotStatement(Statement b) {
        baseStatement = b;
      }

      @Override
      public void evaluate() throws Throwable {
        try {
          baseStatement.evaluate();
        } catch (Error e) {
          out.println("screenshot");
          throw e;
        } finally {
          tearDownManually();
        }
      }

      //Put your after code in this method!
      public void tearDownManually() {
        out.print("tearDownManually - now delegating - ");
        out.println("release resources from baseStatement");
        out.println("###############");
      }
    }

    public Statement apply(Statement base, Description description) {
      return new ScreenshotStatement(base);

    }
  }
}

Wenn man die hier definierten Tests ausführt, erhält man folgende Ausgabe:

beforeclass
setUp
test001 - non failing
tearDown - orig
tearDownManually - now delegating - release resources from baseStatement
###############
setUp
test002 - failing
tearDown - orig
screenshot
tearDownManually - now delegating - release resources from baseStatement
###############

java.lang.AssertionError
	at org.junit.Assert.fail(Assert.java:86)
	at org.junit.Assert.fail(Assert.java:95)
	at junit.org.rapidpm.vaadin.junit.DemoTests.test002(DemoTests.java:32)
     ......
afterclass

Leider wird in jedem Fall alles mit der Annotation @After sofort nach dem Fehlschlag der Testmethode ausgeführt. Hier kann man sich nur helfen, indem man die Verwendung von @After ganz unterlässt. Stattdessen wird das Aufräumen der Ressourcen komplett in der Methode tearDownManually durchgeführt. Spätestens, wenn das Projekt wächst und mehr als ein Entwickler daran beteiligt ist, werden Fehler passieren. Aber es gibt einen Funken Hoffnung. jUnit5 wird kommen ….

Fazit

In diesem Teil haben wir gesehen, was es mit dem Lebenszyklus unter jUnit4 auf sich hat. Um eine Screenshot in einem Fehlerfall zu erstellen, mussten wir uns von dem Standardvorgehen verabschieden, um mittels @Before und @After Ressourcen zu initialisieren und zum Ende des Tests wieder freizugeben. Letzteres war in unserem Fall ein Problem, da wir nur im Fehlerfall einen Screenshot erstellen wollten und dann der WebDriver schon nicht mehr zur Verfügung gestanden hat. In dem nächsten Teil werden wir uns eine Lösung mittels jUnit5 ansehen. Den Quelltext findet ihr auf GitHub. Bei Fragen und/oder 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.