Über unsMediaKontaktImpressum
Martin Reinhardt 04. Juli 2017

Serenity BDD – Integrationstest mit Stil

Der Trend zu mehr Agilität hat in vielen Unternehmen zur verstärkten
inkrementell-iterativen Softwareentwicklung geführt. Dabei entstehen immer mehr Zwischenversionen von Software oder Softwarekomponenten, die qualitätsgesichert werden müssen. Die engere Verbindung von Entwicklung und Betrieb ("DevOps") führt dazu, dass auch die Qualitätssicherung stärker eingebunden und automatisiert werden muss. Daneben gibt es zwei Trends in den letzten Jahren, die ein geeignetes Testautomatisierungswerkzeug nötig werden lassen: Single Page Applications ("SPA") finden immer stärkere Verwendung und einzelne Software-Systeme kommunizieren immer stärker miteinander. Aktuelle Entwicklungen wie das Internet of Things oder Microservices sind hier nur als zwei Beispiele zu nennen. Damit wächst der Bedarf, sowohl die Oberflächen als auch die Schnittstellen automatisch zu testen. Eine Möglichkeit das zu tun ist die Nutzung des Serenity Frameworks.

Mittels Continuous Delivery versucht man dieses Problem zu adressieren, in dem viele Schritte wie Konfiguration der Umgebung/Anwendung und vor allem die Testausführung automatisiert werden. Diese Teilschritte werden aufgeteilt, um möglichst schnell und kontinuierlich Zwischenergebnisse ("Continuous Integration" – CI) zu bekommen. In der Praxis nutzt man dazu Buildpipelines. Ein Hindernis kann dabei sein, dass Testumgebungen nicht zur Verfügung stehen. Das heißt, das Entwicklungsteam muss sich eine Strategie überlegen, wie es selbst – beispielsweise mit DevOps-Mitteln – Umgebungen erstellen kann. Continuous Delivery nutzt dieselben Tools, welche auch für den Betrieb genutzt werden.

Außerdem wird ein hohes Maß an Automatisierung benötigt und der Fokus wird auf schnelles Feedback gelegt. So werden viele Tests parallel in automatisierten Pipelines durchlaufen, um Fehler schneller zu finden und ein qualitätsgesichertes Softwarepaket zu erhalten, das in Produktion genutzt werden kann. Neben den Anforderungen müssen vom Entwicklungsteam auch noch gefundene Fehler behoben werden.

Auch wenn Continuous Delivery auf ein hohes Maß von Automatisierung setzt, werden dennoch weiterhin manuelle Tests benötigt. Diese werden häufig in Form von Freigabeschritten in Build-Pipelines umgesetzt.

Gemäß der Testpyramide von Mike Cohn [1] sind hoch automatisierte Integrationstests nämlich zum Einen teuer und zum Anderen auch störanfällig (s.Abb.2).

Dabei wird deutlich, dass Unit-Tests günstiger sind und schneller ablaufen. Günstiger deswegen, weil Unit-Tests Fehler möglichst frühzeitig finden und durch die geringe Komplexität einfacher bestimmten Codeabschnitten zuzuordnen sind. Außerdem ist es einfacher, die genaue Fehlerursache zu finden – bei Integrationstests ist die Fehleranalyse ungleich aufwändiger und somit teurer.

Man versucht also, einzelne Komponenten funktional mit Unit-Tests isoliert zu überprüfen. Die Integration der Komponenten kann teilweise auch über Unit-Tests verifiziert werden. Akzeptanztests bzw. Integrationstests testen meist vollintegrierte Komponenten in der Gesamtanwendung. Dazu zählen Oberflächen- und auch Schnittstellentests. Daneben werden Performancetests und statische Codeanalysen genutzt, um auch die nichtfunktionalen Anforderungen zu verifizieren.

Damit ergibt sich meist, dass die Testpyramide nacheinander von unten nach oben durchlaufen wird, um mit den schnell ablaufenden Unit-Tests zügig Fehler zu isolieren. Das wird in Schritten der Build-Pipelines umgesetzt. Ist ein Schritt erfolgreich absolviert, wird anschließend der nächste durchlaufen. Dieses "Stop the Line"-Prinzip aus "Lean Manufacturing" stellt sicher, dass die Änderung die zum Build geführt hat, keine Fehler enthält und die Software die Anforderungen immer noch erfüllt. Dabei ist nicht nur die Oberfläche zu testen, sondern in einem hohen Maße auch die Schnittstellen. Im Artikel wird sich auf eine REST-API und eine Weboberfläche konzentriert.

