Skip to content
wannacode

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:

delivery.feature
# language: en
Feature: 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 refused

Every line is connected to a method in the code. That method is called a step definition. Above it stands the text it matches:

src/test/java/shop/DeliverySteps.java
@Given("a cart of {amount} €")
public void cart(int cents) {
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:

src/test/java/shop/DeliverySteps.java
@ParameterType(name = "amount", value = "\\d+\\.\\d{2}")
public int amount(String text) {
return Integer.parseInt(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?

1. The step is "Given a cart of 49.99 €". Which definition matches it?
2. A scenario says "Given a cart of 50 €", without the cents. What happens?
3. Two programmers wrote the same definition in two files. What does the tool say?

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:

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

"Then" compares with what is expected. This is the check that can fail:

src/test/java/shop/DeliverySteps.java
@Then("delivery is {amount} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}

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:

src/test/java/shop/DeliverySteps.java
// 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:

src/test/java/shop/DeliverySteps.java
// 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");
}
}

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".

  1. Given a cart of 50.00 €
  2. The hook after the scenario
  3. Then delivery is free
  4. When I place the order
  5. The hook before the scenario

The idea

The run

Here is the rule the scenarios run against:

src/main/java/shop/Delivery.java
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;
}
}

The tool runs the three scenarios. All of them pass:

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 {
// 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");
}
}
}
Output
PASS An empty cart
PASS An order exactly on the boundary
PASS An order just under the boundary
Scenarios: 3, passed: 3, failed: 0

Now 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:

src/main/java/shop/Delivery.java
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;
}
}
Output
cart: 5000 cents
FAIL An order exactly on the boundary: expected: <0> but was: <490>
PASS An empty cart
PASS An order just under the boundary
Scenarios: 3, passed: 2, failed: 1

The 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.

1. Marketing wants the step "Then the order is accepted" in the delivery scenarios too. What do you have to touch?
2. The scenario "An empty cart" passes even though the refusal has been deleted from the code. Why could that be?
3. The amount in the steps is stored as 49.99 (a floating-point number) instead of in cents. Which risk appears?

Test

Check yourself

1. What is a step definition?
2. How does "When I place the order" know what the cart is?
3. What is the "after the scenario" hook for in this lesson?
4. The scenarios pass on the correct code, and one fails on the code with ">" instead of ">=". What does this show?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·