Suche
Teil 16: Backend meets Frontend: Trainer for kids 6

UI-Elemente einzeln testen

Sven Ruppert

© Shutterstock / HelenField

Wir steigen tiefer in das Testen der UI ein. Unser Ziel ist es, einzelne UI-Komponenten auch einzeln zu testen. Und das mit möglichst wenig Aufwand, damit es auch bei einer größeren Anwendung nicht mühselig wird und wir schnell Feedback zu unseren Änderungen bekommen.

Im vorherigen Teil haben wir uns angesehen, wie wir eine Komponente erstellen und testen können. Ziel ist es nun, die Komponente einzeln zu testen und den Zyklus zwischen Quelltextänderung und Test durch den Entwickler möglichst klein zu halten. Wollen wir doch möglichst schnell und einfach Feedback bekommen, wie sich Änderungen auswirken.

Die Komponenten haben wir isoliert und sind damit in der Lage, diese einzeln zu verwenden. Nun benötigen wir nur noch einen Servlet-Container, der uns diese Komponente darstellt. Bisher haben wir uns ein Servlet und eine UI-Komponente geschrieben, die unsere selbstgeschriebene Komponente instanziiert und als ContentRoot setzte. Dieses Vorgehen ist allerdings ein wenig aufwendig, wenn wir für jede Komponente ein Servlet und eine UI-Komponente schreiben müssten. Wie also können wir dieses Vorgehen weiter extrahieren und generisch definieren? Ebenfalls ist noch zu klären, wie wir diese Komponenten mit einer Testabdeckung belegen. Aber beginnen wir zuerst mit dem Servlet und dem Undertow.

Im erstem Schritt werden wir zu jeder Komponente ein Servlet und die damit verbundenen UI-Klasse schreiben. Zusätzlich gibt es nun immer noch eine main-Methode, in der wir den Undertow dazu initialisieren. Als erste Übung ist dies hilfreich, um zu erkennen welche Teile der Quelltexte sich wie wiederholen werden.

Für diese Beispiele nehmen wir unsere Mathekomponente vom letzten Teil. In unserem Verzeichnis für die Testquelltexte beginnen wir mit einem Haupt-Package comp. Wir werden die jeweiligen originalen Package-Namen verwenden. Das führt in in unserem Fall zu comp.org.rapidpm.vaadin.trainer.modules.mainview.calc. Da wir nicht alle Versionen in einem Package realisieren wollen, beginne ich mit der einfachen Durchnummerierung der Verzeichnisse. v001, v002 usw. In jedem Verzeichnis erstellen wir nun eine Klasse mit den Namen Main und der Methode zum Start des Undertow. Dann bauen wir jeweils ein Servlet und eine UI-Klasse. Hiermit haben wir zwei Versionen erzeugt. Mir ist bewusst, das dies keine glorreiche Lösung ist. Aber es ist für die nun folgenden Experimente eine hervorragende Grundlage.

Extraktion Undertow

Recht offensichtlich ist, dass der Teil für den Start unseres Servlet-Containers redundant ist. Diesen zu extrahieren, ist somit der erste Schritt. Dazu benötigen wir lediglich eine Instanz mit einem Undertow und dem assoziierten Servlet. Bisher müssen keine weiteren Dinge gestartet werden. Ein wenig abstrakt formuliert handelt es sich um eine Funktion, mit der wir ein Servlet in eine Undertow-Instanz überführen: Function<ServletClass, Undertow>. Aber beginnen wir ganz von vorne.

Bisher hatten wir in der Klasse Main alles zusammen in der Main-Methode verpackt. Wir werden dies nun ein wenig umstrukturieren. Als erstes ziehen wir die Teile heraus, die sich mit Shiro beschäftigen. Diese sind definitiv optional. Oder anders formuliert: Bei einem Komponententest werden sie meistens nicht benötigt. Wir bekommen eine Instanz von DeploymentInfo und fügen nun die Shiro-Filter-Definitionen hinzu. Alle neu erzeugten Fragmente setzen wir in ein Interface mit dem Namen Infrastructure.

static DeploymentInfo addShiroFilter(DeploymentInfo deploymentInfo,
                                       String shiroFilterName,
                                       String shiroShiroFilterMappin) {
    return deploymentInfo.addListener(new ListenerInfo(EnvironmentLoaderListener.class))
                         .addFilter(new FilterInfo(shiroFilterName, ShiroFilter.class))
                         .addFilterUrlMapping(shiroFilterName, shiroShiroFilterMappin, REQUEST)
                         .addFilterUrlMapping(shiroFilterName, shiroShiroFilterMappin, FORWARD)
                         .addFilterUrlMapping(shiroFilterName, shiroShiroFilterMappin, INCLUDE)
                         .addFilterUrlMapping(shiroFilterName, shiroShiroFilterMappin, ERROR);

  }

Als nächstes extrahieren wir das Erzeugen der Instanz von DeploymentInfo, die wir als generisch ansehen. Weil wir davon ausgehen, dass wir sie in dieser Form immer so erzeugen werden. In unserem Fall sähe das wie folgt aus:

