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
- Docker-Konfiguration
- Frontend-Ordner der Angular WebApp
- Java-Quellcode
- WebApp-Testquellen
- Java-Testquellen
- PageObject-Klassen
- Interaktionensmöglichkeiten des Nutzers mit der Oberfläche
- Rest-basierte Integrations-Tests
- 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();
}
}
Der REST kommt noch...
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/");
}
@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");
}
}