Functioneel testen voor deployment met Docker, Maven, Cucumber en Webdriver

Arjan Broer

Voor verschillende typen klanten worden verschillende manieren van testen aangehouden. Testers van Mirabeau kunnen op verschillende manieren de tests uitvoeren, automatisch of handmatig, gestructureerd of ad-hoc. In sommige gevallen is het mogelijk in een strakke planning te werken met een geplande test periode, andere gevallen is er een continue test inzet mogelijk. Al deze gevallen komen voor bij Mirabeau en in al die gevallen is het duidelijk dat het testen bijdraagt aan de kwaliteit.

Een belangrijke factor tijdens de ontwikkeling is het snel krijgen van feedback over de kwaliteit. In een gepland proces met een test periode betekent dit dat de feedback na de test periode wordt gegeven. In het geval van een continue proces wordt de feedback over de kwaliteit gegeven kort nadat de wijziging is aangeboden aan de testers.

Het ideaal vs het haalbare

Als ik uit de voorbeelden bij een ideaalsituatie probeer te schetsen, zou bij iedere codewijziging een volledige regressietest worden uitgevoerd op alle functionaliteiten en worden de resultaten direct terug gekoppeld aan de ontwikkelaar. In de IDE wordt direct het statement van een rood kringeltje voorzien als het statement een fout veroorzaakt. Directe terugkoppeling op hetzelfde moment dat de regel code wordt getyped.

Om dit niveau te bereiken moet de cyclus van compile, package, deploy, run, test voor een volledige regressietest afgerond worden binnen ongeveer een halve seconde. OK, dat gaat niet lukken. Maar ik ga wel proberen om wat dichter bij dat ideaal te komen.

Het zou al mooi zijn als de buildserver mij binnen enkele minuten feedback kan geven. Als de wijziging wordt doorgevoerd en gecommit (push met git) dan gaat de build server al voor mij aan de gang om de code te compileren, unit test uit te voeren, een sonar analyse uit te voeren en de boel te packagen. We hebben bij een aantal klanten het zelfs al een flinke tijd voor elkaar dat de applicatie bij iedere commit ook op de development server wordt gedeployed zodat er altijd een recente versie aanwezig is. Dit moment is ook een mooi moment om de regressie test uit te voeren. Natuurlijk wel voordat we de applicatie deployen op de development-server en niet pas erna.

Doel: automatische regressie test na de build, voor deployment binnen enkele minuten

Het ontwerp

Een aantal technische keuzes zijn bij de meeste klanten al gemaakt en daar wil ik niet aan tornen. In het voorbeeld dat ik hier gebruik ga ik uit van een java applicatie, het build proces staat al in maven en de applicatie draait in een tomcat servlet container. Verder liggen de mogelijkheden volledig open. Voor het automatisch testen kies ik voor cucumber. Dit geeft een prettige mix van tekstueel beschreven test scenario's en good old java code om de scripts uit te voeren. Er zijn nog een heleboel andere producten te vinden, maar omdat ik me bij cucumber prettig voel blijf ik daar bij.

De tests moeten straks ook werken op een build server. Er kan dus geen reguliere browser worden geopend. Als alternatief voor de reguliere browser zet ik phantomjs in. deze webkit browser is goed te vergelijken met chrome of safari, maar draait wel volledig headless. Daar kan een build server dus ook goed mee overweg. Om cucumber en phantomjs vervolgens aan elkaar te kunnen knopen is de selenium webdriver een prima oplossing.

Dan hebben we nog een omgeving nodig om de applicatie te runnen. Docker is een voor de hand liggende keuze. Met docker kunnen we zowel een tomcat container als een database runnen. De applicatie werkt met een MS SQL Server database, dat gaat niet werken met Docker. Omdat de applicatie gebruikt maakt van jdbc en hibernate is het wel eenvoudig een andere database te kiezen. Support voor postgresql op docker is goed en voor jdbc en hibernate zijn er voldoende libraries aanwezig om met postgresql te connecten. Dus de run omgeving wordt tomcat / postgresql op docker.

