Suche
Teil 4: Screenshots für einen UI-Test erstellen

Backend meets Frontend: Selenium goes Functional

Sven Ruppert

© Shutterstock / HelenField

Bei UI-Tests kann man sich nicht nur auf die Logfiles verlassen. Um Fehler wirklich nachvollziehen zu können, sind Screenshots hilfreich und wichtig. Die Kombination aus Vaadin und Selenium macht auch dies einfach möglich, wie dieser Teil der Kolumne „Backend meets Frontend“ zeigt.

Im letzten Teil dieser Serie haben wir uns damit beschäftigt den ersten funktionalen Test mit Selenium zu schreiben. Solange alles erfolgreich verläuft, ist nicht viel weiteres zu beachten. Aber leider ist dem ja meistens nicht so. Was kann man also im Fehlerfall an Informationen bekommen? Klassischerweise sind es die Logfiles die ausgewertet werden, um den Fehler zu finden und zu beheben.

Aber wir testen ja ein UI. Demnach ist es sinnvoll auch noch grafische Informationen zu erhalten. Wir werden heute den einfachsten Fall betrachten und zu definierten Zeitpunkten Screenshots anfertigen, die wir dann speichern. Diese können für eine manuelle und maschinelle Analyse als Basis dienen.

Und wieder erweitern wir das UI

Abb. 1: So könnten unsere Rechenfelder aussehen

Um den Unterschied deutlicher zu zeigen, werden wir das UI in diesem Teil wieder ein wenig erweitern. Ziel ist es diesmal eine kleine Rechenaufgabe zu lösen. Hierzu benötigen wir zwei Input-Felder und ein Output-Feld. In den beiden Input-Feldern werden jeweils eine Zahl hineingeschrieben; in das Output-Feld das korrespondierende Ergebnis, in diesem Fall die Summe der beiden Zahlen in den linken Feldern. Um die Berechnung zu starten, verwenden wir einen Button. Durch Drücken des selbigen werden die beiden Werte von links genommen und die Summe in das rechte Textfeld geschrieben. Der daraus resultierende Quelltext ist in der ersten Version sehr einfach.

public class MyUI extends UI {

  public static final String BUTTON_ID = "buttonID";
  public static final String INPUT_ID_A = "input_A_ID";
  public static final String INPUT_ID_B = "input_B_ID";
  public static final String OUTPUT_ID = "outputID";

