Suche
Teil 7: Die WebDriver Binarys managen

Die Grundlagen für UI-Tests mit mehreren Browsern

Sven Ruppert

© Shutterstock / HelenField

Nun hat man es als Web-Entwickler ja nicht nur mit einem Zielsystem zu schaffen: In einem Browser schlägt der UI-Test fehlt, im nächsten nicht. Es gilt, Grundlagen für Browser-übergreifende Tests zu legen. Dafür müssen die WebDriver Binarys gemanagt werden. Dieser Teil von „Backend meets Frontend“ zeigt wie.

In den letzten Teilen meiner Serie haben wir uns um verschieden Basisthemen gekümmert, die bei der Entwicklung von Vaadin-Apps hilfreich sind. Dabei haben wir Selenium für das Erstellen von UI-Tests verwendet. Notwendig ist dabei die Installation der WebDriver Binarys, die es ermöglichen, den Browser anzusprechen, der im Test verwendet werden soll.

Bisher mussten man die Binarys selbst aus dem Internet zusammensuchen und dann lokal in einem Verzeichnis zur Verfügung stellen, sodass man beim Durchlauf eines Tests diese Binarys laden kann. Was für einen Treiber noch OK sein mag, wird aber bei mehreren Projekten mit unterschiedlichen Versionen und Browsern zu einer lästigen Angelegenheit. Wenn man nun ein wenig weiter denkt, kommt da auch recht schnell doch einiges dazu. Da sind zum einen die Kollegen, die genau dasselbe machen müssen. Wer kennt nicht die tollen Verweise auf ein internes Wiki, in dem natürlich alles steht? Zum anderen kommt früher oder später auch noch die CI/CD-Strecke dazu. Wer möchte schon die Binarys auf allen Knoten eines Jenkins-, TeamCity-, Bamboo- oder Was-auch-immer-Clusters installieren und auf aktuellem Stand halten. Und auch nicht unbedingt auf dem aktuellem Stand, sondern immer auf dem Stand, den das gerade verarbeitete Projekt benötigt. Kurz gesagt: Damit möchte sich keiner befassen.

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 erster Einsatz

Da wir nicht die ersten sind, die mit diesem Problem zu kämpfen haben, findet man eine Lösung im Netz. Hier spreche ich von einem Maven-Plug-in, das uns genau dabei hilft. Das Projekt selbst findet man auf GitHub. Um nun mit der Verwendung zu beginnen, fügen wir die notwendigen Einträge unserer pom.xml hinzu.

 <plugin>
        <groupId>com.github.webdriverextensions</groupId>
        <artifactId>webdriverextensions-maven-plugin</artifactId>
        <version>3.1.1</version>
        <executions>
          <execution>
            <goals>
              <goal>install-drivers</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <keepDownloadedWebdrivers>true</keepDownloadedWebdrivers>
          <installationDirectory>_data/webdrivers</installationDirectory>
          <drivers>
            <driver>
              <name>chromedriver</name>
              <platform>mac</platform>
              <bit>64</bit>
              <version>2.29</version>
            </driver>
          </drivers>
        </configuration>
      </plugin>

Was genau wird hier gemacht? Das Plug-in holt die in der Sektion <drivers> definierten Binaries. Hierbei muss die Plattform, der Browser, die Architektur (32/64 Bit) und Version angegeben werden. In der Sektion <installationDirectory> kann man wählen, wo die Dateien gespeichert werden sollen. Hierbei gilt es zu beachten, dass man ein Verzeichnis angibt, das nicht unter target liegt, um die Dateien auch über einen mvn clean-Zyklus hinweg behalten zu können. Allerdings ist es ratsam dieses auf einem Build-Server genau so zu realisieren, um sicher zu sein, dass die Binarys nach dem Build auch wirklich wieder entsorgt werden. Das Zielverzeichnis sollte demnach über eine Umgebungsvariable in der settings.xml gesteuert werden. In der Sektion <keepDownloadedWebdrivers> kann entschieden werden, ob die Cacheing-Funktion des Plug-ins verwendet werden soll.

Zugriff auf gecachte Binarys im eigenen Netzwerk

Nun gibt es natürlich auch Umgebungen, in der kein Internetzugang geschaltet ist. Hier kann man sich damit behelfen, das man einen Server intern installiert, auf den man mittels HTTP zugreifen kann. In diesem Fall wird die Treiberdefinition um einen URL erweitert, der auf den Speicherort verweist.

<driver>
    <name>chromedriver</name>
    <platform>mac</platform>
    <bit>64</bit>
    <version>2.29</version>
    <url>http://internal.server.url/path/driver.zip</url>
</driver>

Zu beachten ist, dass beim Erzeugen der Instanz des Treibers die System Propertys nach dem Schlüssel, in unserem Fall webdriver.chrome.driver, durchsucht werden. Hier muss der angegebene Pfad natürlich mit dem Wert in den Propertys übereinstimmen.