Dabei soll dieser Artikel zeigen wie man sinnvoll durch geeignete Framework-Auswahl, sowie mit Build- und Test-Tools eben diesen Zustand erreicht. Für diesen Artikel wird eine SpringBoot-basierende Anwendung genutzt (s.Abb.3).

Mit diesem Technologie-Stack wird eine Basis gelegt, um gemäß agilen Prinzipien kontinuierlich das Produkt weiterzuentwickeln. So hilft zum Beispiel Flyway bei der Datenbankmigration. Alle benötigten Skripte – egal ob SQL oder Java – werden in einem Ordner gespeichert, der im Classpath verfügbar sein muss. Flyway durchsucht automatisch diesen Order und erkennt, zu welcher Version ein Skript gehört. Dabei wird ein einfaches Namensschema vorgeschrieben. Damit sind Schema-Anpassungen erstaunlich einfach.

Damit die einzelnen Schritte auch für alle Teammitglieder transparent sind, sollte ein Continuous Integration-Server wie Jenkins genutzt werden. Mit der aktuellen Jenkins-Version 2 kommt neben einer höheren Zugriffssicherheit und einer verbesserten Plug-in-Auswahl auch das Pipeline-Plug-in mit.

Auf zu blauen Ufern

Die einzelnen Pipeline-Schritte sind dabei frei definierbar. Außerdem wird im Beispiel das Blue Ocean-Plugin [2] genutzt. Die Weboberfläche von Jenkins selbst ist einfach in die Jahre gekommen und auch die Erweiterung durch Plugins ist nicht wirklich geplant gewesen. Dem wollen die Entwickler nun mit einem Plugin Abhilfe schaffen: Jenkins Blue Ocean soll die User Experience deutlich verbessern. Dabei steht aber nicht nur der visuelle Eindruck im Vordergrund. Vielmehr sollen auch neue Funktionen mehr Informationen übersichtlicher darstellen. Zudem wurde schon eingangs an die Erweiterung durch Jenkins-Plugins gedacht (s. Abb.4).

Die neue Oberfläche hilft dabei, sofort das Wesentliche zu sehen und stellt dabei dennoch auch Details übersichtlich dar. So wird das Scrollen durch mehrere Zeilen an Logs nicht mehr nötig sein, da Jenkins Blue Ocean nur die Abschnitte im Log anzeigt, die zum jeweiligen Schritt im Build gehören.

Besonders nützlich ist das personalisierte Dashboard. In vielen größeren Projekten benötigt man einen schnellen Überblick über verschiedene Jobs. In der Vergangenheit wurden dafür oft extra Dashboards konfiguriert und teilweise entwickelt, die dann die einzelnen Jobs darstellen. Mit Blue Ocean kann sich jeder Nutzer seine eigene Übersicht an wichtigen und oft genutzten Pipelines, Pull Requests etc. konfigurieren. Dabei ist das Dashboard aber nicht statisch, sondern erkennt Jobs, die die Aufmerksamkeit des Nutzers erfordern und stellt sie heraus. Außerdem stellt das Dashboard Git-Branches und Pull-Requests direkt dar und validiert diese.

Fehlerhafte Tests werden in Blue Ocean übersichtlich dargestellt (s.Abb.5).

Am Beispiel-Projekt wird schnell deutlich, was mit der Fokussierung gemeint ist. Der Header wird rot eingefärbt bei Fehlern und die Konsolen-Ausgabe springt zum jeweiligen Schritt inklusive Ausgabe (s.Abb.6).

Obwohl die Tests (die bisher gelaufen sind) erfolgreich waren, ist schnell sichtbar, dass der Job nicht erfolgreich gelaufen ist (s.Abb.7).

Dabei kann das Build-Skript für den Job auch im Versionskontrollsystem hinterlegt werden und es gibt sehr gute Unterstützung für mehrere Branches, ohne dass separate Jobs angelegt werden müssen.

