Zum Inhalt
wannacode

Vom Szenario zu den Schritten

Kurz gesagt: Ein Szenario ist Text. Damit es läuft, wird jede Zeile über einen Ausdruck mit einer Methode im Code verbunden: „ein Warenkorb von {Betrag} €“ passt auf die Zeile und übergibt den Betrag. Die Schritte eines Szenarios teilen sich einen Zustand: „Angenommen“ hält den Warenkorb fest, „Wenn“ ruft die Regel auf, „Dann“ prüft. Hooks laufen vor und nach jedem Szenario. Das Werkzeug ist in jeder Sprache ein anderes; die Idee ist dieselbe.

Problem

Ein Szenario ist nur Text

Im Gherkin-Modul hast du die Lieferszenarien geschrieben. Der Prüfer im Kurs hat sie gegen das Beispielsystem laufen lassen und dir gesagt, ob sie die Fehler fangen.

Ein echtes Projekt hat keinen solchen Prüfer. Der Programmierer schaut auf die Datei mit den Szenarien und sagt: „Der Computer weiß nicht, was ‚ein Warenkorb von 50,00 €‘ ist. Das ist ein Satz auf Deutsch.“ Er hat recht. Etwas muss jede Zeile in Code übersetzen.

Die Idee

Schritt-Definitionen

Hier sind die Szenarien. In der Datei des Projekts stehen sie unverändert, dieselben, die das Geschäft gelesen und abgenommen hat:

delivery.feature
# language: de
Funktionalität: Kostenlose Lieferung
Die Lieferung ist ab einer Bestellung von 50 € einschließlich kostenlos, sonst kostet sie 4,90 €.
Szenario: Bestellung genau an der Grenze
Angenommen ein Warenkorb von 50,00 €
Wenn ich bestelle
Dann ist die Lieferung kostenlos
Szenario: Bestellung knapp unter der Grenze
Angenommen ein Warenkorb von 49,99 €
Wenn ich bestelle
Dann beträgt die Lieferung 4,90 €
Szenario: Leerer Warenkorb
Angenommen ein leerer Warenkorb
Wenn ich bestelle
Dann wird die Bestellung abgelehnt

Jede Zeile ist mit einer Methode im Code verbunden. Diese Methode heißt Schritt-Definition. Über ihr steht der Text, auf den sie passt:

src/test/java/shop/DeliverySteps.java
@Given("ein Warenkorb von {Betrag} €")
public void cart(int cents) {
total = cents;
}

Das Werkzeug liest das Szenario Zeile für Zeile, findet die Definition, deren Text passt, und ruft sie auf. Die Werkzeuge sind in jeder Sprache andere, aber die Idee ist dieselbe:

  • Cucumber-JVM für Java;
  • Reqnroll für C# (der Nachfolger von SpecFlow);
  • behave für Python;
  • cucumber-js für JavaScript.
Genauer, für Neugierige

Cucumber ist das Original, aus Ruby. Cucumber-JVM und cucumber-js sind seine offiziellen Versionen, während Reqnroll und behave eigene Projekte mit derselben Idee und demselben Gherkin sind. In Cucumber-JVM heißen die Annotationen @Given, @When und @Then, aber welche der drei über einer Methode steht, spielt eigentlich keine Rolle: Der Text entscheidet. Deshalb wird „Angenommen“ aus einem deutschen Szenario von @Given gefunden.

Gherkin-Schlüsselwörter gibt es in vielen Sprachen, und das offizielle Gherkin-Wörterbuch listet sie auf. Eine Zeile wie # language: de ganz oben sagt dem Werkzeug, welche es lesen soll; ohne sie wird Englisch angenommen.

Die Idee

Ausdrücke und Parametertypen

ein Warenkorb von {Betrag} € passt auf jeden Betrag. {Betrag} ist ein Parameter: die Stelle, an der die Zahl steht, und die Regel, wie aus dem Text ein Wert wird. Der Betrag wird in Cent gehalten, als ganze Zahl: „49,99“ wird zu 4999. So gibt es keine Rundungsfehler.

Der Typ wird einmal beschrieben, mit einem regulären Ausdruck („Ziffern, ein Komma, zwei Ziffern“) und einer Funktion, die die Zahl macht:

src/test/java/shop/DeliverySteps.java
@ParameterType(name = "Betrag", value = "\\d+,\\d{2}")
public int amount(String text) {
return Integer.parseInt(text.replace(",", ""));
}

Ausdrücke mit {…} heißen Cucumber-Ausdrücke. Sie sind leichter zu lesen als reguläre Ausdrücke. Es gibt eingebaute Typen für ganze Zahlen und Wörter ({int}, {word}), und der Betrag in Euro ist ein eigener Typ des Projekts.

Genauer, für Neugierige

