Wartbare Rich Web Applications mit AngularJS – Teil 2

Im letzten Artikel haben wir anhand einer Demo-Anwendung veranschaulicht, wie sich mit AngularJS auf einfache Weise gut wartbare Rich Web Applications erstellen lassen. Die wichtigsten Konzepte von AngularJS wurden vorgestellt: Die Umsetzung des Model-View-Controller-Patterns, die Erweiterung von HTML durch Directives und das Routing zur Navigation innerhalb der Anwendung.

Dieser Artikel erläutert die Einbindung von AngularJS in einen Maven-basierten Build-Prozess und stellt vor, wie sich automatisierte Unit-Tests und Ende-zu-Ende-Tests für AngularJS-Anwendungen erstellen lassen.

Einleitung

Build-Prozess mit Maven

Maven ist ein in der Java-Welt weit verbreitetes Build-Werkzeug, um Anwendungen automatisch zu bauen, testen, paketieren und auszuliefern. Ein automatisierter und auf jedem Rechner wiederholbarer Build-Prozess bildet dabei insbesondere die Grundlage für Continuous-Integration-Systeme, die die Anwendung zeitnah auf dedizierten Servern bauen und testen. Damit werden Änderungen verschiedener Entwickler frühzeitig integriert getestet und etwaige Probleme schnell erkennbar.

Mit Maven lassen sich auch JavaScript-basierte Projekte automatisch bauen und testen. Dies ermöglicht, die JavaScript-Anteile komplexer Server-Projekte in den Build-Prozess des Gesamtsystems zu integrieren, ohne dass ein weiteres Build-Werkzeug benötigt wird. Diese „Mavenisierung“ von JavaScript-Projekten und dessen Vorteile haben wir in einem vergangenen Blog-Post vorgestellt. Im Folgenden konzentrieren wir uns vorrangig auf die Besonderheiten im Kontext von AngularJS und speziell auf die Integration von Tests in AngularJS-Projekte.

Testen von AngularJS-Anwendungen

Softwaretests lassen sich u.a. danach klassifizieren, ob Systemkomponenten isoliert oder im Zusammenspiel getestet werden. Hierbei unterscheidet man zwischen Unit-, Integrations- und Ende-zu-Ende-Tests.

Unit-Tests konzentrieren sich auf Sourcecode-Einheiten (Units) eines Systems, z.B. einzelne Klassen oder Packages. Integrationstests prüfen die Integration eines Systems oder Systemteils mit Drittsystemen, wie z.B. einer Datenbank oder einem Java-EE-Container, um zu ermitteln, ob diese im Zusammenspiel korrekt funktionieren. Unit- und Integrationstests erfordern, Abhängigkeiten zu Systemteilen, die nicht mitgetestet werden sollen (soweit vorhanden), während des Tests durch Attrappen (Mocks) zu ersetzen. Im Kontext von Rich Web Applications besteht dabei insbesondere die Anforderung, die Client-Anteile isoliert vom Server zu testen.

Ende-zu-Ende-Tests (auch Akzeptanztest) zeichnen sich dadurch aus, ein System in seiner vollständigen Tiefe als Black-Box zu testen, einschließlich der Benutzungsschnittstelle und des Servers. Der Test nimmt hierbei die Warte eines potentiellen Nutzers ein und steuert die Benutzungsoberfläche des Systems, um ein bestimmtes Feature zu testen. Technisch löst der Test UI-Ereignisse in einem automatisierten Browser aus und definiert Erwartungen an resultierende Reaktionen der Benutzungsschnittstelle.

Unit-Tests

Unit-Tests lassen sich in AngularJS-Anwendungen mit Jasmine definieren, einem Framework zur Implementierung von JavaScript-Tests. Jasmine ist nicht vom HTML-DOM abhängig, so dass Unit-Tests von JavaScript-Code unabhängig vom HTML der UI möglich sind. Zu Jasmine gibt es zudem AngularJS-spezifische Erweiterungen zum Testen von AngularJS-Anwendungen.