System.setProperty("webdriver.chrome.driver", "_data/webdrivers/chromedriver-mac-64bit");

Wenn wir unser Projekt nun mittels Maven bauen, werden die Treiber aus der angegebenen Quelle geladen und verwendet. Was ist aber wenn wir mit der IDE einen jUnit-Test aufrufen? Hat ein Maven Build vorher stattgefunden, sind alle Treiber vorhanden. Mit dem Maven-Plug-in selbst kann man aber ebenfalls die Treiber explizit herunterladen: mvn webdriverextensions:install-drivers.

Teste mehr als einen Browser

Nun sind wir also in der Lage, die benötigten Binarys für die WebDriver komfortabel zu verwalten.
Es spricht also nichts mehr dagegen, die Anwendung nun gegen mehrere Browser zu testen. Alle Browser, die wir in unserem Test verwenden wollen, sollten lokal installiert sein. In diesem Beispiel verwende ich auf meinem MacbookPro Google Chrome und Firefox. Die Sonderfälle Internet Explorer, Safari und Headless-Browser behandeln wir in einem späteren Teil, da es doch ein wenig
Mehraufwand bedeutet.

Wir haben nun die Situation, das wir einen Test mit mehreren Browsern testen möchten. Dazu muss der Test an sich jedesmal mit einem anderen WebDriver initialisiert werden. In jUnit4 gab es dazu die Parameter. In jUnit5 sieht es allerdings ein wenig anders aus. Im letzten Teil haben wir den Mechanismus verwendet, mit dem wir uns eine Instanz des WebDrivers als Methoden-Parameter haben geben lassen. Leider passt das nun nicht mehr, da wir diesmal eine nicht fix definierte Menge an Browsern testen möchten. Alle WebDriver auf einmal zu erzeugen ist auch weniger effizient. Wie benötigen die Möglichkeit eine Menge an Kombinationen durchlaufen zu lassen und gleichzeitig sicher zu stellen dass wir die WebDriver-Instanzen auch wieder frei geben werden. Aber nun ein Schritt nach dem anderen.

Als erstes erweitern wir das Interface WebDriverFunctions um eine Methode die uns einen Supplier für einen WebDriver für den Browser Firefox erzeugt.

 // not nice to copy all this stuff
  static Supplier<optional<WebDriver>> newWebDriverChrome() {
    return () -> {
      try {
        final DesiredCapabilities chromeCapabilities = DesiredCapabilities.chrome();
        final ChromeDriver chromeDriver = new ChromeDriver(chromeCapabilities);
        chromeDriver.manage().window().maximize();
        return Optional.of(chromeDriver);
      } catch (Exception e) {
        e.printStackTrace();
        return empty();
      }
    };
  }

  static Supplier<optional<WebDriver>> newWebDriverFirefox() {
    return () -> {
      try {
        final DesiredCapabilities chromeCapabilities = DesiredCapabilities.firefox();
        final FirefoxDriver driver = new FirefoxDriver(chromeCapabilities);
        driver.manage().window().maximize();
        return Optional.of(driver);
      } catch (Exception e) {
        e.printStackTrace();
        return empty();
      }
    };
  } 

Nun müssen wir den richtigen Lebenszyklus aufbauen. Dazu modifizieren wir die im letzten Teil eingeführte Klasse WebDriverSeleniumExtension. Gehen wir die einzelnen Schritte durch: Als erstes müssen die Propertys gesetzt werden, damit die Binarys für die WebDriver gefunden werden können. Da dieses nicht viele Ressourcen belegt, können wir das einmal zum Beginn der Tests machen. Hierzu verwenden wir den BeforeAllCallback und ergänzen die Klassendefinition um ein implements-BeforeAllCallback und implementieren die Methode beforeAll.

  @Override
  public void beforeAll(ContainerExtensionContext context) throws Exception {
    System.setProperty("webdriver.chrome.driver", "_data/webdrivers/chromedriver-mac-64bit");
    System.setProperty("webdriver.opera.driver", "_data/webdrivers/operadriver-mac-64bit");
    System.setProperty("webdriver.gecko.driver", "_data/webdrivers/geckodriver-mac-64bit");
  }

Nach jedem Test möchten wir den jeweils verwendeten WebDriver wieder entfernen. Als Zwischenspeicher verwenden wir diesmal wieder den Store. Dazu erweitern wir die Implements-Anweisung um das Interface AfterTestExecutionCallback. Daraufhin müssen wir die Methode afterTestExecution implementieren.

  public static final String WEBDRIVER = "webdriver";

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

  static Function<ExtensionContext, ExtensionContext.Store> store() {
    return (context) -> context.getStore(namespaceFor().apply(context));
  }

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

  @Override
  public void afterTestExecution(TestExtensionContext context) throws Exception {
    final WebDriver webdriver = webdriver().apply(context);

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

    store().apply(context).remove(WEBDRIVER);
  }