Listing 1


properties properties: [
  [$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', artifactDaysToKeepStr: '',
artifactNumToKeepStr: '', daysToKeepStr: '30', numToKeepStr: '10']],
  disableConcurrentBuilds()
]
timeout(60) {
  node {
    def buildNumber = env.BUILD_NUMBER
    def branchName = env.BRANCH_NAME
    def workspace = env.WORKSPACE
    def buildUrl = env.BUILD_URL
    def mvnHome = tool 'Maven 3.3.1'
    env.JAVA_HOME = tool 'jdk-8-oracle'
    env.PATH = "${env.JAVA_HOME}/bin:${mvnHome}/bin:${env.PATH}"
    // PRINT ENVIRONMENT TO JOB
    echo "workspace directory is $workspace"
    echo "build URL is $buildUrl"
    echo "build Number is $buildNumber"
    echo "branch name is $branchName"
    echo "PATH is $env.PATH"
  
    try {
      stage('Clean workspace') {
        deleteDir()
      }
      
      stage('Checkout') {
       checkout scm
      }
      
      stage('Build') {
        sh "${mvnHome}/bin/mvn clean package"
      }
      
      stage('Unit-Tests') {
        sh "${mvnHome}/bin/mvn test -Dmaven.test.failure.ignore"
        step([
          $class     : 'JUnitResultArchiver',
          testResults: 'angular-spring-boot-webapp/target/surefire-reports/TEST*.xml'
        ])
      }
    
      stage('Integration-Tests') {
      node('mac') {
        env.JAVA_HOME = '/Library/Java/JavaVirtualMachines/jdk1.8.0_25.jdk/Contents/Home/jre'
        checkout scm
        sh "mvn -Pdocker -Ddocker.host=tcp://127.0.0.1:2375 clean verify -Dmaven.test.failure.ignore"
        step([
          $class     : 'ArtifactArchiver',
          artifacts  : '**/target/*.jar',
          fingerprint: true
        ]) 
        step([
          $class     : 'JUnitResultArchiver',
          testResults: 'angular-spring-boot-webapp/target/failsafe-reports/TEST*.xml'
        ])
        publishHTML(target: [
          reportDir            : 'angular-spring-boot-webapp/target/site/serenity/',
          reportFiles          : 'index.html',
          reportName           : 'Serenity Test Report',
          keepAll              : true,
          alwaysLinkToLastBuild: true,
          allowMissing         : false    
        ]) 
      }
    } 
    
  } catch (e) {
      rocketSend channel: 'holi-demos', emoji: ':rotating_light:', message: 'Fehler'
      throw e
  } 
}
}       

So werden in dem Beispiel-Projekt neben den klassischen JUnit-Tests auch Jasmine-Unit-Tests ausgeführt, wie man sie in modernen Webprojekten häufiger findet. Über das Frontend-Maven-Plugin [3] lassen sich diese Frontend-Tools bequem mit Maven verbinden, ohne sich gegenseitig zu stören. Die Webentwickler können weiterhin mit Grunt und Bower arbeiten und die Backendentwickler können direkt alle nötigen Artefakte bauen – ohne Kenntnisse von NPM und Installation der Tools haben zu müssen. Das Plugin erleichtert auch die Integration in eine CI-Umgebung wie Jenkins, da es alle nötigen Frontend-Tools wie Node und NPM lokal für das Projekt installiert und die Build-Tasks anstößt. Webentwickler hingegen können weiterhin ihre bekannten Tools einsetzen. 

Über die Multibranch-Jobkonfiguration in Jenkins 2 ist es möglich, den Job in mehreren Branches auszuführen. Das ist besonders interessant, wenn man mit Release-Branches arbeitet und Hotfixes in derselben Pipeline testen will. Bisher musste dafür entweder ein separater Jenkins-Job angelegt oder aber mit Parametern gearbeitet werden. Gerade bei langlebiger Software kann sich aber durchaus der Build- und/oder Deploy-Prozess zwischen Releases ändern. Dadurch, dass man die Job-Konfiguration in Form einer DSL mit eincheckt, wird nun Abhilfe geschaffen (s.Abb.8).