Das folgende Code-Beispiel zeigt einen Test des BlogPostService der Demo-Anwendung, der die Methode fetchBlogPosts() daraufhin überprüft, ob sie einen Request mit erwarteten Eigenschaften an den Server übermitteln würde (Zeile 15). Dabei wird mit Hilfe von Mocks vermieden, dass zur Ausführung des Tests ein Server benötigt wird.

describe('BlogPostService test suite', function() {
  beforeEach(module('Services'));

  // ...

  describe('BlogPostList tests', function() {
    var data, responseData,
      requestMethod, requestUrl;

    it('should send get request to "rest/blog"', inject(
      function($httpBackend, BlogPostService) {
        data = getJSONFixture('blog-post-list.json');
        $httpBackend.expectGET('rest/blog').respond(data);

        BlogPostService.fetchBlogPosts().
          success(function(data, status, headers, config) {
            responseData = data;
            requestMethod = config.method;
            requestUrl = config.url;
          });
        $httpBackend.flush();

        expect(responseData).not.toBe(undefined);
        expect(requestMethod).toEqual("GET");
        expect(requestUrl).toEqual("rest/blog");
      }
    ));
  });
  // ...
});

Die Definition von Jasmine-Tests orientiert sich am Konzept von Fluent Interfaces: Der Code ähnelt natürlicher Sprache und wird damit leichter lesbar.

Im Beispiel wird zunächst eine Test-Suite definiert. Hierzu erfolgt ein Aufruf der globalen Jasmine-Funktion describe() mit zwei Parametern: Ein String, der den Titel der Test-Suite bestimmt und eine Funktion, die die Implementierung der Suite enthält. Eine Test-Suite kann mehrere Testspezifikationen enthalten, die jeweils mit Hilfe der Funktion it() definiert werden. Diese erwartet ebenfalls die Übergabe der zwei Parameter. Innerhalb einer Testspezifikation erfolgt dann eine Bestimmung des zu erwartenden Ergebnisses mittels der Funktion expect().

Um im Test Zugriff auf den BlogPostService zu erhalten, muss zunächst das Modul, das diesen Service enthält, im Test registriert werden (Zeile 2). Die dazu verwendete Funktion module() ist eine Erweiterung des Jasmine-Frameworks durch AngularJS. Ist das Modul registriert, kann der zu testenden Service mit der Hilfsfunktion inject() (Zeile 10) in eine Testspezifikation injiziert werden. Im Beispiel ist dies der vordefinierte Mock-Service $httpBackend und der zu testende BlogPostService.

Die Testspezifikation prüft, ob die Funktion fetchBlogPosts() eine Serveranfrage vom Typ GET an eine bestimmte Adresse tätigt. Dafür wird zuerst der HTTP-Mock-Service mit erwartetem Request-Typ und erwarteter Request-Adresse konfiguriert (Zeile 13). Außerdem werden die Mockdaten (data) angebeben, die der Mock-Service zurückgeben soll. Diese Mockdaten werden aus Gründen der Übersichtlichkeit in eine JSON-Datei ausgelagert (hier: blog-post-list.json).

Somit sind die Erwartungen nun definiert und die zu testende Funktion fetchBlogPosts() kann aufgerufen werden (Zeile 15). Da es sich um eine asynchrone Funktion handelt, muss zur Verifizierung des Ergebnisses ein Callback (success) definiert werden (Zeile 16-20), der im Erfolgsfall aufgerufen wird. Nun wird die Funktion flush() des HTTP-Mock-Service aufgerufen, die so lange blockiert, bis alle wartenden Anfragen abgearbeitet wurden. Abschließend erfolgt eine Überprüfung der Erwartungen (Zeile 23-25).

Die Ausführung der Unit-Tests wird später im Abschnitt Integration in Maven beschrieben.

Ende-zu-Ende-Tests

Zur Implementierung von Ende-zu-Ende-Tests von AngularJS-Anwendungen nutzen wir Schnittstellen, die durch den AngularJS Scenario Runner bereitgestellt werden. Diese ermöglichen es, die Anwendung in einem Browser per Test fernzusteuern. Der folgende Test verwendet die Benutzeroberfläche mit dem Ziel, ein Benutzerkonto in der Bloganwendung anzulegen.

