Teil 8: Backend meets Frontend: Von Selenium zu TestBench

Fachliche UI-Tests objektorientiert oder funktional schreiben

Sven Ruppert

© Shutterstock / HelenField

Wenn die ersten UI-Tests laufen, geht es daran, Elemente der Webanwendung zu selektieren und fachliche Tests zu schreiben. Das kann objekt-orientiert oder funktional erfolgen. Der Selenium-Aufsatz TestBench hilft dabei, das Entwicklerleben einfacher zu machen.

In dem letzten Teil der Serie haben wir uns damit beschäftigt, wie wir mit jUnit5 und Selenium UI-Tests schreiben können und wie wir die dazu benötigten Ressourcen verwalten. Die technischen Basisdinge haben wir somit vorerst unter Kontrolle und können unsere Tests lokal gegen verschiedene Browser testen. In diesem Teil werden wir uns damit auseinandersetzen, wie wir die Elemente unserer Web-App selektieren können, um dann unsere fachlich-/logischen Tests zu formulieren.

Das bisherige Vorgehen mit Selenium ist, dass über den WebDriver die Seite geladen wird, die wir in unserem Test verwenden wollen. Ist das erfolgt, lassen sich Elemente z. B. mit ihrer ID selektieren und als eine Instanz der Klasse WebElement zur Verfügung stellen. Darauf werden dann die fachlichen Tests ausgeführt. Als kleine Erinnerung, wie das in unserer Beispiel-Vaadin-Applikation ausgesehen hat hier nochmals der dazugehörige Quelltext.

WebElement element = driver.findElement(By.id(id))

An dieser Stelle haben wir keine Information darüber, um welches Element es sich vom Typ her handelt. Es kann ein Button sein oder auch eine ComboBox. Demnach sind auch keine Element-spezifischen Attribute mittels Getter/Setter vorhanden. Um z. B. aus einem TextField den Wert zu bekommen, müssen wir das Attribut mit dem Wert direkt adressieren.

String value = element.getAttribute("value");

Das ist weder Refactoring-sicher noch kann die IDE dabei richtig unterstützen. Um das ein wenig komfortable für den Entwickler zu gestallten, haben wir uns die Elemente mittels einer Funktion geholt, die einen aussagekräftigen Namen hat. In unserem Fall zum Beispiel wie nachfolgend aufgelistet:

//generisch im Interface WebDriverFunctions
  static BiFunction<WebDriver, String, Optional<WebElement>> elementFor() {
    return (driver, id) -> ofNullable(driver.findElement(id(id)));
  }

//AbstractVaadinPageObject , Instanz des WebDriver vorhanden 
  public Function<String, WebElement> element
      = (id) -> elementFor()
      .apply(getDriver(), id)
      .orElseThrow(() -> new RuntimeException("WebElement with the ID " + id + " is not available"));

//MyUIPageObject - Supplier für einen def. Button
  public Supplier<WebElement> button = () -> element.apply(BUTTON_ID);

// MyUITest - in der Verwendung
  button.get().click();

Sicherlich kann man nun für alle Komponenten Funktionen schreiben. Das ist jedoch recht viel Arbeit. Wie können wir uns also das Leben als Softwareentwickler leichter machen?

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.

Vaadin TestBench: Aufsatz auf Selenium

Da wir uns hier mit Vaadin-Web-Apps beschäftigen, möchte ich das passende Werkzeug aus dem Hause Vaadin vorstellen. TestBench ist ein Aufsatz auf Selenium und nimmt viel Arbeit ab, wenn es darum geht die Komponenten typsicher zu verwenden und viele technische Dinge, wie das richtige Warten auf Ergebnisse. Da es sich um einen Aufsatz auf Selenium handelt, werden wir uns nun ansehen, wie wir uns einiges an Arbeit bei Selenium-basierten UI-Tests sparen können. Aber beginnen wir hier mit einem TestBench „Hello World“.

TestBench selbst gibt es bei Vaadin oder auf GitHub. TestBench ist Open Source, für die Verwendung in kommerziellen Projekten braucht man allerdings eine Lizenz. Es gibt eine Testversion, die man online bekommen kann. Demnach steht einem praktischen Test nichts mehr im Wege. Weitere Informationen sind hier zu finden.