Interessant für größere Umgebungen ist auch das Feature, die DSL um eigene Bibliotheksfunktionen zu erweitern. Diese können natürlich auch in einem Versionskontrollsystem hinterlegt werden.

Container to the rescue

Gerade bei umfangreicheren Projekten reichen meist Unit-Tests nicht aus und es werden Integrationstests benötigt. Man benötigt dazu in jedem Fall ein System in dem man seine Anwendung ausführen kann. Häufig steht man dabei vor dem Problem, dass Testsysteme schwierig zu bekommen sind und vom Entwicklungsteam nicht komplett kontrollierbar sind. Dabei kann der Einsatz von Docker sinnvoll sein. Dabei werden Integrationstests gegen ein Docker-Image ausgeführt, das im normalen Build erstellt, gestartet und wieder gestoppt wird. Dazu nutzt man ein Maven-Plugin, das es auch erlaubt, die Orchestrierung mehrerer Images inklusive Verlinkung und Ersetzung von Build-Artefakten zu nutzen.

Listing 2:

<plugin>
    <groupId>com.alexecollins.docker</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <configuration>
        <!-- (optional) remove images created by Dockerfile (default true) -->
        <removeIntermediateImages>true</removeIntermediateImages>
        <!-- (optional) do/do not cache images (default false), disable to
            get the freshest images -->
        <cache>true</cache>
        </configuration>
  <executions>
    <execution>
      <goals>
        <goal>stop</goal>
        <goal>clean</goal>
        <goal>package</goal>
        <goal>start</goal>
      </goals>
      <phase>package</phase>
    </execution>
  </executions>
</plugin>

Neben dem Dockerfile benötigt man eine YAML-Datei für die dynamischen Anteile:

Unresolved directive in article.adoc 
    - include::https://raw.githubusercontent.com/holisticon/continuous-delivery-demo/master/angular-spring-boot-webapp/src/main/docker/ngspring/conf.yml[]

YAML-Datei:


# additional data require to create the Docker image
packaging:
  # files to add to the build, usually used with ADD in the Dockerfile
  add:
    - target/${project.build.finalName}.jar
# optional list of port to expose on the host
ports:
  - ${server.port}
healthChecks:
  pings:
    - url: http://localhost:${server.port}/
      timeout: 120000
links:
  - mysql:db
# how long in milliseconds to sleep after start-up (default 0)
sleep: 5000
# tag to use for images
tag: ngspring/${project.artifactId}-app:${project.version}

    Contact GitHub API Training Shop Blog About   

Selenium-Tests mit Stil

Dort werden dann die Build-Artefakte hinterlegt. Es können auch mehrere Docker-Images im Rahmen der Integrations-Phase erstellt werden. Mit dieser Basis können nun zum Beispiel Oberflächentests gegen das Docker-Image mit der eigenen Anwendung ausgeführt werden. Nutzt man dabei ein Tool wie Serenity erhält man auch einen aussagekräftigen Report (s.Abb.9).

Dieser Report hilft schnell, das Feedback aus dem Test einzuarbeiten, ohne zwingend den Test nochmals lokal ausführen zu müssen, da man durch die Aufbereitung der Testschritte in vielen Fällen schon den Fehler sehen kann (s.Abb.10).

Oberflächentests sind extrem wertvoll, um kritische Pfade abzutasten, z. B. als Smoketest vor einem Live-Deployment. Mit Serenity ist es möglich, Oberflächentests lesbarer, wartbarer und wiederverwendbar umzusetzen. Dabei wird das Page-Object-Pattern umgesetzt, um diese Anforderung umzusetzen (s.Abb.11).

Somit finden sich die Prinzipien der Modularisierung und Wiederverwendbarkeit aus der Softwareentwicklung auch im Bereich der Testautomatisierung wieder. Außerdem können Tests neben klassischem Java auch in JBehave beschrieben werden.

Über das integrierte Reporting, das auch in eine Maven-Site eingebunden werden kann, können die Tests einfach getrackt werden, was die Fehlersuche erleichtert. Denn jeder Benutzerschritt wird im Report einzeln ausgewiesen: Die UI-Elemente werden dabei in den Page-Klassen logisch zusammengefasst und jegliche Benutzerinteraktion wird in den Step-Klassen gruppiert, wie z. B. das Klicken auf Buttons in der Oberfläche. Die Tests sind dann lediglich eine Kollektion von Steps, die Benutzereingaben reflektieren und das Ergebnis verifizieren. 