behave benutzt standardmäßig keine Cucumber-Ausdrücke, sondern die Bibliothek parse, in der der Parameter {total:Amount} geschrieben und der Typ mit register_type registriert wird. (behave 1.3 kann auch Cucumber-Ausdrücke benutzen, aber diese Unterstützung ist noch experimentell.) Reqnroll akzeptiert beide Arten von Ausdrücken. In Reqnroll steht der reguläre Ausdruck des Typs in Klammern, weil der Wert aus der Gruppe darin kommt.

Wenn keine Definition auf eine Zeile passt, ist der Schritt undefiniert, und das Szenario besteht nicht. Die Werkzeuge geben meist eine leere Definition zum Kopieren aus. Wenn zwei passen, ist der Schritt mehrdeutig, und das ist ein Fehler.

Probier es aus

Welche Zeile, welche Definition?

1. Der Schritt lautet „Angenommen ein Warenkorb von 49,99 €“. Welche Definition passt darauf?
2. Ein Szenario sagt „Angenommen ein Warenkorb von 50 €“, ohne die Cent. Was passiert?
3. Zwei Programmierer haben dieselbe Definition in zwei Dateien geschrieben. Was sagt das Werkzeug?

Die Idee

Der Zustand eines Szenarios

„Wenn ich bestelle“ hat keinen Betrag in der Zeile. Woher weiß es, was der Warenkorb ist? Aus dem Zustand des Szenarios: „Angenommen“ hält ihn fest, „Wenn“ benutzt ihn, „Dann“ prüft ihn.

„Wenn“ ruft die Regel auf. Wenn die Regel mit einer Exception ablehnt, wird die Ablehnung festgehalten, statt das Szenario zu stoppen. Genau das prüft „Dann wird die Bestellung abgelehnt“:

src/test/java/shop/DeliverySteps.java
@When("ich bestelle")
public void order() {
try {
fee = Delivery.feeFor(total);
} catch (IllegalArgumentException e) {
refusal = e.getMessage();
}
}

„Dann“ vergleicht mit dem, was erwartet wird. Das ist die Prüfung, die fehlschlagen kann:

src/test/java/shop/DeliverySteps.java
@Then("beträgt die Lieferung {Betrag} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}

Der Zustand ist für jedes Szenario neu. In Java und C# erzeugt das Werkzeug für jedes Szenario ein neues Objekt der Schritt-Klasse. In JavaScript ist das Objekt this (die World) neu, und in behave wird alles, was ein Szenario in context ablegt, beim Ende des Szenarios entfernt. Ein Szenario darf nicht von einem anderen abhängen: Jedes läuft für sich und in beliebiger Reihenfolge.

Genauer, für Neugierige

In Java und C# ist der Zustand die Felder der Klasse:

src/test/java/shop/DeliverySteps.java
// Der Zustand eines Szenarios: Für jedes Szenario wird ein neues Objekt erzeugt.
private int total;
private int fee;
private String refusal;

Wenn es viele Schritte gibt, werden sie auf mehrere Klassen verteilt. Dann lebt der gemeinsame Zustand in einem eigenen Objekt, das das Werkzeug jeder Klasse übergibt (in Cucumber-JVM mit einer Bibliothek für Dependency Injection wie PicoContainer, in Reqnroll mit seinem eingebauten Container). In behave sind context und in cucumber-js this (das World-Objekt) schon allen Schritt-Dateien gemeinsam.

Die Idee

Hooks

Manchmal muss vor oder nach jedem Szenario etwas geschehen: die Datenbank aufräumen, eine Verbindung öffnen, festhalten, was passiert ist. Das sind Hooks. Hier hält der Hook nach dem Szenario fest, mit welchem Warenkorb es fehlgeschlagen ist, falls es fehlgeschlagen ist:

src/test/java/shop/DeliverySteps.java
// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb.
@After
public void showCart(Scenario scenario) {
if (scenario.isFailed()) {
System.out.println("Warenkorb: " + total + " Cent");
}
}

Eine Regel: Ein Hook bereitet vor und räumt auf, aber er prüft nicht und versteckt keine Schritte, die für den Leser wichtig sind. Wenn jedes Szenario einen Warenkorb braucht, ist das eine „Grundlage“ im Szenario, wo das Geschäft sie sieht, kein Hook.

Probier es aus

Die Reihenfolge der Ausführung

Bringe in die richtige Reihenfolge, was das Werkzeug für das Szenario „Bestellung genau an der Grenze“ aufruft.

  1. Der Hook vor dem Szenario
  2. Dann ist die Lieferung kostenlos
  3. Der Hook nach dem Szenario
  4. Wenn ich bestelle
  5. Angenommen ein Warenkorb von 50,00 €

Die Idee

Der Lauf

Hier ist die Regel, gegen die die Szenarien laufen:

src/main/java/shop/Delivery.java
package shop;
public class Delivery {
public static final int FREE_FROM = 5000;
public static final int FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.
public static int feeFor(int total) {
if (total <= 0) {
throw new IllegalArgumentException("Ein leerer Warenkorb kann nicht bestellt werden");
}
return total >= FREE_FROM ? 0 : FEE;
}
}

Das Werkzeug lässt die drei Szenarien laufen. Alle bestehen:

src/test/java/shop/DeliverySteps.java
package shop;
import io.cucumber.java.After;
import io.cucumber.java.ParameterType;
import io.cucumber.java.Scenario;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class DeliverySteps {
// Der Zustand eines Szenarios: Für jedes Szenario wird ein neues Objekt erzeugt.
private int total;
private int fee;
private String refusal;
// {Betrag} in den Schritten: „49,99“ wird zu 4999 Cent.
@ParameterType(name = "Betrag", value = "\\d+,\\d{2}")
public int amount(String text) {
return Integer.parseInt(text.replace(",", ""));
}
@Given("ein Warenkorb von {Betrag} €")
public void cart(int cents) {
total = cents;
}
@Given("ein leerer Warenkorb")
public void emptyCart() {
total = 0;
}
@When("ich bestelle")
public void order() {
try {
fee = Delivery.feeFor(total);
} catch (IllegalArgumentException e) {
refusal = e.getMessage();
}
}
@Then("beträgt die Lieferung {Betrag} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}
@Then("ist die Lieferung kostenlos")
public void free() {
assertEquals(0, fee);
}
@Then("wird die Bestellung abgelehnt")
public void refused() {
assertNotNull(refusal, "die Bestellung wurde angenommen");
}
// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb.
@After
public void showCart(Scenario scenario) {
if (scenario.isFailed()) {
System.out.println("Warenkorb: " + total + " Cent");
}
}
}
Ausgabe
OK Bestellung genau an der Grenze
OK Bestellung knapp unter der Grenze
OK Leerer Warenkorb
Szenarien: 3, bestanden: 3, fehlgeschlagen: 0

Jetzt ändert der Programmierer >= in >. Derselbe Fehler, der im Gherkin-Modul ein Mutant war, steckt jetzt in echtem Code. Das Szenario an der Grenze schlägt fehl, und der Hook sagt, mit welchem Warenkorb:

src/main/java/shop/Delivery.java
package shop;
public class Delivery {
public static final int FREE_FROM = 5000;
public static final int FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.
public static int feeFor(int total) {
if (total <= 0) {
throw new IllegalArgumentException("Ein leerer Warenkorb kann nicht bestellt werden");
}
return total > FREE_FROM ? 0 : FEE;
}
}
Ausgabe
Warenkorb: 5000 Cent
FEHLER Bestellung genau an der Grenze: expected: <0> but was: <490>
OK Bestellung knapp unter der Grenze
OK Leerer Warenkorb
Szenarien: 3, bestanden: 2, fehlgeschlagen: 1

Die Meldung ist in jeder Sprache anders, weil jedes Werkzeug sie auf seine Art formuliert. Die Bedeutung ist dieselbe: Die erwartete Gebühr ist 0, die tatsächliche 490 Cent.

Genauer, für Neugierige

Die Ausgabe hier ist ein kurzer Bericht, nicht die übliche Ausgabe des Werkzeugs: eine Zeile pro Szenario, nach Namen geordnet, und die Anzahl. Die Szenarien im Kurs laufen wirklich, mit Cucumber-JVM 8, Reqnroll 3, behave 1.3 und cucumber-js 13, aber in einer Hülle, die nur diesen Bericht ausgibt. In einem Projekt gibt das Werkzeug mehr aus: jeden Schritt, die Zeiten und genau, wo es fehlgeschlagen ist.

Die Annotationen in Java (@Given, @After) und die Attribute in C# ([Given], [AfterScenario]) sind die aus dem Modul „Programme in der echten Welt“: Etiketten über einer Methode, die das Werkzeug per Reflection findet.

Übung

Schritte bei der Arbeit

1. Änderungen und Fallen. Die Fragen gehen um die Lieferschritte oben.

1. Das Marketing will den Schritt „Dann wird die Bestellung angenommen“ auch in den Lieferszenarien. Was musst du anfassen?
2. Das Szenario „Leerer Warenkorb“ besteht, obwohl die Ablehnung aus dem Code gelöscht wurde. Warum könnte das sein?
3. Der Betrag wird in den Schritten als 49,99 (eine Gleitkommazahl) statt in Cent gespeichert. Welches Risiko entsteht?

Test

Teste dich

1. Was ist eine Schritt-Definition?
2. Woher weiß „Wenn ich bestelle“, was der Warenkorb ist?
3. Wozu dient der Hook „nach dem Szenario“ in dieser Lektion?
4. Die Szenarien bestehen auf dem richtigen Code, und eines schlägt auf dem Code mit „>“ statt „>=“ fehl. Was zeigt das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·