describe('E2E Tests', function() {
  describe('Registration test', function() {
    it('should redirect to "/login" after successful registration',
      function() {
        browser().navigateTo('/blog/#/register');
        input('user.username').enter('jd');
        input('user.password').enter('pwd');
        input('user.firstname').enter('John');
        input('user.surname').enter('Doe');
        input('user.email').enter('john@doe.com');
        element(
          '#registerSubmitBtn',
          'registration form submit button'
        ).click();
        expect(browser().location().url()).toBe('/login');
      }
    );
  });
});

Ein Test-Szenario wird in einer Syntax definiert, die der von Jasmine ähnelt. Mit vordefinierten Funktionen kann direkt mit der Oberfläche der Anwendung interagiert werden, z.B. mittels navigateTo(), um die Seite des Registrierungsformulars aufzurufen (Zeile 5). Eine Übersicht aller Funktionen, die AngularJS für diesen Zweck anbietet, ist hier zu finden.

Nachdem der Aufruf der Registrierungsseite erfolgt ist, wird das Formular in den Zeilen 6-10 ausgefüllt und anschließend per Klick-Event auf den Submit-Button abgeschickt. Abschließend erfolgt die Überprüfung der Erwartungen, hier die Weiterleitung auf die Login-Seite.

Die Ausführung der Ende-zu-Ende-Tests wird im folgenden Abschnitt beschrieben.

Integration in Maven

Unit-Tests

Zur Integration der Unit-Tests in den Maven-Buildprozess wird das jasmine-maven-plugin benötigt. Dieses wird folgendermaßen in der pom.xml des Maven-Projekts konfiguriert:

<plugin>
  <groupId>com.github.searls</groupId>
  <artifactId>jasmine-maven-plugin</artifactId>
  <version>1.2.0.0</version>
  <extensions>true</extensions>
  <executions>
    <execution>
      <goals>
        <goal>test</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <jsSrcDir>${project.basedir}/src/main/webapp/js</jsSrcDir>
    <jsTestSrcDir>${project.basedir}/src/test/webapp/js/spec</jsTestSrcDir>
    <preloadSources>
      <!-- libraries and frameworks here -->
    </preloadSources>
    <sourceIncludes>
      <!-- AngularJS components here -->
    </sourceIncludes>
  </configuration>
</plugin>

Durch die Angabe des Maven-Goals test (Zeile 9) werden die Unit-Tests an die Maven-Testphase gebunden. Damit werden sie nun automatisch während des Builds ausgeführt, einschließlich Builds auf CI-Servern.

Während der Entwicklung möchte man neue Änderungen sowohl häufig als auch schnell testen können. Der Maven-Buildprozess ist hierfür unter Umständen zu zeitintensiv. Hier bietet das jasmine-maven-plugin die Möglichkeit, eine spezielle Server-Umgebung zu starten, innerhalb dessen der Jasmine Spec Runner ausgeführt werden kann. Dieser ermöglicht eine manuelle Ausführung der Unit-Tests in einem Browser. So kann schnell überprüft werden, ob die Änderungen Fehler verursacht haben. Diese Server-Umgebung lässt sich wie folgt starten:

$ mvn jasmine:bdd

Allerdings ergibt sich hierbei ein Problem beim Laden der Mock-Daten, die in unserem Beispiel in JSON-Dateien ausgelagert sind. Damit diese in der Jasmine-Server-Umgebung korrekt referenziert werden können, muss der Pfad zu diesen Mocks explizit gesetzt werden. Hierfür wird eine JavaScript-Datei mit folgendem Inhalt erstellt:

jasmine.getJSONFixtures().fixturesPath = 'src/test/webapp/js/spec/javascripts/fixtures/json';

Damit diese Pfadänderung nur in der Jasmine-Server-Umgebung greift, definieren wir im Beispiel ein passendes Maven-Profil in der pom.xml:

<profile>
  <id>jasmine-spec-runner</id>
  <build>
    <plugins>
      <plugin>
        <groupId>com.github.searls</groupId>
        <artifactId>jasmine-maven-plugin</artifactId>
        <configuration>
          <preloadSources combine.children="append">
            <source>${project.basedir}/src/test/webapp/js/lib/jasmine-jquery-settings/jasmine_spec_runner_fixtures_path.js</source>
          </preloadSources>
        </configuration>
      </plugin>
    </plugins>
  </build>
</profile>

Im preloadSources Tag wird die gerade erstellte JavaScript-Datei angegeben (Zeile 10), wodurch diese nur im angegebenen Profil geladen wird. Um die Einstellungen des jasmine-maven-plugins nicht zu überschreiben, wird über den Zusatz combine-children="append" (Zeile 9) sichergestellt, dass die Datei zusätzlich und nicht anstelle der anderen Quelldateien geladen wird.

Nun muss beim Start des Maven-Goals bdd das Profil angegeben werden.

$ mvn jasmine:bdd -Pjasmine-spec-runner

Dies startet den Testserver und die Tests können daraufhin durch den Aufruf der angegeben URL ausgeführt werden. In unserem Beispiel erhalten wir die folgenden Testergebnisse:

Jasmine Spec Runner - Tests passed

Bei Testfehlschlägen würden wir eine Ausgabe wie die folgende erhalten:

Jasmine Spec Runner - Tests failed

Ende-zu-Ende-Tests

Die Integration der Ende-zu-Ende-Tests in den Build-Prozess erfolgt in unserem Beispiel in drei Schritten:

  1. Automatischer Start des Webservers
  2. Starten eines fernsteuerbaren Browsers
  3. Ausführung der AngularJS-Ende-zu-Ende-Tests

Um den Webserver automatisch zu starten (Schritt 1) und später wieder zu beenden, nutzen wir Arquillian, ein in der Java-EE-Welt verbreitetes Framework zur Ausführung von Tests gegen einen eingebetteten Server. Hierzu definieren wir ein separates Maven-Modul (im Beispiel: angularjs-blog-e2e-tests) mit einem einzelnen Arquillian-Test:

@RunWith(Arquillian.class)
public class E2ETest {

  @Deployment(testable = false)
  public static WebArchive accessDeployment() {
    File war = MavenDependencyResolver.resolve(
        "de.akquinet.angularjs",
        "angularjs-blog-web",
        "1.0-SNAPSHOT",
        "e2etest",
        "war"
    );
    return ShrinkWrap.createFromZipFile(WebArchive.class, war);
  }

  @Test
  public void runE2ETest() { // ... }
}

Die zu startende Webanwendung wird über den Rückgabewert der Methode accessDeployment() (Zeile 5) definiert. In unserem Beispiel nutzen wir eine selbstdefinierte Hilfsklasse MavenDependencyResolver, die unsere Blog-Anwendung über ihre Maven-Koordinaten referenziert und auflöst.

Die Testklasse enthält einen einzigen Test, in welchem wir zunächst einen fernsteuerbaren Browser starten (Schritt 2). Dieser Test sieht wie folgt aus:

// ...
  @Test
  public void runE2ETest() {
    WebDriver driver = new FirefoxDriver();
    driver.get("http://localhost:8180/blog/angularjs-scenario-runner/runner.html");

    ExpectedCondition e = new ExpectedCondition() {
      public Boolean apply(WebDriver d) {
        return !d.findElement(By.id("application"))
                 .isDisplayed();
      }
    };
    Wait w = new WebDriverWait(driver, 20);
    w.until(e);

    // ...

    Assert.assertEquals(error.getText(), "0 Errors");
    Assert.assertEquals(failure.getText(), "0 Failures");
    driver.close();
  }
}

Mit Hilfe des Testframework Selenium und dessen WebDriver-API wird der eingebettete Browser gestartet und der AngularJS Scenario Runner (Schritt 3) ausgeführt (Zeile 4, 5).