  @Override
  protected void init(VaadinRequest request) {
    final HorizontalLayout layout = new HorizontalLayout();

    final TextField inputA = new TextField();
    inputA.setId(INPUT_ID_A);
    final TextField inputB = new TextField();
    inputB.setId(INPUT_ID_B);

    final Button button = new Button("click me");
    button.setId(BUTTON_ID);

    final TextField output = new TextField();
    output.setReadOnly(true);
    output.setId(OUTPUT_ID);

    layout.addComponents(inputA, new Label("+"), inputB, button, output);

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

Sicherlich sind hier einige Dinge zu verbessern. Aber dazu später mehr. Beginnen wir erst einmal mit dem Test. Wie in den letzten Teilen beschrieben haben wir Selenium in diesem Projekt initialisiert. Wir können demnach sofort mit dem Test an sich beginnen.

Der Ablauf ist recht trivial:

  • Hole das erste Textfeld
  • Schreibe einen Wert dort hinein
  • Hole das zweite Textfeld
  • Schreibe einen Wert dort hinein
  • Hole den Button
  • Klicke den Button
  • Hole das letzte, ganz rechte Textfeld
  • Hole den Wert dieses Textfeldes heraus
  • Überprüfe das Ergebnis

In dem letzten Teil haben wir uns eine Hilfsmethode geschrieben, mittels der wir z. B. das Input-Textfeld holen können. Zur Erinnerung hier nochmals der Quelltext:

  protected WebElement output(WebDriver driver) {
    return driver.findElement(By.id(MyUI.OUTPUT_ID));
  }

Wir verwenden hier die ID des Elements, um es zu addressieren und es zurückzuliefern. Einige Dinge sind hier nicht sonderlich schön. Zum einen: Wie gehen wir mit dem Fall um, dass es das Feld nicht gibt? Zum anderen: Schreiben wir nun für jede ID eine Methode? Die erste Frage können wir recht einfach beantworten, indem wir als Rückgabewert ein Optional verwenden.

  protected Optional<WebElement> output(WebDriver driver) {
    return Optional.ofNullable(driver.findElement(By.id(MyUI.OUTPUT_ID)));
  }

Zum zweiten Punkt gibt es mehrere Lösungen. Die einfachste ist es, die Methoden um das zweite Input-Textfeld zu erweitern. Danach haben wir also folgende Methoden:

  protected Optional<WebElement> button(WebDriver driver) {
    return Optional.ofNullable(driver.findElement(By.id(MyUI.BUTTON_ID)));
  }

  protected Optional<WebElement> output(WebDriver driver) {
    return Optional.ofNullable(driver.findElement(By.id(MyUI.OUTPUT_ID)));
  }

  protected Optional<WebElement> inputA(WebDriver driver) {
    return Optional.ofNullable(driver.findElement(By.id(MyUI.INPUT_ID_A)));
  }
  protected Optional<WebElement> inputB(WebDriver driver) {
    return Optional.ofNullable(driver.findElement(By.id(MyUI.INPUT_ID_B)));
  }

Um den Quelltext ein wenig schlanker zu gestallten, kann man nun noch ein wenig mit syntaktischen Mitteln arbeiten. Schritt eins: Extrahiere die generischen Teile in eine eigene Methode:

  private Optional<WebElement> elementWithID(WebDriver driver, String id) {
    return Optional.ofNullable(driver.findElement(By.id(id)));
  }

  protected Optional<WebElement> button(WebDriver driver) {
    return elementWithID(driver, MyUI.BUTTON_ID);
  }

  protected Optional<WebElement> output(WebDriver driver) {
    return elementWithID(driver, MyUI.OUTPUT_ID);
  }

  protected Optional<WebElement> inputA(WebDriver driver) {
    return elementWithID(driver, MyUI.INPUT_ID_A);
  }

  protected Optional<WebElement> inputB(WebDriver driver) {
    return elementWithID(driver, MyUI.INPUT_ID_B);
  }

Umbau der private Method in eine BiFunction<WebDriver, String, Optional>.

  private BiFunction<WebDriver, String, Optional<WebElement>> element
      = (driver, id) -&amp;gt; Optional.ofNullable(driver.findElement(By.id(id)));

  protected Optional<WebElement> button(WebDriver driver) {
    return element.apply(driver, MyUI.BUTTON_ID);
  }

  protected Optional<WebElement> output(WebDriver driver) {
    return element.apply(driver, MyUI.OUTPUT_ID);
  }

  protected Optional<WebElement> inputA(WebDriver driver) {
    return element.apply(driver, MyUI.INPUT_ID_A);
  }

  protected Optional<WebElement> inputB(WebDriver driver) {
    return element.apply(driver, MyUI.INPUT_ID_B);
  }

Verwendung von Static Imports.

  private BiFunction<WebDriver, String, Optional<WebElement>> element
      = (driver, id) -> ofNullable(driver.findElement(id(id)));

  protected Optional<WebElement> button(WebDriver driver) {
    return element.apply(driver, BUTTON_ID);
  }

  protected Optional<WebElement> output(WebDriver driver) {
    return element.apply(driver, OUTPUT_ID);
  }

  protected Optional<WebElement> inputA(WebDriver driver) {
    return element.apply(driver, INPUT_ID_A);
  }

  protected Optional<WebElement> inputB(WebDriver driver) {
    return element.apply(driver, INPUT_ID_B);
  }

Konvertieren in Functions.

 private BiFunction<WebDriver, String, Optional<WebElement>> element
      = (driver, id) -> ofNullable(driver.findElement(id(id)));

  protected Function<WebDriver, Optional<WebElement>> button
      = (driver) -> element.apply(driver, BUTTON_ID);

  protected Function<WebDriver, Optional<WebElement>> output
      = (driver) -> element.apply(driver, OUTPUT_ID);

  protected Function<WebDriver, Optional<WebElement>> inputA
      = (driver) -> element.apply(driver, INPUT_ID_A);

  protected Function<WebDriver, Optional<WebElement>> inputB
      = (driver) -> element.apply(driver, INPUT_ID_B);

Trennen der generischen Teile von den lokalen Elementen, wie dem WebDriver.

  //generic version - need it later
  private BiFunction<WebDriver, String, Optional<WebElement>> elementFor
      = (driver, id) -> ofNullable(driver.findElement(id(id)));

  //localized version
  private Function<String, Optional<WebElement>> element
      = (id) -> driver.flatMap(driverOptional -> elementFor.apply(driverOptional, id));

  protected Supplier<optional<WebElement>> button
      = () -> element.apply(BUTTON_ID);

  protected Supplier<optional<WebElement>> output
      = () -> element.apply(OUTPUT_ID);

  protected Supplier<optional<WebElement>> inputA
      = () -> element.apply(INPUT_ID_A);

  protected Supplier<optional<WebElement>> inputB
      = () -> element.apply(INPUT_ID_B);

Nun haben wir als Rückgabewert immer ein Optional. In vielen Fällen ist es eine gute Endscheidung, um Nullpointer Exceptions zu vermeiden undein wenig mehr Functional Style anwenden zu können. In diesem Fall aber würde ich davon absehen. Die Erklärung ist recht einfach. Wenn ich in den jUnit-Tests immer dieses Optional auspacken muss, ist es sinnvoll auch gleich auf das Vorhandensein der Elemente zu überprüfen. Das wiederum führt zu sehr repetitiven Code. Da Funktionen wie button() oder inputA() nur an die Tests gebunden sind, die auch zu dem jeweiligen UI gehören, kann man den Test ob eine Instanz wirklich vorhanden ist auch gleich in die Funktion selbst überführen. Das Ergebnis, wenn eines der Elemente nicht vorhanden ist, ist der Fehlschlag des jeweiligen Tests. Das führt zu folgende Implementierung:

  protected Supplier<WebElement> button
      = () -> element.apply(BUTTON_ID).orElseThrow(()-> new RuntimeException("Button not available"));

  protected Supplier<WebElement> output
      = () -> element.apply(OUTPUT_ID).orElseThrow(()-> new RuntimeException("OutputField not available"));

  protected Supplier<WebElement> inputA
      = () -> element.apply(INPUT_ID_A).orElseThrow(()-> new RuntimeException("InputField A not available"));

  protected Supplier<WebElement> inputB
      = () -> element.apply(INPUT_ID_B).orElseThrow(()-> new RuntimeException("InputField B not available"));

Nun haben wir die Überprüfung und auch eine aussagekräftige Fehlermeldung. Aber halt! Die Fehlermeldungen sind ja für Entwickler und nicht für den Endanwender. Demnach sollte die Nennung der ID ausreichend sein. Also können die Fehlermeldungen an anderer Stelle und generisch erzeugt werden:

  //generic version - need it later
  public BiFunction<WebDriver, String, Optional<WebElement>> elementFor
      = (driver, id) -> ofNullable(driver.findElement(id(id)));

  //localized version
  private Function<String, WebElement> element
      = (id) -> driver
      .flatMap(driverOptional -> elementFor.apply(driverOptional, id))
      .orElseThrow(()-> new RuntimeException("WebElement with the ID " + id +" is not available"));

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

Sehen wir uns nochmals die Testbeschreibung an:

  • Hole das erste Textfeld
  • Schreibe einen Wert dort hinein
  • Hole das zweite Textfeld
  • Schreibe einen Wert dort hinein
  • Hole den Button
  • Klicke den Button
  • Hole das letzte, ganz rechte Textfeld
  • Hole den Wert dieses Textfeldes heraus
  • Überprüfe das Ergebnis

Der dazugehörige Test sieht nun so aus:

    final WebDriver webDriver = driver
        .orElseThrow(() -> new RuntimeException("WebDriver not available"));

    webDriver.get("http://127.0.0.1:8080/");

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

    final WebElement btn = button.get();
    btn.click();

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

Wie man sieht kann man die Beschreibung des Tests eins zu eins in Quelltext übersetzen, bis auf die technische Notwendigkeit die hier noch besteht um die Seite zu laden. Wenn wir aber nun diesen Test laufen lassen – was für ein Wunder – schlägt er fehl. Was jetzt helfen könnte, aber sicherlich nicht notwendig in diesem trivialen Fall, wäre ein Screenshot. Damit könnten wir sehen, wie das UI ausgesehen hat als der Test fehlgeschlagen ist.

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.

Der erste Screenshot

Lösen wir zuerst die Herausforderung zu einem von uns definierten Zeitpunkt einen Screenshot zu erstellen und zu speichern. Eines können wir zumindest gleich festlegen. Der Speicherort für die Screnshots, sollte unter target/ liegen, damit bei jedem mvn clean install alle vorherigen Screenshots gelöscht werden und man sicher ist, das nur Screenshots passend zu dem jeweils letztem Durchlauf vorhanden sind.

Aber wie kann man nun einen Screnshot erstellen? Die Lösung ist recht einfach. Wenn der WebDriver das Interface TakesScreenshot implementiert,  verfügt er über eine Methode mit dem Namen
getScreenshotAs(..). Das Format der Rückgabe dieser Methode kann man steuern und hat verschiedene Möglichkeiten, von der ich die Variante Byte-Array nehme. Diese kann man direkt in einen ByteArrayOutputStream übergeben und mittels FileOutputStream auf die Zielplatte schreiben.

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    try {
      outputStream.write(((TakesScreenshot) webDriver).getScreenshotAs(OutputType.BYTES));
      //write to target/screenshot-[timestamp].jpg
      final FileOutputStream out = new FileOutputStream("target/screenshot-" + LocalDateTime.now() + ".png");

      out.write(outputStream.toByteArray());
      out.flush();
      out.close();
    } catch (IOException e) {
      e.printStackTrace();
    }

Wenn wir nun dieses als Methode takeScreenshot() in die Klasse BaseSeleniumTest auslagern, können wir in jedem Test nun beginnen Screenshots auf die Festplatte zu schreiben. Fügen wir nun diesen Methodenaufruf hinter das Drücken des Buttons und vor die Auswertung des Ergebnisses mittels Asserts, bekommen wir bei jedem Durchlauf einen Screenshot. Im nächsten Teil werden wir nicht nur den Bug in dieser Anwendung beheben.

Abb. 2: Unser erster Screenshot

Fazit

Wir sind nun in der Lage zu definierten Zeitpunkten Screenshots zu erstellen. Was man alles damit anstellen kann und ob man dies für automatische Tests verwenden kann, sehen wir in einem der nächsten Teile. Des weiteren haben wir uns ein klein wenig mit der Möglichkeit beschäftigt Teile der Anwendung eher Funktional Style zu schreiben. Den Quelltext findet ihr hier auf GitHub. Was damit machbar ist und welche Vorteile man damit erlangen kann, werden wir im Detail genauer ansehen. Hier möchte ich auf eine weitere Serie von mir aufmerksam machen, in der ich mich unter anderem den Thema Functional Reactive mittels Core Java zuwende. 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.