Nicht vergessen, dass wir im Fehlerfall einen Screenshot erzeugen möchten. Hier hilft uns das Interface TestExecutionExceptionHandler.

  @Override
  public void handleTestExecutionException(TestExtensionContext context, Throwable throwable) throws Throwable {

    takeScreenShot().accept(webdriver().apply(context));

    throw throwable;

Nicht vergessen, das man die Exception entweder wieder wirft oder in eine andere verpackt. Wenn man sich dazu entschließen sollte die Exception zu schlucken werden nachfolgende TestExecutionExceptionHandler nicht ausgeführt.

Nun fehlt nur noch das Erzeugen von den WebDriver-Instanzen. Hier beginnen wir bei dem Test selbst. Um eine Liste von definierten Eingangsparameter-Werten zu bekommen, gibt es in jUnit5 den Mechanismus des @ParameterizedTest. Diese Annotation ist anstelle der Annotation @Test zu verwenden. Es gibt verschiedene Wege nun die Eingangswerte zu definieren. In unserem Beispiel habe ich mich dazu entschlossen eine Quelle anzugeben, die wir selbst implementieren. Um die Verbindung herzustellen, wird die Annotation @ArgumentsSource(WebDriverSeleniumExtension.PageObjectProvider.class) verwendet. Die Klasse, die hier angegeben werden muss, ist nichts anderes als eine Factory für einen Stream von Eingangswerten. Der Stream muss von dem Typ sein, der als Parameter für die Testmethode verwendet wird.

  @ParameterizedTest(name = "{index} ==> ''{0}''")
  @ArgumentsSource(WebDriverSeleniumExtension.PageObjectProvider.class)
  void test001(final MyUIPageObject pageObject) {
    assertNotNull(pageObject);

    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);
  }

Für diesen Test erzeugen wir uns einen Stream vom Typ MyUIPageObject. Und damit sind wir genau dort wo wir ankommen möchten. Wie aber wird nun die Factory implementiert? Da es fest mit der WebDriverSeleniumExtension verbunden ist, habe ich die Factory dort als innere statische Klasse angelegt.

public static class PageObjectProvider implements ArgumentsProvider {
    @Override
    public Stream<? extends Arguments> arguments(ContainerExtensionContext context) {
      return Stream
          .of(
              newWebDriverChrome(),
              newWebDriverFirefox()
          )
          .map(Supplier::get)
          .filter(Optional::isPresent)
          .map(Optional::get)
          .peek(d -> storeWebDriver().accept(context, d))
          .map(MyUIPageObject::new)
          .map(ObjectArrayArguments::create);
    }
  }

Was hier passiert ist recht geradlinig. Es werden die Supplier für die WebDriver erzeugt und bei der Abarbeitung des Streams aktiviert. Die erzeugte Instanz des WebDrivers wird in dem Store abgelegt, damit die nachfolgenden Lebenszyklen darauf zugreifen können. Der WebDriver selbst wird als Attribut dem PageObject übergeben und damit haben wir das Eingangsattribut für unseren Test.

Fazit

Nun sind wir in der Lage eine fest definierte Menge und Reihenfolge an Browsern zu testen. Wie wir das dynamisch gestallten und somit an die jeweiligen Ablaufumgebungen anpassen können, sehen wir in einem weiteren Teil der Serie. Komfortabel ist nun auch die Verwaltung der WebDriver-Binarys mittels Maven.

Eine Kleinigkeit noch zum Schluss. Bisher hatten wir den Test auf der Klassenebene mit den Extensions versehen und damit sichergestellt, dass die Reihenfolge stimmt, um die Infrastruktur (Servlet-Container) und die Verwaltung der WebDriver zu garantieren. Damit es hier nicht zu Fehlern kommt, und auch nachträgliche Erweiterungen einfach durchzuführen sind, kann man diese beiden Annotationen zusammenfassen:

@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
//Order is important top / down
@ExtendWith(ServletContainerExtension.class)
@ExtendWith(WebDriverSeleniumExtension.class)
public @interface VaadinTest {
}

Nun können wir mit einer einzigen Annotation das korrekte Verhalten auf dieser Ebene sicherstellen und unser Test selbst hat fast ausschließlich fachliche Anteile.

@VaadinTest
public class MyUITest  {

  @ParameterizedTest(name = "{index} ==> ''{0}''")
  @ArgumentsSource(WebDriverSeleniumExtension.PageObjectProvider.class)
  void test001(final MyUIPageObject pageObject) {
    assertNotNull(pageObject);

    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);
  }
}

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

Schreibe einen Kommentar

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