Damit der Maven-Build-Prozess bei einem Testfehlschlag ebenfalls fehlschlägt, definieren wir zwei Assertions (Zeile 18, 19). Um zu erreichen, dass diese erst nach Durchlaufen aller Tests geprüft werden, muss dem WebDriver mitgeteilt werden, dass er auf das Eintreten eines Ereignisses warten soll. Da der Scenario Runner die Anwendung innerhalb eines iFrames lädt und diesen nach Beendigung aller Tests wieder entfernt, bietet sich die Überprüfung der Sichtbarkeit des iFrames an. So kann sichergestellt werden, dass alle Tests ausgeführt worden sind und die Auswertung des Ergebnisses beginnen kann. Die Bestimmung der Wartebedingung erfolgt in den Zeilen 7-14.

Damit ist eine automatische Ausführung der Ende-zu-Ende-Tests während des Maven-Build-Prozesses erreicht.

Fazit

Im ersten Artikel haben wir mit Hilfe einer Demo-Anwendung vorgestellt, wie sich Rich Web Applications mit AngularJS entwickeln lassen. Dabei haben wir die wichtigsten Konzepte von AngularJS vorgestellt: Die Erweiterung von HTML durch Directives, bidirektionales Data-Binding, die Umsetzung des Model-View-Controller-Patterns sowie das Routing-Konzept. In diesem zweiten Artikel haben wir gezeigt, wie Unit- und Ende-zu-Ende-Tests implementiert und diese in einen Maven-basierten Build-Prozess einer Server-Anwendung integriert werden können.

Als vergleichsweise neue Lösung gestaltet sich die Einbindung von AngularJS in Unternehmensanwendung natürlich riskanter als bei erprobteren und standardisierteren Lösungen. Ein von Google eingereichter W3C-Entwurf „Web Components“, der dem Konzept der Directives sehr ähnlich ist, zeigt aber bereits eine mögliche Standardisierungsrichtung auf.

AngularJS automatisiert bestimmte Vorgänge, wie das bidirektionale Binding und die Injection von Dependencies. Dies führt einerseits zu einer Vereinfachung der JavaScript-Logik, kann aber andererseits die Fehlersuche in komplexen Projekten erschweren.

Das Konzept der Directives führt zu einer klaren Trennung von statischer UI-Beschreibung und dynamischer UI-Logik. Views werden in HTML definiert und durch Angabe von Directives mit JavaScript-Logik verknüpft. Somit wird eine saubere Trennung von Präsentation und Funktionalität erreicht. Durch das bidirektionale Data-Binding entfällt der Code zur Überwachung von Benutzerinteraktionen sowie Code zur DOM-Manipulation.

Zur Strukturierung von JavaScript bietet AngularJS ein Modularisierungskonzept, um zusammenhängende Komponenten wie Controller und Services zu kapseln. Abhängigkeiten unter den Modulen werden mittels Dependency Injection aufgelöst, wodurch diese lose aneinander gekoppelt sind.

All diese Punkte tragen dazu bei, dass AngularJS-Anwendungen sehr gut testbar sind. Mit Jasmine und dem Scenario Runner von AngularJS können sowohl Unit-Tests als auch Ende-zu-Ende-Tests realisiert werden. Auch die Integration der Tests in einen Maven-basierten Build-Prozess ist möglich. Während die Unit-Tests problemlos mit Hilfe des jasmine-maven-plugins integriert werden können, ist die Einbettung der Ende-zu-Ende-Tests zumindest initial etwas aufwändiger.

Die Anbindung von AngularJS-Anwendungen an beliebig komplexe Server-Backends mit REST-Schnittstelle ist möglich und gestaltet sich durch in AngularJS enthaltene Services sehr komfortabel und einfach.

Alles in allem macht AngularJS einen sehr vielversprechenden und durchdachten Eindruck.

Bei Fragen und Anmerkungen kontaktieren Sie uns gerne per E-Mail:

philipp.kumar [at] akquinet.de
till.hermsen [at] akquinet.de

3 Gedanken zu “Wartbare Rich Web Applications mit AngularJS – Teil 2

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s