FunctionalTestArch

Daarmee is het ontwerp op hoofdlijnen gemaakt. Tijdens het maven build process moet in de integration-test phase een deploy worden gedaan waarbij de dockers images worden gebouwd en de containers worden gestart. Er zijn een aantal maven plugins beschikbaar voor docker integratie. Zie onderstaande link naar de shootout van de plugins. Ik heb gekozen voor de rhuss plugin omdat ik die aan de praat kreeg. De andere kosten mij veel meer moeite zonder dat ik resultaat haalde.

  • Buildomgeving: Maven
  • Hosting/run: Docker containers
  • Servlet container: Tomcat
  • Database: Postgresql
  • Test framework: Cucumber met selenium
  • Browser: phantomjs

De implementatie

De applicatie is een omvangrijke complexe applicatie met al zijn bijbehorende nukken. Dat is niet de ideale omgeving om de integration-test phase mee in elkaar te knutselen. Om te voorkomen dat de complexiteit van de applicatie de overhand krijgt en ik niet meer toe kom aan het aan elkaar knopen van de run en test omgeving kies ik voor een eenvoudigere web applicatie. Een eenvoudige war met een index.jsp. Die jsp bevat geen logica, maar alleen een header tag.

<html>
<head>
    <title>Simple page</title>
</head>
<body>
  <h1 id="textHeader">The header</h1>
</body>
</html>

Deze header tag is te vinden met zijn id. Dus de selenium webdriver kan testen of de header tag wordt weer gegeven en als dat zo is, dan is de test succesvol. Om deze applicatie te runnen heb ik alleen een docker container nodig voor tomcat. Het image bouw en start ik tijdens de pre-integration-test phase en stop ik in de post-integration-test phase.

Docker file

De Dockerfile is de definitie van het docker image. Gelukkig is er al een officiele tomcat image, dus die image kunnen we gebruiken als startpunt. Met de docker-maven-plugin wordt de docker file in xml vorm gespecificeerd. De dockerfile definitie is hieronder te zien. Belangrijkste onderdelen zijn de tag waarin het basis docker image staat, de tag die het build resultaat beschikbaar maakt en de tag die ervoor zorgt dat de war naar de juiste plek wordt gekopieerd.

...
<image>
  <name>arjan/${project.artifactId}-dockerfile:${project.version}</name>
  <build>
    <from>tomcat:7-jre8</from>
    <assembly>
      <basedir>/release</basedir>
      <descriptor>${basedir}/src/main/docker/web/docker-assembly.xml</descriptor>
    </assembly>
    <runCmds>
      <run>cp /release/cucumber-integration-test.war ${CATALINA_HOME}/webapps/cucumber-integration-test.war</run>
    </runCmds>
    <ports>
      <port>${tomcat.port}</port>
    </ports>
    <cmd>catalina.sh run</cmd>
  </build>
  <run>
    <ports>
      <port>jolokia.port:8080</port>
    </ports>
    <wait>
      <url>http://${docker.host.address}:${jolokia.port}/cucumber-integration-test</url>
      <time>100000</time>
    </wait>
    <log>
      <prefix>web</prefix>
      <color>cyan</color>
    </log>
  </run>
</image>
...

Deze image kan gerund worden door deze plugin te koppelen aan de pre-integration-test phase. Zie hiervoor de het voorbeeld github project in de referenties.

Cucumber test

De run omgeving van de eenvoudige applicatie is nu in orde. De docker container draaid de applicatie en er komt een keurige response. De volgende stap is om met cucumber een functionele test uit te voeren met de phantomjs browser. Om een test tijdens de integratie phase uit te voeren maak ik gebruik van de maven-failsafe-plugin. Configuratie is simpel, maar je moet wel even letten op de classnaam van je tests. Deze classes moeten beginnen of eindigen met 'IT'.

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-failsafe-plugin</artifactId>
  <version>2.18.1</version>
  <executions>
    <execution>
      <goals>
        <goal>integration-test</goal>
        <goal>verify</goal>
      </goals>
    </execution>
  </executions>