Als erstes löschen wir alle Einträge zu Selenium-Abhängigkeiten im Projekt (pom.xml), da Selenium in TestBench integriert ist. Nun fehlen noch die Abhängigkeiten zu TestBench selbst in der pom.xml. Als der Artikel geschrieben wurde stand die Version 5.1 kurz vor der Auslieferung. Deswegen habe ich mich für die Snapshot-Version entschieden.

    <dependency>
      <groupId>com.vaadin</groupId>
      <artifactId>vaadin-testbench</artifactId>
      <version>5.1-SNAPSHOT</version>
      <scope>test</scope>
    </dependency> 

Um auch auf Vorabversionen zugreifen zu können, fügen wir noch weitere Repositorys in der pom.xml hinzu.

   <repositories>
    <!--Vaadin Snapshot Repos-->
    <repository>
      <id>vaadin-addons</id>
      <url>http://maven.vaadin.com/vaadin-addons</url>
    </repository>
    <repository>
      <id>vaadin-prereleases</id>
      <url>http://maven.vaadin.com/vaadin-prereleases</url>
    </repository>
    <repository>
      <id>vaadin-snapshots</id>
      <name>Vaadin snapshot repository</name>
      <url>https://oss.sonatype.org/content/repositories/vaadin-snapshots/</url>
    </repository>
  </repositories>
  
  <pluginRepositories>
    <pluginRepository>
      <id>vaadin-prereleases</id>
      <url>http://maven.vaadin.com/vaadin-prereleases</url>
    </pluginRepository>
  </pluginRepositories>

Ich möchte darauf hinweisen, dass zu diesem Zeitpunkt Testbench noch offiziell für jUnit4 ausgelegt ist. In diesem Artikel werden wir aber jUnit5 weiter benutzen. Demnach gibt es kleine Unterschiede zur Homepage. Der große Vorteil von jUnit5 ist, dass es möglich ist, jUnit4-Tests laufen zu lassen. Zu Einschränkungen muss ich auf die aktuelle jUnit5-Dokumentation verweisen. [/))

TestBench Hello World

Kommen wir zu einem kleinen TestBench Hello World und schreiben einen Test für unsere Web-App.
Es handelt sich um denselben Test, den wir bisher erstellt haben und er schlägt ebenfalls fehl. Bisher haben wir den Fehler im Tests verwendet, um genau dann einen Screenshot zu erstellen. In diesem Test wird bei einem Fehlschlag kein Screenshot erstellt. Das habe ich deswegen hier ausgelassen.

public class TestBenchHelloWorldTest extends TestBenchTestCase {
  @BeforeEach
  void setUp() {
    System.setProperty("webdriver.chrome.driver", "_data/webdrivers/chromedriver-mac-64bit");
    Main.start();
    setDriver(new ChromeDriver());
  }

  @AfterEach
  void tearDown() {
    WebDriver driver = getDriver();
    if (driver != null) driver.quit();
    Main.shutdown();
  }

  @Test
  void testHelloWorld() {
    getDriver().get("http://127.0.0.1:8080/");
    $(TextFieldElement.class).id(INPUT_ID_A).sendKeys("5");
    $(TextFieldElement.class).id(INPUT_ID_B).sendKeys("5");
    $(ButtonElement.class).id(BUTTON_ID).click();
    String value =  $(TextFieldElement.class).id(OUTPUT_ID).getValue();
    Assertions.assertEquals("10", value);
  }
}

Wenn wir uns eine Zeile exemplarisch genauer ansehen und in einzelnen Schritten darstellen,
sieht man schon einen der großen Unterschiede zum ursprünglichen Selenium.

String value =  $(TextFieldElement.class).id(OUTPUT_ID).getValue();
    ElementQuery<TextFieldElement> elementQuery = $(TextFieldElement.class);
    TextFieldElement textFieldElement = elementQuery.id(OUTPUT_ID);
    String value =  textFieldElement.getValue();

