Vor einiger Zeit wurde im Rahmen eines Blog Artikels Behavioural Driven Development als Methode agiler Softwareentwicklung vorgestellt, die ein konsequent akzeptanztestgetriebenes Vorgehen forciert, indem Anforderungsspezifikation und Testausführung direkt miteinander verknüpft werden. Die konzeptionellen Grundlagen wurden anhand des Beispiels aus unserem Vortrag bei der SoCraTes 2011 erläutert. Dort verwendeten wir JavaScript und das BDD Framework Jasmine. Im Rahmen dieses Blog Beitrages soll anhand des gleichen Beispiels das Framework Cucumber vorgestellt werden.
Schon wieder ein neues Framework?
Mittlerweile existieren auch in der Java-Welt neben dem Klassiker JBehave diverse BDD Frameworks wie JDave oder Cucumber. Wofür soll man sich also entscheiden? Ich persönlich empfinde die Eigenschaft von Cucumber, sich leicht in verschiedenste Entwicklungslandschaften zu integrieren, als sehr angenehm. Letztlich sind Integrationsprobleme häufig die grössten Zeitfresser in der täglichen Entwicklungsarbeit, und als Entwickler möchte man ja eben lieber „entwickeln“, anstatt sich mit dem Aufsetzen der Testumgebung o.ä. rumzuärgern. Genau das geht mit Cucumber wirklich einfach.
Voraussetzungen
Da Cucumber selbst in Ruby implementiert ist, braucht man JRuby, um es für JVM-basierte Sprachen einsetzen zu können. Hierbei kommt cuke4duke
, ein spezielles Ruby Gem für die Verwendung von Cucumber zum Einsatz. Um die Spezifikation auszuführen, kann neben Ant Targets auch das „Cucumber“ Goal des Cuke4Duke-Maven-Plugins verwendet werden.
Dieses wird wie folgt konfiguriert
<configuration> <jvmArgs> <jvmArg> -Dcuke4duke.objectFactory=cuke4duke.internal.jvmclass.PicoFactory </jvmArg> <jvmArg>-Dfile.encoding=UTF-8</jvmArg> </jvmArgs> <cucumberArgs> <cucumberArg>--backtrace</cucumberArg> <cucumberArg>--color</cucumberArg> <cucumberArg>--verbose</cucumberArg> <cucumberArg>--format</cucumberArg> <cucumberArg>pretty</cucumberArg> <cucumberArg>--format</cucumberArg> <cucumberArg>junit</cucumberArg> <cucumberArg>--out</cucumberArg> <cucumberArg>${project.build.directory}/cucumber-reports</cucumberArg> <cucumberArg>--require</cucumberArg> <cucumberArg>${basedir}/target/test-classes</cucumberArg> </cucumberArgs> <gems> <gem>install cuke4duke --version 0.3.2</gem> </gems> </configuration>
Durch zusätzliche Konfiguration einer Execution für das Cucumber-Goal können Features später ausgeführt werden.
<execution> <id>run-features</id> <phase>integration-test</phase> <goals> <goal>cucumber</goal> </goals> </execution>
Das Barkeeper-Feature
Wie bereits erwähnt, dreht sich bei BDD alles um Verhalten. Neue Funktionalität fließt in Form kleiner Features zielgerichtet in die Anwendung ein. In unserem Beispiel ging es um ein fiktives Feature zum Mixen von Drinks
A barkeeper should mix drinks
das sich aus unserer User Story ableitet
In order to get a drink, as a guest I want to order it from the barkeeper.
Ausführbare Spezifikation
In Cucumber werden Anforderungen in Gherkin, einer DSL mit natürlichsprachlicher Syntax, spezifiziert. Daher ist es auch Personen ohne programmiertechnischem Background möglich, derartige Spezifikationen zu erstellen. Allerdings gilt es, ähnlich wie bei User Stories, eine grundlegende Strukturierung sowie das zur Beschreibung von Szenarios typische „Given-When-Then“-Pattern zu beachten. Letztlich erleichtern diese Regeln auch die Abgrenzung der jeweiligen Szenarios voneinander. Diese Spezifikation wird in *.feature Files im Zielprojekt abgelegt. Für unser Barkeeper-Feature sähe dies dann so aus:
Feature: A Barkeeper should mix drinks
In order to get a drink
As a guest I want to order it from the barkeeper
Scenario: Wodka Lemon Scenario
Given An order for Wodka Lemon
When The Barkeeper mixes the drink
Then It should return a Wodka Lemon
Das jeweils spezielle Format der Anforderungsspezifikation auf der einen Seite und Tests auf der anderen Seite bildet die Grundlage einer ausführbaren Spezifikation. BDD Frameworks wie Cucumber verknüpfen die Anforderungsspezifikation direkt mit den Testfällen und erlauben so die Erstellung einer „ausführbaren Anforderungsspezifikation“, die aufgrund ihrer natürlichsprachlichen Form von jeder beliebigen Person gelesen und interpretiert werden kann. Aussagen zum Funktionsumfang von Software erschließt sich so relativ einfach. Doch wie funktioniert das? Schauen wir dazu unser Beispiel an.
Das Wodka-Lemon-Szenario
Als Szenario betrachten wir unser Beispiel eines Wodka Lemon
Given An order for Wodka Lemon
When The Barkeeper mixes the Drink
Then It should return a Wodka Lemon
Die Ausführung unserer Spezifikation mittels mvn clean integration-test
liefert folgendes Ergebnis:
Cucumber parst zunächst die Features. Im Anschluss wird nach den Step Definitions gesucht. Da noch keine Implementierung vorhanden ist, schlägt Cucumber per default eine mögliche Implementierung in Ruby vor. Da wir (noch :P) mit Java arbeiten, legen wir stattdessen im Folder src/test/java eine Klasse BarkeeperFeature
an. Bei erneuter Ausführung erkennt Cucumber dies und schlägt entsprechende Code Snippets für Java vor.
Diese lassen sich direkt in die Testklasse übernehmen und können nun mit Leben gefüllt werden. Betrachten wir zunächst den Kontext des Tests. Gegeben ist eine Bestellung (Order), die Namen verschiedener Drinks enthalten kann. Diese werden als Liste in der Order gespeichert.
Anders als herkömmliche Tests haben Cucumber-Tests einen spezifischen Aufbau, der das „Given-When-Then“ Pattern adaptiert. Es gibt dazu mindestens drei Methoden, die entsprechend annotiert sind. Anhand des Patterns versucht Cucumber, Step Definitions aus der Testklasse den Step Definitions aus den Szenarios zuzuordnen. Durch den Einsatz von Regular Expressions ist es möglich, auch bestimmte Textabschnitte als Parameter an Testmethoden zu übergeben.
Given – Der Kontext
Die Fixture, also das Setup des (Test)Kontextes, erfolgt in der mit @Given
annotierten Methode. Hier initialisieren wir eine Instanz der Klasse Order
. Diese Order soll einen Wodka Lemon beinhalten.
@Given("^An order for Wodka Lemon$") public void anOrderForWodkaLemon() { order = OrderBuilder.anOrder().withDrink("Wodka Lemon").build(); }
When – Ausführung der Spezifikation
Im nächsten Schritt betrachten wir, was das System eigentlich tun soll. Um Drinks zu mixen, brauchen wir einen Barkeeper. Dazu legen wir eine entsprechende Klasse an. Die Barkeeper-Klasse enthält eine Methode mixDrinks
, die eine Order übernimmt und eine Liste von Drinks zurückgibt. Für einen Drink wird vorher ebenfalls eine Klasse angelegt. Die Methode mixDrinks
wird später das Verhalten des Systems abbilden. Sie wird in der mit @When
annotierten Testmethode aufgerufen. Die gegebene Order kann dazu als Member im Test gehalten und hier wieder referenziert werden. Als Ergbnis erhalten wir eine Liste mit Instanzen gemixter Drinks.
@When("^The Barkeeper mixes the drink$") public void theBarkeeperMixesTheDrink() { mixedDrinks = new Barkeeper().mixDrinksFrom(order); }
Then – Überprüfung der Akzeptanzkriterien
Im dritten Schritt wird das erwartete Verhalten anhand des Ergebnisses überprüft. In der mit @Then
annotierten Methode werden Assertions formuliert, um die Akzeptanzkriterien des Szenarios zu überprüfen. In unserem Fall erwarten wir vom Barkeeper genau einen Wodka Lemon zurück.
@Then("^It should return a Wodka Lemon$") public void itShouldReturnAWodkaLemon() { assertThat(DrinkListBuilder.listOf(mixedDrinks).withName("Wodka Lemon").itemCount(),is(equalTo(1))); }
Als vollständig ausführbare Spezifikationsklasse erhalten wir
public class BarkeeperFeature { private Order order; private List<Drink> mixedDrinks; @Given("^An order for Wodka Lemon$") public void anOrderForWodkaLemon() { order = OrderBuilder.anOrder().withDrink("Wodka Lemon").build(); } @When("^The Barkeeper mixes the drink$") public void theBarkeeperMixesTheDrink() { mixedDrinks = new Barkeeper().mixDrinksFrom(order); } @Then("^It should return a Wodka Lemon$") public void itShouldReturnAWodkaLemon() { assertThat(DrinkListBuilder.listOf(mixedDrinks).withName("Wodka Lemon").itemCount(),is(equalTo(1))); } }
Da die mixDrinks()
-Methode noch nicht implementiert ist, schlägt der Test erwartungsgemäß fehl.
Die Implementierung des eigentlichen Produktionscodes ist nun sehr trivial, da es sich lediglich um die Initialisierung eines Drink-Objekts für einen Wodka Lemon handelt.
public class Barkeeper { public List<Drink> mixDrinksFrom(Order order) { List<Drink> drinks = new ArrayList<Drink>(); for(String drinkName : order.getDrinks()){ drinks.add(new Drink(drinkName)); } return drinks; } }
Hinzufügen weiterer Szenarios
Analog können nun weitere Szenarios ergänzt werden. Natürlich kann ein Barkeeper nur Drinks mixen, die er kennt. Als Beispiel nehmen wir an, er kenne keinen Cuba Libre. Dann müsste das Barkeeper-Feature wie folgt ergänzt werden:
Feature: A Barkeeper should mix drinks
In order to get a drink
As a guest I want to order it from the barkeeper
Scenario: Wodka Lemon Scenario
Given An order for Wodka Lemon
When The Barkeeper mixes the drink
Then It should return a Wodka Lemon
Scenario: Unknown Drink Scenario
Given An order for Cuba Libre
When The Barkeeper mixes the drink
Then It should complain that no recipe is available for Cuba Libre
Ersetzt man die Namen der Drinks durch reguläre Ausdrücke, so können diese als Parameter in der Signatur der Testmethoden verwendet werden.
@Given("^An order for ([^\.]*)$") public void anOrderFor(String drinkName) { order = OrderBuilder.anOrder().withDrink(drinkName).build(); }
Während der Ausführung der Spezifikation fangen wir in diesem Fall eine UnknownDrinkException
und merken uns diese.
@When("^The Barkeeper mixes the drink$") public void theBarkeeperMixesTheDrink() { try { mixedDrinks = new Barkeeper().mixDrinksFrom(order); } catch (UnknownDrinkException ude) { unknownDrinkException = ude; } }
Nun muss lediglich die Testklasse um eine spezifische @Then
-annotierte Methode erweitert werden, die prüft, ob die Exception geworfen wurde.
@Then("^It should complain that no recipe is available for ([^\.]*)$") public void itShouldComplainThatNoRecipeIsAvailableFor(String drinkName) { String failMsg = String.format("UnknownDrinkException due to missing recipe for %s", drinkName); assertThat(failMsg, unknownDrinkException, is(not(nullValue()))); assertThat(failMsg, unknownDrinkException.getDrink(), is(equalTo(drinkName))); }
Die Ausführung der Spezifikation zeigt uns:
Dementsprechend wird die Implementierung des Barkeepers erweitert:
public class Barkeeper { private static final List<String> knownDrinks = Arrays.asList(new String[]{"Wodka Lemon", "Tequila Sunrise"}); public List<Drink> mixDrinksFrom(Order order) { List<Drink> drinks = new ArrayList<Drink>(); for(String drinkName : order.getDrinks()){ if(knownDrinks.contains(drinkName)){ drinks.add(new Drink(drinkName)); } else { throw new UnknownDrinkException(drinkName); } } return drinks; } }
Anschließend zeigt uns die ausführbare Spezifikation:
Da die Ausgabe der Testergebnisse auf den Step Definitions der Szenarios beruht, ist die Beurteilung des Ergebnisses bzw. der tatsächlichen Anwendungsfunktionalität auch ohne Programmierkenntnisse oder Einblick in den Quelltext der Tests möglich. Darüberhinaus erleichtert das Given-When-Then Pattern die Strukturierung der Testklassen sowie die fachliche Abgrenzung der Testfälle erheblich.