</plugin>

De failsafe plugin zal nu tijdens de integratie-test phase de tests draaien. De plugin is failsafe, dus worden gegarandeerd de pre-integration-test en post-integration-test phases uitgevoerd. In de post-integration-test phase worden de docker containers weer down gebracht, dus het is wel belangrijk dat deze fase ook echt wordt uitgevoerd.

Dan hebben we natuurlijk ook nog de phantomjs browser nodig. Omdat ik niet weet of de buildserver beschikt over een phantomjs binary wil ik deze zelf aanleveren vanuit maven. Ook daarvoor is weer een mooie maven plugin beschikbaar. Die koppelen we aan de pre-integration-test phase, want dat is het moment dat we de binary nodig hebben.

<plugin>
  <groupId>com.github.klieber</groupId>
  <artifactId>phantomjs-maven-plugin</artifactId>
  <version>${phantomjs.plugin.version}</version>
  <executions>
    <execution>
      <goals>
        <goal>install</goal>
      </goals>
      <phase>pre-integration-test</phase>
    </execution>
  </executions>
  <configuration>
    <version>${phantomjs.version}</version>
  </configuration>
</plugin>

Nu is het zo ver dat we tijdens de integration-test fase beschikken over een browser en dat de unit tests worden uitgevoerd. Tijd om test code te gaan bouwen. We beginnen met een test feature geschreven in Gherkin. Deze test gaat naar de homepage en verwacht daar de header te vinden.

Feature: smoketest
  Scenario: look for the header
    Given the user is at the homepage
    Then the header should be visible

Om deze test te automatiseren leggen we een structuur aan waar ieder scherm (of pagina) wordt vertegenwoordigd door een view. Een view kent een aantal kenmerken en er kunnen operaties op worden uitgevoerd. Dit is analoog aan scherm elementen waar je op kan klikken. Een navigatie klasse is vervolgens nog verantwoordelijk om de browser te vertellen welke url er geladen moet worden. Toegang tot de browser wordt verkregen via de BrowserDriver. Als de complexiteit van de applicatie toeneemt kan je nog overwegen om bij de view een aparte klasse toe te voegen om de scherm elementen te beschrijven. In dit eenvoudige model vond ik dat een overkill.

cucumberClassDiagram

De SmokeTest klasse is de koppeling tussen de texuele gherkin test en de uitvoerbare code. De code van de SmokeTest is bijzonder eenvoudig. De annotaties zorgen voor de match met de gherking regelen en de aanroepen naar Navigation en homeView spreken voor zich.

public class SmokeTest {
    private HomeView homeView;

    @Given("^the user is at the homepage$")
    public void the_user_is_at_the_homepage() throws Throwable {
        homeView = Navigation.openHomepage();
    }

    @Then("^the header should be visible$")
    public void the_header_should_be_visible() throws Throwable {
        homeView.isHeaderVisible();
    }
}

Navigation en HomeView zijn ook eenvoudig en besteden het echte werk aan de BrowserDriver uit.

public class Navigation {
    public static HomeView openHomepage() {
        String simpleWebUrl = System.getProperty("simpleWebUrl");
        BrowserDriver.loadPage(simpleWebUrl);

        return new HomeView();
    }
}

public class HomeView {
    public boolean isDisplayed() {
        WebElement textHeader = BrowserDriver.waitForElement(By.id("textHeader"));
        return textHeader.isDisplayed();
    }

    public boolean isHeaderVisible() {
        WebElement textHeader = BrowserDriver.waitForElement(By.id("textHeader"));
        return textHeader.isDisplayed();
    }
}