Es werden hier keine Aktionen auf Instanzen der Klasse WebElement durchgeführt, sondern wir erhalten für einen Button final eine Instanz vom Typ ButtonElement und bei einem TextField ein TextFieldElement. Damit werden die entsprechenden Methoden geliefert, die für die jeweilige Komponente individuell zur Verfügung stehen. Aber wie bauen wir das nun in unser Projekt ein? Denn wir haben doch schon einiges an Arbeit in die Infrastruktur investiert.

TestBench-Integration in unser Projekt

Bei größeren Projekten wächst auch der Anteil der Quelltexte, die für die Testabdeckung verwendet werden. Auch hier gilt es möglichst Wiederholungen zu vermeiden. Refactoring in Test-Quelltexten gehört ebenfalls zu den Dingen, die auf uns zukommen werden. Kurz gesagt, auch hier gilt es sauber und ordentlich zu arbeiten, da sonst die Aufwände mit der Zeit stark ansteigen werden.

Wir haben in den letzten Teilen das Pattern PageObject eingeführt. Das werden wir hier weiter verwenden. Ist es doch für uns die Möglichkeit die technischen Dinge von den fachlichen Tests zu trennen. Nun kann man aber die Teile, welche die Selection ergeben, unterschiedlich realisieren. Ich werde hier verschiedene – aber sicherlich nicht alle möglichen – Wege aufzeigen. So kann man die Unterschiede erkennen und abwägen, welche dieser Wege für einen selbst oder für das jeweilige Projekt das Richtige sein kann.

Als erstes sehen wir uns den objektorientierten Stil an. Hier wird auf klassischem Weg mit Methoden
der Umfang der repetitiven Quelltextanteile reduziert.

  public WebElement elementByID(String id) { return driver.findElement(id(id)); }

  public WebElement button() { return elementByID(BUTTON_ID); }
  public WebElement output() { return elementByID(OUTPUT_ID); }
  public WebElement inputA() { return elementByID(INPUT_ID_A); }
  public WebElement inputB() { return elementByID(INPUT_ID_B); }

Vorteil ist die für viele Entwickler gewohnte Vorgehensweise. Der Quelltext selbst ist recht kompakt und wir kommen mit Selenium alleine aus. Als Rückgabewert erhalten wir allerdings immer ein WebElement.

Nun kann man sich dem auch mittels der Functional Interfaces nähern. Hierzu werden die zustandsbehafteten Teile soweit wie möglich isoliert. Der generische Teil kann z. B. in einer Sammlung von Functions ausgelagert werden. Hier ist die Instanz des WebDrivers noch ein Parameter.

  static BiFunction<WebDriver, String, Optional<WebElement>> elementFor() {
    return (driver, id) -> ofNullable(driver.findElement(id(id)));
  } 

In der Klasse AbstractVaadinPageObject hingegen kann man schon direkt auf die richtige Instanz zustandsbehaftet zugreifen. Zusätzlich wird hier die Entscheidung getroffen, das eine Exception geworfen wird, wenn die gesuchte ID nicht zu einem Treffer führt. Ob das hier gemacht werden soll, kann sicherlich diskutiert werden. Ich habe es als Beispiel mit eingebracht und gleichzeitig an der Stelle auf eine reine Instanz ohne Optional reduziert.

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

Im jeweiligen PageObject sind dann z. B. nur noch eindeutig identifizierte Elemente im Zugriff.

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

Wenn man nun die TestBench-Anteile von den reinen Selenium-Anteilen nochmals trennen möchte oder Bestandsquelltexte hat, die man weiter verwenden möchte oder muss, kann man sich folgende Brücken vorstellen. Für die Transformation von einem WebElement zu dem jeweiligen TestBench-Typ wird die Methode TestBenchTestCase.wrap(..) verwendet. Allerdings habe ich in den Beispielen einen static import angewendet.

Objektorientierte Variante: Hier gibt es nicht viel zu erklären, da es sich um eine reine Verschachtelung von Methoden handelt.

  public ButtonElement button() {return wrap(ButtonElement.class, elementByID(BUTTON_ID));}

