From scenario to steps
In short: A scenario is text. To run it, every line is connected to a method in the code through an expression: "a cart of {amount} €" matches the line and passes the amount. The steps of one scenario share state: "Given" records the cart, "When" calls the rule, "Then" checks. Hooks run before and after every scenario. The tool is different in every language; the idea is the same.
Problem
A scenario is only text
In the Gherkin module you wrote the delivery scenarios. The checker in the course ran them against the example system and told you whether they catch the bugs.
A real project has no such checker. The programmer looks at the scenarios file and says: "The computer does not know what 'a cart of 50.00 €' is. That is a sentence in English." He is right. Something has to translate every line into code.
The idea
Step definitions
Here are the scenarios. In the project's file they are unchanged, the same ones the business has read and accepted:
# language: enFeature: Free delivery Delivery is free for an order from 50 € inclusive, otherwise it is 4.90 €.
Scenario: An order exactly on the boundary Given a cart of 50.00 € When I place the order Then delivery is free
Scenario: An order just under the boundary Given a cart of 49.99 € When I place the order Then delivery is 4.90 €
Scenario: An empty cart Given an empty cart When I place the order Then the order is refusedEvery line is connected to a method in the code. That method is called a step definition. Above it stands the text it matches:
@Given("a cart of {amount} €")public void cart(int cents) { total = cents;}[Given("a cart of {amount} €")]public void Cart(int cents) => total = cents;@given("a cart of {total:Amount} €")def step_cart(context, total): context.total = totalGiven("a cart of {amount} €", function (cents) { this.total = cents;});The tool reads the scenario line by line, finds the definition whose text fits, and calls it. The tools are different in each language, but the idea is the same:
- Cucumber-JVM for Java;
- Reqnroll for C# (the successor of SpecFlow);
- behave for Python;
- cucumber-js for JavaScript.
More precisely, if you are curious
Cucumber is the original, from Ruby. Cucumber-JVM and cucumber-js are its official versions, while Reqnroll and behave are separate projects with the same idea and the same Gherkin. In Cucumber-JVM the annotations are @Given, @When and @Then, but which of the three sits above a method does not actually matter: the text decides.
Gherkin keywords exist in many languages, and the official Gherkin dictionary lists them. A line like # language: bg at the top tells the tool which ones to read; without it, English is assumed.
The idea
Expressions and parameter types
a cart of {amount} € matches any amount. {amount} is a parameter: the place where the number is, and the rule for turning the text into a value. The amount is kept in cents, as a whole number: "49.99" becomes 4999. That way there are no rounding errors.
The type is described once, with a regular expression ("digits, a point, two digits") and a function that makes the number:
@ParameterType(name = "amount", value = "\\d+\\.\\d{2}")public int amount(String text) { return Integer.parseInt(text.replace(".", ""));}[StepArgumentTransformation(@"(\d+\.\d{2})", Name = "amount")]public int Amount(string text) => int.Parse(text.Replace(".", ""));def parse_amount(text): # {amount} in the steps: "49.99" becomes 4999 cents. return int(text.replace(".", ""))
parse_amount.pattern = r"\d+\.\d{2}"register_type(Amount=parse_amount)defineParameterType({ name: "amount", regexp: /\d+\.\d{2}/, transformer: (text) => Number(text.replace(".", "")),});Expressions with {…} are called Cucumber expressions. They are easier to read than regular expressions. There are built-in types for whole numbers and words ({int}, {word}), and the amount in euros is the project's own type.
More precisely, if you are curious
By default behave does not use Cucumber expressions but the parse library, where the parameter is written {total:Amount} and the type is registered with register_type. (behave 1.3 can also use Cucumber expressions, but that support is still experimental.) Reqnroll accepts both kinds of expression. In Reqnroll the regular expression of the type is in brackets, because the value comes from the group in it.
If no definition matches a line, the step is undefined, and the scenario does not pass. The tools usually print an empty definition for you to copy. If two match it, the step is ambiguous, and that is an error.
Try it
Which line, which definition?
The idea
The state of one scenario
"When I place the order" has no amount in its line. How does it know what the cart is? From the state of the scenario: "Given" records it, "When" uses it, "Then" checks it.
"When" calls the rule. If the rule refuses with an exception, the refusal is recorded instead of stopping the scenario. That is what "Then the order is refused" will check:
@When("I place the order")public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); }}[When("I place the order")]public void Order(){ try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; }}@when("I place the order")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)When("I place the order", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});"Then" compares with what is expected. This is the check that can fail:
@Then("delivery is {amount} €")public void feeIs(int cents) { assertEquals(cents, fee);}[Then("delivery is {amount} €")]public void FeeIs(int cents) => Assert.Equal(cents, fee);@then("delivery is {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"delivery is {context.fee}, expected {fee}"Then("delivery is {amount} €", function (cents) { assert.equal(this.fee, cents);});The state is new for every scenario. In Java and C# the tool creates a new object of the steps class for every scenario. In JavaScript the this object (the World) is new, and in behave whatever a scenario puts into context is removed when the scenario ends. A scenario must not depend on another one: each runs on its own and in any order.
More precisely, if you are curious
In Java and C# the state is the fields of the class:
// The state of one scenario: a new object is made for every scenario.private int total;private int fee;private String refusal;// The state of one scenario: a new object is made for every scenario.private int total;private int fee;private string refusal;When there are many steps, they are split into several classes. Then the shared state lives in a separate object that the tool hands to every class (in Cucumber-JVM with a dependency injection library such as PicoContainer, in Reqnroll with its built-in container). In behave context, and in cucumber-js this (the World object), are already shared by all step files.
The idea
Hooks
Sometimes something has to happen before or after every scenario: clean the database, open a connection, record what happened. These are hooks. Here the hook after the scenario records which cart it failed with, if it failed:
// After every scenario: if it failed, record the cart it had.@Afterpublic void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("cart: " + total + " cents"); }}// After every scenario: if it failed, record the cart it had.[AfterScenario]public void ShowCart(){ if (scenario.TestError != null) { Console.WriteLine("cart: " + total + " cents"); }}def before_scenario(context, scenario): # Before every scenario: nothing is refused. context.refusal = None
def after_scenario(context, scenario): # After every scenario: if it failed, record the cart it had. if scenario.status == "failed": print(f"cart: {context.total} cents")import { After, Before, Status } from "@cucumber/cucumber";
// Before every scenario: nothing is refused.Before(function () { this.refusal = null;});
// After every scenario: if it failed, record the cart it had.After(function ({ result }) { if (result.status === Status.FAILED) { console.log(`cart: ${this.total} cents`); }});One rule: a hook prepares and cleans up, but it does not check and does not hide steps that matter to the reader. If every scenario needs a cart, that is a "Background" in the scenario, where the business can see it, not a hook.
Try it
The order of execution
Put in order what the tool calls for the scenario "An order exactly on the boundary".
Given a cart of 50.00 €The hook after the scenarioThen delivery is freeWhen I place the orderThe hook before the scenario
The hooks surround the scenario. Between them the steps run in the order they are written. Then the next scenario starts with a new before hook and new state.
The idea
The run
Here is the rule the scenarios run against:
package shop;
public class Delivery { public static final int FREE_FROM = 5000; public static final int FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int feeFor(int total) { if (total <= 0) { throw new IllegalArgumentException("An empty cart cannot be ordered"); } return total >= FREE_FROM ? 0 : FEE; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("An empty cart cannot be ordered"); } return total >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """The delivery fee in cents. total is the cart total, also in cents.""" if total <= 0: raise ValueError("An empty cart cannot be ordered") return 0 if total >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents.export function deliveryFee(total) { if (total <= 0) { throw new Error("An empty cart cannot be ordered"); } return total >= FREE_FROM ? 0 : FEE;}The tool runs the three scenarios. All of them pass:
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 { // The state of one scenario: a new object is made for every scenario. private int total; private int fee; private String refusal;
// {amount} in the steps: "49.99" becomes 4999 cents. @ParameterType(name = "amount", value = "\\d+\\.\\d{2}") public int amount(String text) { return Integer.parseInt(text.replace(".", "")); }
@Given("a cart of {amount} €") public void cart(int cents) { total = cents; }
@Given("an empty cart") public void emptyCart() { total = 0; }
@When("I place the order") public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); } }
@Then("delivery is {amount} €") public void feeIs(int cents) { assertEquals(cents, fee); }
@Then("delivery is free") public void free() { assertEquals(0, fee); }
@Then("the order is refused") public void refused() { assertNotNull(refusal, "the order is accepted"); }
// After every scenario: if it failed, record the cart it had. @After public void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("cart: " + total + " cents"); } }}PASS An empty cartPASS An order exactly on the boundaryPASS An order just under the boundaryScenarios: 3, passed: 3, failed: 0using Reqnroll;using Xunit;
namespace Shop;
[Binding]public class DeliverySteps(ScenarioContext scenario){ // The state of one scenario: a new object is made for every scenario. private int total; private int fee; private string refusal;
// {amount} in the steps: "49.99" becomes 4999 cents. [StepArgumentTransformation(@"(\d+\.\d{2})", Name = "amount")] public int Amount(string text) => int.Parse(text.Replace(".", ""));
[Given("a cart of {amount} €")] public void Cart(int cents) => total = cents;
[Given("an empty cart")] public void EmptyCart() => total = 0;
[When("I place the order")] public void Order() { try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; } }
[Then("delivery is {amount} €")] public void FeeIs(int cents) => Assert.Equal(cents, fee);
[Then("delivery is free")] public void Free() => Assert.Equal(0, fee);
[Then("the order is refused")] public void Refused() => Assert.NotNull(refusal);
// After every scenario: if it failed, record the cart it had. [AfterScenario] public void ShowCart() { if (scenario.TestError != null) { Console.WriteLine("cart: " + total + " cents"); } }}PASS An empty cartPASS An order exactly on the boundaryPASS An order just under the boundaryScenarios: 3, passed: 3, failed: 0from behave import given, register_type, then, when
from delivery import delivery_fee
def parse_amount(text): # {amount} in the steps: "49.99" becomes 4999 cents. return int(text.replace(".", ""))
parse_amount.pattern = r"\d+\.\d{2}"register_type(Amount=parse_amount)
@given("a cart of {total:Amount} €")def step_cart(context, total): context.total = total
@given("an empty cart")def step_empty_cart(context): context.total = 0
@when("I place the order")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)
@then("delivery is {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"delivery is {context.fee}, expected {fee}"
@then("delivery is free")def step_free(context): assert context.fee == 0, f"delivery is {context.fee}, expected 0"
@then("the order is refused")def step_refused(context): assert context.refusal, "the order is accepted"PASS An empty cartPASS An order exactly on the boundaryPASS An order just under the boundaryScenarios: 3, passed: 3, failed: 0import { Given, When, Then, defineParameterType } from "@cucumber/cucumber";import assert from "node:assert/strict";import { deliveryFee } from "../../delivery.js";
// {amount} in the steps: "49.99" becomes 4999 cents.defineParameterType({ name: "amount", regexp: /\d+\.\d{2}/, transformer: (text) => Number(text.replace(".", "")),});
Given("a cart of {amount} €", function (cents) { this.total = cents;});
Given("an empty cart", function () { this.total = 0;});
When("I place the order", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});
Then("delivery is {amount} €", function (cents) { assert.equal(this.fee, cents);});
Then("delivery is free", function () { assert.equal(this.fee, 0);});
Then("the order is refused", function () { assert.ok(this.refusal, "the order is accepted");});PASS An empty cartPASS An order exactly on the boundaryPASS An order just under the boundaryScenarios: 3, passed: 3, failed: 0Now the programmer changes >= to >. The same bug that was a mutant in the Gherkin module is now in real code. The scenario on the boundary fails, and the hook says with which cart:
package shop;
public class Delivery { public static final int FREE_FROM = 5000; public static final int FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int feeFor(int total) { if (total <= 0) { throw new IllegalArgumentException("An empty cart cannot be ordered"); } return total > FREE_FROM ? 0 : FEE; }}cart: 5000 centsFAIL An order exactly on the boundary: expected: <0> but was: <490>PASS An empty cartPASS An order just under the boundaryScenarios: 3, passed: 2, failed: 1namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("An empty cart cannot be ordered"); } return total > FreeFrom ? 0 : Fee; }}cart: 5000 centsFAIL An order exactly on the boundary: Assert.Equal() Failure: Values differ Expected: 0 Actual: 490PASS An empty cartPASS An order just under the boundaryScenarios: 3, passed: 2, failed: 1FREE_FROM = 5000FEE = 490
def delivery_fee(total): """The delivery fee in cents. total is the cart total, also in cents.""" if total <= 0: raise ValueError("An empty cart cannot be ordered") return 0 if total > FREE_FROM else FEEcart: 5000 centsFAIL An order exactly on the boundary: ASSERT FAILED: delivery is 490, expected 0PASS An empty cartPASS An order just under the boundaryScenarios: 3, passed: 2, failed: 1export const FREE_FROM = 5000;export const FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents.export function deliveryFee(total) { if (total <= 0) { throw new Error("An empty cart cannot be ordered"); } return total > FREE_FROM ? 0 : FEE;}cart: 5000 centsFAIL An order exactly on the boundary: 490 !== 0PASS An empty cartPASS An order just under the boundaryScenarios: 3, passed: 2, failed: 1The message is different in every language, because every tool phrases it its own way. The meaning is the same: the expected fee is 0, the actual one is 490 cents.
More precisely, if you are curious
The output here is a short report, not the tool's usual output: one line per scenario, sorted by name, and the count. The scenarios in the course really run, with Cucumber-JVM 8, Reqnroll 3, behave 1.3 and cucumber-js 13, but inside a wrapper that prints only this report. In a project the tool prints more: every step, the timings and exactly where it failed.
The annotations in Java (@Given, @After) and the attributes in C# ([Given], [AfterScenario]) are the ones from the module "Real-world programs": labels above a method that the tool finds with reflection.
Exercise
Steps at work
1. Changes and traps. The questions are about the delivery steps above.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Scenario Outline and Examples · Living documentation · Boundary values · Why and what to automate
- Continues in: Component tests · API and user interface · Tests in CI · Promo codes: from the request to the release
- Tasks on this topic: Automation