De BrowserDriver is iets complexer, maar bevat geen applicatie specifieke logica. Voor details over de BrowserDriver verwijs ik naar de github repository. Met name het inladen van de phantomjs binary kan leuk zijn om eens te bekijken.

De test is nu klaar om te runnen. Dit kan je doen met mvn clean verify. In de output zien we dan de volgende fragmenten terugkeren die erop wijzen dat de run succesvol was.

...
[INFO] DOCKER> [arjan/cucumber-integration-test-dockerfile:1.0-SNAPSHOT] : Built image 5bf288f87001
...
[INFO] DOCKER> [arjan/cucumber-integration-test-dockerfile:1.0-SNAPSHOT] : Start container f0518ac86409
...
[INFO] DOCKER> [arjan/cucumber-integration-test-dockerfile:1.0-SNAPSHOT] : Waited on url http://192.168.99.100:32769/cucumber-integration-test 50811 ms
...
INFO: Directing browser to:http://192.168.99.100:32769/cucumber-integration-test

  Scenario: look for the header       # nl/arjan/sandbox/smoketest.feature:2
    Given the user is at the homepage # SmokeTest.the_user_is_at_the_homepage()
    Then the header should be visible # SmokeTest.the_header_should_be_visible()

1 Scenarios (1 passed)
2 Steps (2 passed)
0m1.886s
...
[INFO] DOCKER> [arjan/cucumber-integration-test-dockerfile:1.0-SNAPSHOT] : Stop and remove container f0518ac86409
...

In deze resultaten zien we dat tijdens de build een docker image is gemaakt, de container is gestart, de webapp draait en de test met twee stappen slaagt. Vervolgens wordt de container weer keurig down gebracht.

Klant webapp case

De eenvoudige applicatie is gelukt. Nu op naar de meer complexe webapp. Hiervoor hebben we ook een database nodig en zijn de test scenario's wat meer uitgebreid. Hier ga ik wat sneller doorheen omdat het slechts een paar stappen bovenop het bovenstaande zijn.

De docker configuratie heeft nu ook een link om de data en web container aan elkaar te koppelen.

<image>
  <alias>web</alias>
  <name>app/${project.artifactId}-web-dockerfile:${project.version}</name>
  <build>
    <from>tomcat:7-jre8</from>
    ...
  </build>
  <run>
    <links>
      <link>db:db</link>
    </links>
    ...
  </run>
</image>

Met deze link wordt een koppeling vanuit de server container naar de database container mogelijk. In de hosts file van de tomcat container wordt de host 'db' toegevoegd. Docker zorgt ervoor dat het ip adres van de db container correct in de hosts file wordt gezet. Zo is de db server dus beschikbaar voor de tomcat container. In de jdbc connection url kan je nu dus verwijzen naar de db host.

<image>
  <alias>db</alias>
  <name>app/${project.artifactId}-db-dockerfile:${project.version}</name>
  <build>
    <from>postgres:9.4</from>
    <assembly>
      <basedir>/release</basedir>
      <descriptor>${basedir}/src/main/docker/db/assembly.xml</descriptor>
    </assembly>
    <runCmds>
      <run>cp /release/init_docker_postgres.sh /docker-entrypoint-initdb.d/</run>
    </runCmds>
  </build>
  <run>
    <namingStrategy>none</namingStrategy>
    <wait>
      <log>database system is ready to accept connections</log>
    </wait>
  </run>
</image>

Dit database image is gebaseerd op het standaard postgresql image. Als postgresql wordt gestart wordt door postgresql het init_docker_postgres.sh uitgevoerd. Dat is meteen het moment om een script met test data in te laden. Via de assembly tag worden het shell script en de sql scripts aan het image toegevoegd.

Dan rest nog de cucumber test scenario's. Er zijn meedere test scenario's gemaakt, voor dit voorbeeld ligt ik het inlog scenario voor een reguliere gebruiker eruit.

