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:
# language: deFunktionalitä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 abgelehntJede Zeile ist mit einer Methode im Code verbunden. Diese Methode heißt Schritt-Definition. Über ihr steht der Text, auf den sie passt:
@Given("ein Warenkorb von {Betrag} €")public void cart(int cents) { total = cents;}[Given("ein Warenkorb von {Betrag} €")]public void Cart(int cents) => total = cents;@given("ein Warenkorb von {total:Amount} €")def step_cart(context, total): context.total = totalGiven("ein Warenkorb von {Betrag} €", function (cents) { this.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:
@ParameterType(name = "Betrag", value = "\\d+,\\d{2}")public int amount(String text) { return Integer.parseInt(text.replace(",", ""));}[StepArgumentTransformation(@"(\d+,\d{2})", Name = "Betrag")]public int Amount(string text) => int.Parse(text.Replace(",", ""));def parse_amount(text): # {Betrag} in den Schritten: „49,99“ wird zu 4999 Cent. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)defineParameterType({ name: "Betrag", regexp: /\d+,\d{2}/, transformer: (text) => Number(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?
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“:
@When("ich bestelle")public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); }}[When("ich bestelle")]public void Order(){ try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; }}@when("ich bestelle")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)When("ich bestelle", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});„Dann“ vergleicht mit dem, was erwartet wird. Das ist die Prüfung, die fehlschlagen kann:
@Then("beträgt die Lieferung {Betrag} €")public void feeIs(int cents) { assertEquals(cents, fee);}[Then("beträgt die Lieferung {Betrag} €")]public void FeeIs(int cents) => Assert.Equal(cents, fee);@then("beträgt die Lieferung {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"Lieferung ist {context.fee}, erwartet {fee}"Then("beträgt die Lieferung {Betrag} €", function (cents) { assert.equal(this.fee, cents);});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:
// Der Zustand eines Szenarios: Für jedes Szenario wird ein neues Objekt erzeugt.private int total;private int fee;private String refusal;// 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:
// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb.@Afterpublic void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("Warenkorb: " + total + " Cent"); }}// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb.[AfterScenario]public void ShowCart(){ if (scenario.TestError != null) { Console.WriteLine("Warenkorb: " + total + " Cent"); }}def before_scenario(context, scenario): # Vor jedem Szenario: Nichts ist abgelehnt. context.refusal = None
def after_scenario(context, scenario): # Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb. if scenario.status == "failed": print(f"Warenkorb: {context.total} Cent")import { After, Before, Status } from "@cucumber/cucumber";
// Vor jedem Szenario: Nichts ist abgelehnt.Before(function () { this.refusal = null;});
// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb.After(function ({ result }) { if (result.status === Status.FAILED) { console.log(`Warenkorb: ${this.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.
Der Hook vor dem SzenarioDann ist die Lieferung kostenlosDer Hook nach dem SzenarioWenn ich bestelleAngenommen ein Warenkorb von 50,00 €
Die Hooks umschließen das Szenario. Dazwischen laufen die Schritte in der Reihenfolge, in der sie geschrieben sind. Dann beginnt das nächste Szenario mit einem neuen Hook davor und neuem Zustand.
Die Idee
Der Lauf
Hier ist die Regel, gegen die die Szenarien laufen:
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; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const 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 ArgumentException("Ein leerer Warenkorb kann nicht bestellt werden"); } return total >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.""" if total <= 0: raise ValueError("Ein leerer Warenkorb kann nicht bestellt werden") return 0 if total >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.export function deliveryFee(total) { if (total <= 0) { throw new Error("Ein leerer Warenkorb kann nicht bestellt werden"); } return total >= FREE_FROM ? 0 : FEE;}Das Werkzeug lässt die drei Szenarien laufen. Alle bestehen:
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"); } }}OK Bestellung genau an der GrenzeOK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 3, fehlgeschlagen: 0using Reqnroll;using Xunit;
namespace Shop;
[Binding]public class DeliverySteps(ScenarioContext scenario){ // 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. [StepArgumentTransformation(@"(\d+,\d{2})", Name = "Betrag")] public int Amount(string text) => int.Parse(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 (ArgumentException e) { refusal = e.Message; } }
[Then("beträgt die Lieferung {Betrag} €")] public void FeeIs(int cents) => Assert.Equal(cents, fee);
[Then("ist die Lieferung kostenlos")] public void Free() => Assert.Equal(0, fee);
[Then("wird die Bestellung abgelehnt")] public void Refused() => Assert.NotNull(refusal);
// Nach jedem Szenario: Wenn es fehlgeschlagen ist, wird festgehalten, mit welchem Warenkorb. [AfterScenario] public void ShowCart() { if (scenario.TestError != null) { Console.WriteLine("Warenkorb: " + total + " Cent"); } }}OK Bestellung genau an der GrenzeOK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 3, fehlgeschlagen: 0from behave import given, register_type, then, when
from delivery import delivery_fee
def parse_amount(text): # {Betrag} in den Schritten: „49,99“ wird zu 4999 Cent. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)
@given("ein Warenkorb von {total:Amount} €")def step_cart(context, total): context.total = total
@given("ein leerer Warenkorb")def step_empty_cart(context): context.total = 0
@when("ich bestelle")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)
@then("beträgt die Lieferung {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"Lieferung ist {context.fee}, erwartet {fee}"
@then("ist die Lieferung kostenlos")def step_free(context): assert context.fee == 0, f"Lieferung ist {context.fee}, erwartet 0"
@then("wird die Bestellung abgelehnt")def step_refused(context): assert context.refusal, "die Bestellung wurde angenommen"OK Bestellung genau an der GrenzeOK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 3, fehlgeschlagen: 0import { Given, When, Then, defineParameterType } from "@cucumber/cucumber";import assert from "node:assert/strict";import { deliveryFee } from "../../delivery.js";
// {Betrag} in den Schritten: „49,99“ wird zu 4999 Cent.defineParameterType({ name: "Betrag", regexp: /\d+,\d{2}/, transformer: (text) => Number(text.replace(",", "")),});
Given("ein Warenkorb von {Betrag} €", function (cents) { this.total = cents;});
Given("ein leerer Warenkorb", function () { this.total = 0;});
When("ich bestelle", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});
Then("beträgt die Lieferung {Betrag} €", function (cents) { assert.equal(this.fee, cents);});
Then("ist die Lieferung kostenlos", function () { assert.equal(this.fee, 0);});
Then("wird die Bestellung abgelehnt", function () { assert.ok(this.refusal, "die Bestellung wurde angenommen");});OK Bestellung genau an der GrenzeOK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 3, fehlgeschlagen: 0Jetzt ä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:
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; }}Warenkorb: 5000 CentFEHLER Bestellung genau an der Grenze: expected: <0> but was: <490>OK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 2, fehlgeschlagen: 1namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const 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 ArgumentException("Ein leerer Warenkorb kann nicht bestellt werden"); } return total > FreeFrom ? 0 : Fee; }}Warenkorb: 5000 CentFEHLER Bestellung genau an der Grenze: Assert.Equal() Failure: Values differ Expected: 0 Actual: 490OK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 2, fehlgeschlagen: 1FREE_FROM = 5000FEE = 490
def delivery_fee(total): """Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.""" if total <= 0: raise ValueError("Ein leerer Warenkorb kann nicht bestellt werden") return 0 if total > FREE_FROM else FEEWarenkorb: 5000 CentFEHLER Bestellung genau an der Grenze: ASSERT FAILED: Lieferung ist 490, erwartet 0OK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 2, fehlgeschlagen: 1export const FREE_FROM = 5000;export const FEE = 490;
// Die Liefergebühr in Cent. total ist der Warenkorbwert, ebenfalls in Cent.export function deliveryFee(total) { if (total <= 0) { throw new Error("Ein leerer Warenkorb kann nicht bestellt werden"); } return total > FREE_FROM ? 0 : FEE;}Warenkorb: 5000 CentFEHLER Bestellung genau an der Grenze: 490 !== 0OK Bestellung knapp unter der GrenzeOK Leerer WarenkorbSzenarien: 3, bestanden: 2, fehlgeschlagen: 1Die 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.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Szenariogrundriss und Beispiele · Lebendige Dokumentation · Grenzwerte · Warum und was automatisieren
- Geht weiter in: Komponententests · API und Benutzeroberfläche · Tests in der CI · Gutscheincodes: von der Anfrage bis zur Freigabe
- Aufgaben zum Thema: Automatisierung