Funktionale Variante: Wenn man das funktionaler ausdrücken möchte, kann man sich hier folgende Transformation überlegen. Beginnen wir mit einer Eins-zu-eins-Umsetzung. Als erstes die reine Typenkonvertierung, dann die Selection der Identität.

  private Function<WebElement, ButtonElement> toButton = (webElement) -> wrap(ButtonElement.class, webElement);
  private Function<WebElement, TextFieldElement> toTextField = (webElement) -> wrap(TextFieldElement.class, webElement);
  public Supplier<ButtonElement> btnSup = () -> toButton.apply(element.apply(BUTTON_ID));
  public Supplier<TextFieldElement> inputASup = () -> toTextField.apply(element.apply(INPUT_ID_A));
  public Supplier<TextFieldElement> inputBSup = () -> toTextField.apply(element.apply(INPUT_ID_B));

Lesen Sie auch: Checkpoint Java: Funktional und reaktiv mit Java 9

Nun gibt es auch die Möglichkeit, die Funktionen zu kombinieren. Das ermöglicht eine noch kompaktere Schreibweise.

  public Supplier<ButtonElement> buttonSupplier = () -> element.andThen(toButton).apply(BUTTON_ID);
  public Supplier<TextFieldElement> inputASupplier = () -> element.andThen(toTextField).apply(INPUT_ID_A);
  public Supplier<TextFieldElement> inputBSupplier = () -> element.andThen(toTextField).apply(INPUT_ID_B);

  public Function<String, ButtonElement> button = element.andThen(toButton);

Selenium bietet die Möglichkeit, die explizit adressierten Instanzen direkt in das PageObject
injizieren zu lassen. Dazu verwendet es einen eigenen Marker, @FindBy(..). Diese Annotation wird ausgewertet, indem man die Klasse des PageObjects und die Instanz des zu verwendenden WebDrivers der Methode initElements der PageFactory übergibt.

PageFactory.initElements(driver, MyUIPageObject.class)

Hier geht man davon aus, dass es einen Default Constructor gibt, der für das Erzeugen einer Instanz verwendet werden kann. In der Klasse MyUIPageObject sind die Attribute dann wie folgt definiert.

  @FindBy(id = BUTTON_ID) private WebElement btnWE;
  @FindBy(id = INPUT_ID_A) private WebElement inputAWE;
  @FindBy(id = INPUT_ID_B) private WebElement inputBWE;

Wenn man dies in seinem Projekt verwendet, kann man mit der nachfolgend gezeigten Methode auf TestBench erweitern, ohne die bestehenden Test damit zu brechen. In neu zu schreibenden Quelltext würde ich dies aber nicht machen, da man einen weiteren Mechanismus inklusive Lebenszyklus bekommt. Diese Instanzen kann man dann natürlich auch wieder in TestBench-Typen konvertieren.

  private ButtonElement toButtonElement(WebElement webElement) { return wrap(ButtonElement.class, webElement);}
  private TextFieldElement toTextFieldElement(WebElement webElement) { return wrap(TextFieldElement.class, webElement);}

  public ButtonElement button() { return toButtonElement(btnWE);}
  public TextFieldElement inputA() { return toTextFieldElement(inputAWE);}
  public TextFieldElement inputB() { return toTextFieldElement(inputBWE);}
  private Function<WebElement, ButtonElement> toButton = (webElement) -> wrap(ButtonElement.class, webElement);
  private Function<WebElement, TextFieldElement> toTextField = (webElement) -> wrap(TextFieldElement.class, webElement);

  public Supplier<ButtonElement> button = () -> toButton.apply(btnWE);
  public Supplier<TextFieldElement> inputA = () -> toTextField.apply(inputAWE);
  public Supplier<TextFieldElement> inputB = () -> toTextField.apply(inputBWE);