static DeploymentInfo getRawDeploymentInfo(ClassLoader classLoader,
                                             String defaultContextPath,
                                             String deploymentName) {
    return Servlets.deployment()
                   .setClassLoader(classLoader)
                   .setContextPath(defaultContextPath)
                   .setDeploymentName(deploymentName)

Wir können nun eine Instanz von DeploymentInfo mit oder ohne Shiro-Filter erzeugen.

  static DeploymentInfo deploymentInfo(ClassLoader classLoader,
                                       String defaultContextPath,
                                       String deploymentName) {
    return getRawDeploymentInfo(classLoader, defaultContextPath, deploymentName);
  }

  static DeploymentInfo deploymentInfo(ClassLoader classLoader,
                                       String defaultContextPath,
                                       String deploymentName,
                                       String shiroFilter,
                                       String filterMapping) {
    return addShiroFilter(getRawDeploymentInfo(classLoader, defaultContextPath, deploymentName), 
                          shiroFilter, 
                          filterMapping);
  }

Nun fehlt noch die Möglichkeit, ein Servlet hinzuzufügen.

  static DeploymentInfo addServlet(DeploymentInfo deploymentInfo,
                                   Class<? extends Servlet> servletClass,
                                   String filterMapping) {
    return deploymentInfo
        .addServlets(
            servlet(servletClass.getSimpleName(), servletClass).addMapping(filterMapping)
        );
  }

Um nun eine Instanz von der Klasse Undertow zu erzeugen, mit allen Parametern als variabel angegeben aber bisher nur mit einem Servlet, bekommen wir folgende Methode:

  static Optional<Undertow> undertow(ClassLoader classLoader,
                                     Class<? extends Servlet> servletClass,
                                     String defaultContextPath,
                                     String shiroFilter, String filterMapping,
                                     int port,
                                     String deploymentName) {

    DeploymentManager manager = Servlets
        .defaultContainer()
        .addDeployment(
            addServlet(
                (shiroFilter == null)
                ? deploymentInfo(classLoader, defaultContextPath, deploymentName)
                : deploymentInfo(classLoader, defaultContextPath, deploymentName, shiroFilter, filterMapping),
                servletClass,
                filterMapping)
        );

    manager.deploy();

    try {
      HttpHandler httpHandler = manager.start();
      PathHandler path = Handlers.path(redirect(defaultContextPath))
                                 .addPrefixPath(defaultContextPath, httpHandler);

      Undertow undertowServer = Undertow.builder()
                                        .addHttpListener(port, "0.0.0.0")
                                        .setHandler(path)
                                        .build();
      undertowServer.start();

      return Optional.of(undertowServer);
    } catch (ServletException e) {
      e.printStackTrace();
      return Optional.empty();
    }
  }

Da wir in unseren Beispielen davon ausgehen, dass wir einige Default-Einstellungen haben, zum Beispiel den Kontext-Pfad, können wir mit diesen Konstanten die Methodensignatur auf folgende zwei Varianten reduzieren.

  String DEFAULT_CONTEXT_PATH = "/";
  String DEFAULT_FILTER_MAPPING = "/*";
  String DEFAULT_SHIRO_FILTER_NAME = "ShiroFilter";
  Integer DEFAULT_HTTP_PORT = 8080;

  String DEFAULT_DEPLOYMENT_NAME = "ROOT.war";

//  Undertow factories

  static Optional<Undertow> undertow(ClassLoader classLoader,
                                     Class<? extends Servlet> servletClass) {
    return undertow(classLoader,
                    servletClass,
                    DEFAULT_CONTEXT_PATH,
                    null,
                    DEFAULT_FILTER_MAPPING,
                    DEFAULT_HTTP_PORT,
                    DEFAULT_DEPLOYMENT_NAME);
  }

  static Optional<Undertow> undertowWithDefaultShiro(ClassLoader classLoader,
                                                     Class<? extends Servlet> servletClass) {
    return undertow(classLoader,
                    servletClass,
                    DEFAULT_CONTEXT_PATH,
                    DEFAULT_SHIRO_FILTER_NAME,
                    DEFAULT_FILTER_MAPPING,
                    DEFAULT_HTTP_PORT,
                    DEFAULT_DEPLOYMENT_NAME);
  }

Hiermit reduzieren sich die beiden Klassen zum Starten der Anwendung oder des Komponententests auf einige wenige Zeilen.

public class Main {


  public static void start() {
    main(new String[0]);
  }

  public static void shutdown() {
    undertowOptional.ifPresent(Undertow::stop);
  }

  private static Optional<Undertow> undertowOptional;

  public static void main(String[] args) {
    undertowOptional = undertowWithDefaultShiro(
        Main.class.getClassLoader(),
        MainServlet.class
    );
  }
}

//Component Tests

public class CalcComponentDevelop {

  public static final String CONTEXT_PATH = "/";

  public static void start() {
    main(new String[0]);
  }

  public static void shutdown() {
    undertowOptional.ifPresent(Undertow::stop);
  }

  private static Optional<Undertow> undertowOptional;

  public static void main(String[] args) {
    Class<ComponentDevelopmentTestServlet> testServletClass = ComponentDevelopmentTestServlet.class;
    ClassLoader classLoader = CalcComponentDevelop.class.getClassLoader();
    undertowOptional = undertow(classLoader,testServletClass);
  }
}

Nun stellt sich noch die Frage aller Fragen. Wo ist der richtige Ort für das Interface Infrastructure? In den Testquelltexten ist es sicherlich nicht gut aufgehoben, da man aus den Produktionsquelltexten keinen Zugriff darauf hätte. In den Produktions-Quelltexten ist meiner Ansicht nach auch nicht der richtige Ort, da es doch ein sehr generisches Element ist. Ich habe mich dazu entschlossen, es entweder in ein separates Modul oder Projekt auszulagern.  Da wir noch weiter daran arbeiten werden, ist es in diesem Fall in das Projekt Functional-Vaadin-AddOns gewandert. Und dort vorerst in das Modul component-testing. Wir fügen nun die Abhängigkeit dem Projekt hinzu.

    <dependency>
      <groupId>org.rapidpm</groupId>
      <artifactId>rapidpm-functional-vaadin-component-testing</artifactId>
      <version>0.0.1-SNAPSHOT</version>
    </dependency>

Wirklich pro Komponente ein Servlet?

Wenn wir uns das Servlet ansehen, haben wir dort nicht viel, was uns im Wege stehen könnte.

@WebServlet(value = "/*")
@VaadinServletConfiguration(productionMode = false, ui = ComponentTestUI.class)
public class ComponentDevelopmentTestServlet extends VaadinServlet {}

Das Einzige was hier in der statischen Semantik verankert ist, ist das Attribut ui = ComponentTestUI.class. Hier wird die Verbindung zur UI-Klasse hergestellt. Also versuchen wir einen Weg zu finden, wie wir den Komponenten-Switch in die UI-Klasse hineinbekommen. In der UI-Klasse, hier ComponentTestUI, wird unsere Komponente einmal erzeugt und initialisiert, um danach als Hauptkomponente mit der Methode setContent(..) gesetzt zu werden.

@Theme("valo")
public class ComponentTestUI extends UI {
  @Override
  protected void init(VaadinRequest request) {
    setContent(new CalcComponentDesign());
    setSizeFull();
  }
}

Abb. 1: Annahme: Die Testkomponenten tauchen auf jeden Fall im Panel auf

Genau hier werden wir nun einhaken und es flexibler gestalten. Eigentlich wollen wir ja die Möglichkeit haben, dass wir beim Start oder zur Laufzeit eine Komponente angeben. Gehen wir zuerst den einfachen Weg und geben alle möglichen Auswahlkomponenten an. Dazu gehen wir davon aus, dass eine eigene Komponente zumindest in der von uns verwendeten Layout-Komponente (Panel) reibungslos funktioniert (Abb. 1). Immer wenn ein neuer Eintrag in der ComboBox selektiert wird, soll sich der Inhalt im Panel mit der ausgewählten Komponente füllen. Hierzu verwenden wir den SingleSelectionListener.

@Theme("valo")
public class ComponentTestUI extends UI {
  @Override
  protected void init(VaadinRequest request) {
    final VerticalLayout mainLayout = new VerticalLayout();
    final Panel testComponentPanel = new Panel("Component Test Area");
    final ComboBox<Class> classComboBox = new ComboBox<>();
    classComboBox.setWidth(100, Unit.PERCENTAGE);
    classComboBox.setItems(Stream.of(CalcComponent.class, DashboardComponent.class));
    classComboBox.addSelectionListener((SingleSelectionListener<Class>) event -> {
      Optional<Class> selectedItem = event.getSelectedItem();
      selectedItem.ifPresent(c -> {
        try {
          Component o = (Component)c.newInstance();
          testComponentPanel.setContent(o);
        } catch (InstantiationException | IllegalAccessException e) {
          e.printStackTrace();
        }
      });
    });
    mainLayout.addComponents(classComboBox, testComponentPanel);
    setContent(mainLayout);
    setSizeFull();
  }
}

Abb. 2: Jetzt können wir jeden Komponente einfach testen

Wir gehen hier wieder davon aus, dass eine Komponente ohne weitere Maßnahmen mit dem Default-Konstruktor erzeugt werden kann.

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.

Fazit

Wir haben in diesem Teil gesehen, wie wir die Infrastrukturteile in ein anderes Projekt auslagern, da sie sehr generisch sind. Hierzu ist nun das Open-Source-Projekt Functional-Vaadin-AddOns Teil dieser Artikelreihe geworden. Ebenfalls haben wir jetzt den Grundaufbau für das Testen der einzelnen Komponenten erstellt. Der Zyklus zwischen Quelltextänderung und visueller Rückmeldung ist sehr kurz. Das ermöglicht ein zügiges Arbeiten. Bisher haben wir allerdings Technologien wie CDI oder ähnliches ausgelassen. Wir werden diese in einem späteren Teil behandeln. Ebenfalls werden wir die Frage angehen, wie wir unsere Ergbnisse in einer CI-Strecke verwenden können. 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.