|----src
| |----main
| | |----docker        1
| | | |----app
| | | |----mysql
| | |----frontend      2
| | |----java          3
| | |----resources
| |----test
| | |----frontend      4
| | |----java          5
| | | |----integration
| | | | |----pages     6
| | | | |----steps     7
| | | | |----rest      8
| | | | |----ui        9
  1. Docker-Konfiguration

  2. Frontend-Ordner der Angular WebApp
  3. Java-Quellcode

  4. WebApp-Testquellen

  5. Java-Testquellen

  6. PageObject-Klassen

  7. Interaktionensmöglichkeiten des Nutzers mit der Oberfläche
  8. Rest-basierte Integrations-Tests

  9. Oberflächen Integrations-Tests

Bei dieser Aufteilung wird auch deutlich, dass Serenity nicht zwischen UI- und REST-basierenden Schritten unterscheidet. Beide können in Tests beliebig kombiniert werden.

Serenity nutzt Selenium als technische Basis, erweitert es aber um verschiedene Locator-Strategien, z. B. für AngularJS (s.Abb.12).

Serenity erlaubt aber auch das Testen von Apps, z. B. hybriden Apps mit Cordova. Dazu gibt es direkte Locator-Unterstützung. Die Locator werden in Page-Klassen genutzt, die die Verbindung zur Weboberfläche darstellen.

Listing 3:


public class LoginPage extends CmsPage {
    @FindBy(id = "username")
    private WebElement userInput;
    @FindBy(id = "password")
    private WebElement passwordInput;
    @FindBy(id = "login")
    private WebElement loginButton;
    public void inputUserName(String emailAddress) {
        enterText(userInput, emailAddress);
    }
    public void inputPassword(String password) {
        enterText(passwordInput, password);
    }
    public void clickOnLogin() {
        click(loginButton);
    }
    public boolean loginFormVisible() {
        return element(userInput).isCurrentlyVisible() && element(passwordInput).isCurrentlyVisible();
    }
}

Die Page-Klassen werden dann in Steps gebündelt, ggf. können auch Schritte in Gruppen ("StepGroup") zusammengefasst werden. Das bietet sich bei performLogin beispielsweise an.

Listing 4:


public class LoginSteps extends ScenarioSteps {
    public LoginPage loginPage() {

        return getPages().currentPageAt(LoginPage.class);
    }

    // STEPS
    @Step
    public void inputUsername(String user) {

        loginPage().inputUserName(user);
    }

    @Step
    public void inputPassword(String pass) {

        loginPage().inputPassword(pass);
    }

    @Step
    public void clickOnLogin() {

        loginPage().clickOnLogin();
    }

    @StepGroup
    public void performLogin(String email, String pass) {

        inputUsername(email);
        inputPassword(pass);
        clickOnLogin();

}

    @Step
    public void userShouldSeeLogin() {

        assertThat("Should be on login page ", loginPage().loginFormVisible());
    }

    @Step
    public void userShouldSeeAnErrorMessage() {

        assertThat("There should be an error message ", loginPage().errorMessageIsVisible());

        assertThat("There should be only one info message ", loginPage().onlyOneErrorMessageIsVisible());
    }

} 

Der eigentliche Test nutzt dann diese Schritte, die injiziert werden, um die Benutzeraktionen mit konkreten Testdaten gemäß bestimmter Testszenarien auszuführen. Dabei können verschieden JUnit- Runner genutzt werden. Der "SerenityParameterizedRunner" nutzt CSV-Dateien, um denselben JUnit-Test mit verschiedenen Daten mehrmals auszuführen.

Listing 5:


@WithTags({
  @WithTag(type = "epic", name = "User management")
})
@Narrative(text = "Login")
@RunWith(SerenityParameterizedRunner.class)
@UseTestDataFrom(value = "testdata/login.csv")
public class LoginIT extends AbstractSerenityITTestBase {
  @Steps
  public LoginSteps loginSteps;
  // test data
  private String username;
  private String password;
  private boolean fail;
  @Qualifier
  public String qualifier() {
    return "user " + username;
  }
  @WithTags({
    @WithTag(type = "feature", name = "Login"),
    @WithTag(type = "feature", name = "User"),
    @WithTag(type = "testtype", name = "postdeploy")
  })
  @Test
  @Issues(value = {"#9"})
  public void loginShouldWork() {
    loginSteps.performLogin(this.username, this.password);
    loginSteps.userShouldSeeNoErrorMessage();
  }
}
Die Spaltennamen müssen dabei den Propertynamen entsprechen. In dem oberen Beispiel werden auch Tags genutzt. Diese Tags tauchen später auch im HTML-Report auf. Diese können aber auch als Filter genutzt werden, um nur bestimmte Serenitytests auszuführen. Außerdem können Ticket-Systeme wie JIRA mit @Issues adressiert werden. Im Falle von JIRA ist es sogar möglich, dass das jeweilige Ticket automatisch bei einem Testlauf aktualisiert wird.  So wird gegenüber BDD-Techniken wie Cucumber & Co eine zusätzliche Vermittler-Schicht vermieden, indem man dazu gezwungen wird, sprechende Methodennamen zu wählen, so dass diese aussagekräftig in dem Testreport dargestellt werden.

Der REST kommt noch...

Aber neben auf Selenium basierenden Tests bietet Serenity auch die Möglichkeit, mit REST Assured [4] API-Integrationstests zu schreiben und diese auch in das Serenity-Reporting zu integrieren. Dabei werden die Steps analog zu seleniumbasierten Tests erstellt. Listing 6:

public class EventSteps {

@StepGroup
  public Response getEvent(String username, String password, String eventId) {
    return RestAssured.given()
      .with().baseUri(getBaseUrl()).basePath("")
      .auth().basic(username, password)
      .expect()
      .statusCode(HttpStatus.SC_OK)
      .when()
      .get("/api/events/{eventId}", eventId);
}

@Step
  public Response getEvents(String username, String password) {
    return RestAssured.given()
      .with().baseUri(getBaseUrl()).basePath("")
      .auth().basic(username, password)
      .expect()
      .statusCode(HttpStatus.SC_OK)
      .when()
      .get("/api/events/");
}
Diese können dann wieder in konkreten Testfällen genutzt und eingebunden werden. Listing 7:

@RunWith(SerenityRunner.class)
public class EventControllerIT {

  @Steps
  public EventSteps eventSteps;
  
  @WithTags({
    @WithTag(type = "feature", name = "Event API"),
  })
  @Test
  public void shouldListEvent() {
    eventSteps.getEvents("user", "password");
  }
  
  @WithTags({
    @WithTag(type = "feature", name = "Event API"),
  })
  @Test
  public void shouldGetOneEvent() {
    eventSteps.getEvent("user", "password", "4");
  }
}

Do not fear Continuous Deployment

Zusammenfassend lässt sich sagen, dass Continuous Delivery mittlerweile ein in der Praxis bewährtes Mittel zur Optimierung der Softwareentwicklung ist. Die Werkzeuge sind erprobt und vielerorts sind Best Practices bekannt. Gerade im Bereich der Automatisierung ergeben sich einerseits mit Test-Tools wie Serenity oder aber im CI-Bereich mit Jenkins in der jüngsten Zeit begrüßenswerte Möglichkeiten, um auch in komplexen Umgebungen Continuous Delivery ohne Kopfschmerzen zu ermöglichen. Frameworks wie Serenity helfen dabei, möglichst viele Aspekte der Software auch automatisiert und effektiv zu testen. Die klassischen BDD-Features wie "Fast Feedback" und "Test as a User" mit den Reports sind hier sehr gut umgesetzt. Außerdem deckt das Framework neben den klassischen Oberflächentests auch die Integration von Schnittstellentests mit RestAssured ab und rundet das Bild damit ab.
Autor

Martin Reinhardt

Martin Reinhardt beschäftigt sich mit der Architektur von komplexen verteilten Systemen, modernen Webarchitekturen und Build Management. Er arbeitet als Architekt bei der Management- und IT-Unternehmensberatung Holisticon AG.
>> Weiterlesen
Kommentare (0)

Neuen Kommentar schreiben