Sind noch keine Bestandsquelltexte vorhanden oder möchte man eventuell auch nur mal eben etwas ausprobieren, ist der ad-hoc-Weg von TestBench hilfreich. Hier werden die Elemente direkt auf Typ und ID hin angesprochen und man erhält eine Instanz der Zielklasse. Zu beachten ist hierbei, dass diese Anweisungen nicht in einem PageObject zu finden sind, sondern direkt im Test selbst. Als vollständiges Beispiel möchte ich auf die Klasse TestBenchHelloWorldTest verweisen.

 public ButtonElement buttonTBOO() {return $(ButtonElement.class).id(BUTTON_ID); }
 public TextFieldElement outputTBOO() {return $(TextFieldElement.class).id(OUTPUT_ID); }
 public TextFieldElement inputATBOO() {return $(TextFieldElement.class).id(INPUT_ID_A); }
 public TextFieldElement inputBTBOO() {return $(TextFieldElement.class).id(INPUT_ID_B); }   

Bei dieser Vorgehensweise kann man sicherlich recht schnell die ersten Ergebnisse verwenden, jedoch bin ich mir nicht sicher, ob dieser Ansatz bei größeren Projekten nicht doch zu repetitiv oder unbequem ist. Ein wenig angenehmer kann man es sich dann doch machen.

Hier nun eine objektorientierte Variante innerhalb des PageObject.

  public ButtonElement buttonTBOO() {return $(ButtonElement.class).id(BUTTON_ID); }
  public TextFieldElement outputTBOO() {return $(TextFieldElement.class).id(OUTPUT_ID); }
  public TextFieldElement inputATBOO() {return $(TextFieldElement.class).id(INPUT_ID_A); }
  public TextFieldElement inputBTBOO() {return $(TextFieldElement.class).id(INPUT_ID_B); }

Wenn man sich dem funktionalen Stil annähern möchte, werden die Zugriffe z. B. mit Supplier realisiert.

  public Supplier<ButtonElement> buttonSupplier = () -> $(ButtonElement.class).id(BUTTON_ID);
  public Supplier<TextFieldElement> outputSupplier = () -> $(TextFieldElement.class).id(OUTPUT_ID);
  public Supplier<TextFieldElement> inputASupplier = () -> $(TextFieldElement.class).id(INPUT_ID_A);
  public Supplier<TextFieldElement> inputBSupplier = () -> $(TextFieldElement.class).id(INPUT_ID_B);

Wenn man nun schon in die Richtung funktionalem Stil geht, sollte man meiner Meinung nach auch die Abstraktionsstufen deutlich trennen. In diesem Fall könnte es wie folgt aussehen:

  public Function<String, TextFieldElement> textField = (id) -> $(TextFieldElement.class).id(id);
  public Function<String, ButtonElement> btn = (id) -> $(ButtonElement.class).id(id);

  public Supplier<ButtonElement> buttonSupplier = () -> btn.apply(BUTTON_ID);
  public Supplier<TextFieldElement> outputSupplier = () -> textField.apply(OUTPUT_ID);
  public Supplier<TextFieldElement> inputASupplier = () -> textField.apply(INPUT_ID_A);
  public Supplier<TextFieldElement> inputBSupplier = () -> textField.apply(INPUT_ID_B);

Wenn man es ein bisschen fachlicher möchte und z. B. id(..) anstelle von apply() verwendet, kann man auch ein eigenes Functional Interface dazwischen setzen.

  @FunctionalInterface
  public interface WithID<T extends AbstractElement> {
    T id(String id);
  }
  public WithID<TextFieldElement> textField = (id) -> $(TextFieldElement.class).id(id);
  public WithID<ButtonElement> btn = (id) -> $(ButtonElement.class).id(id);

  public Supplier<ButtonElement> buttonSupplier = () -> btn.id(BUTTON_ID);
  public Supplier<TextFieldElement> outputSupplier = () -> textField.id(OUTPUT_ID);
  public Supplier<TextFieldElement> inputASupplier = () -> textField.id(INPUT_ID_A);
  public Supplier<TextFieldElement> inputBSupplier = () -> textField.id(INPUT_ID_B); 

Fazit

Wir haben in diesem Teil der Serie gesehen, wie man sich der Selektion von Elementen auf verschiedenen Arten nähern kann. Diese gibt es in den Geschmacksrichtugen objektorientiert und funktional. Welche davon für das jeweilige Projekt am besten geeignet ist, hängt sicherlich von den
Voraussetzungen ab. 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.