Feature: Login to webapp
  Scenario: Log in as the guest user
    Given the user is at the loginpage
    When the user enters username 'test@mirabeau.nl'
    And password 'test123'
    And Clicks the login button
    Then the user dashboard should be shown

Dit scenario vertaalt zich naar de volgende stappen definitie in cucumber.

public class LoginStepDefinitions {
    private Navigation navigation = new Navigation();
    private LoginView loginView;

    @Given("^the user is at the loginpage$")
    public void the_user_is_at_the_loginpage() throws Throwable {
        loginView = navigation.the_user_is_at_the_loginpage();
    }

    @When("^the user enters username '(.+)'$")
    public void the_user_enters_username(String username) throws Throwable {
        loginView.enterUsername(username);
    }

    @And("^password '(.+)'$")
    public void password_gast(String password) throws Throwable {
        loginView.enterPassword(password);
    }

    @And("^Clicks the login button$")
    public void Clicks_the_login_button() throws Throwable {
        loginView.clickLoginButton();
    }

    @Then("^the user dashboard should be shown$")
    public void the_user_dashboard_should_be_shown() throws Throwable {
        navigation.theUserIsAtTheAdminDashboard();
    }
}

Met deze test zijn we nu volledig in staat om de applicatie te runnen en functioneel te testen. De volledige build inclusief integratie test duurt nu circa 6 minuten op mijn laptop. Daarvan was slechts 1.19 nodig voor de test. Het overgrote deel van die tijd was nodig voor het builden en starten van de docker images.

[INFO] Reactor Summary:
[INFO]
[INFO] Shared ............................................. SUCCESS [  2.836 s]
[INFO] WebApp ............................................. SUCCESS [04:15 min]
[INFO] Service1 ........................................... SUCCESS [  6.304 s]
[INFO] Service2 ........................................... SUCCESS [  3.117 s]
[INFO] Cucumber-WebApp Sandbox ............................ SUCCESS [01:19 min]
[INFO] Base ............................................... SUCCESS [  0.006 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 05:47 min
[INFO] Finished at: 2015-11-10T10:24:41+01:00
[INFO] Final Memory: 114M/741M
[INFO] ------------------------------------------------------------------------

Conclusie

Een volledige regressietest tijdens de build op een docker omgeving is haalbaar en de feedback wordt snel genoeg gegeven om dit bruikbaar te maken in het development proces. Door de build op deze manier in te richten kan er een functionele test worden uitgevoerd voordat de applicatie wordt gedeployed in de OTAP straat. De uitkomst van de regressietest is binnen enkele minuten beschikbaar voor de ontwikkelaar en nieuwe bug kunnen dus snel worden opgelost. Dit moet tot gevolg hebben dat de tests in de OTAP straat door de menselijke tester meer gericht kan worden op het vinden van bijzonderheden terwijl de tester ervan uit mag gaan dat de functionaliteiten goed werken.

Een beperking van deze test is dat de test is gebaseerd op een geprepareerde test data set. Eventuele bijzondere data situaties die in productie wel voorkomen, maar in de data set niet worden hierdoor niet afgetest. Gevolg kan zijn dat een bug in productie nog steeds niet wordt opgemerkt in de automatische test.

Een andere beperking is dat phantomjs en selenium de DOM testen. Als inputvelden en links gevonden kunnen worden in de DOM, dan werkt de applicatie. Echter de automatische test zal niet goed zien dat de styling van die elementen niet klopt of dat ze verkeerd op het scherm worden gepositioneerd. Ook ziet de automatische test het niet als er allerlei extra velden, buttons of andere elementen extra worden getoond die er niet horen te zijn.

Deze manier van automatisch testen is dus geen vervanging van menselijk testen, maar maakt het werk van de tester wel een stuk boeiender omdat de immer herhalende test van standaard functionaliteiten niet meer nodig is.

Referenties