Writing Effective Gherkin Scenarios and Step Definitions in Java

Writing Effective Gherkin Scenarios and Step Definitions in Java

Gherkin is not documentation. It is executable specification. The distinction matters: every word in a feature file is a constraint on your production code, not a description of it. Write it carelessly and you get brittle tests that break on every refactor and tell you nothing useful when they fail. Write it well and you get a living contract between stakeholders and the codebase.

This post covers the full Gherkin syntax, how to write step definitions that survive real-world projects, when to use Cucumber Expressions vs regex, custom parameter types, and the anti-patterns that turn BDD into bureaucracy.


Gherkin Syntax: The Complete Picture

Given / When / Then

These keywords represent phases, not vocabulary. Cucumber doesn't care which keyword you use — the underlying step matching is identical. Use them consistently to communicate intent:

  • Given — preconditions and initial state
  • When — the action under test
  • Then — observable outcomes
  • And / But — continuation of the previous phase
Scenario: Checkout with a discount coupon
  Given I have 3 items in my cart totalling £120.00
  And I have a coupon code "SAVE20" for 20% off
  When I apply the coupon at checkout
  Then the order total should be £96.00
  But the original price should still be displayed as £120.00

And and But bind to the phase of the step above them. In the step definition layer, there is no difference between Given, When, Then, And, and But — they all map to the same @Given, @When, @Then annotations (or just one of them — Cucumber doesn't enforce consistency).

Background

Background runs before every scenario in the feature. It's the right place for steps that set up state every scenario needs — not for steps that only some scenarios need:

Feature: Account Management

  Background:
    Given I am logged in as "admin@example.com"
    And the system has no pending notifications

  Scenario: Update display name
    When I change my display name to "Alice"
    Then my profile shows "Alice"

  Scenario: Change password
    When I change my password to "NewS3cur3P@ss"
    Then I am logged out and redirected to the login page

Do not use Background as a dumping ground for every step that happens to be shared. If a setup step is needed by only two of seven scenarios, put it in those two scenarios. Background steps that aren't universally required confuse readers and slow down scenarios that don't need them.

Scenario Outline and Examples

Use Scenario Outline when the same behaviour needs testing with multiple data sets. The Examples table drives it:

Scenario Outline: Password validation rules
  When I register with password "<password>"
  Then registration should "<result>"
  And the error should be "<error>"

  Examples:
    | password   | result  | error                          |
    | short      | fail    | Password must be 8+ characters |
    | nouppercase1 | fail  | Password must contain uppercase|
    | NoSpecial1 | fail    | Password must contain a symbol |
    | V@lid1Pass | succeed |                                |

Each row generates an independent scenario. Scenario Outline names in reports append the row values, so failures are immediately identifiable: Password validation rules (password: short, result: fail, ...).

One common mistake: using Scenario Outline when a single Examples table row could be expressed more clearly as a named scenario. Reserve outlines for genuine parameterisation, not for avoiding the effort of writing descriptive scenario names.

Data Tables

Data Tables pass structured data to a step without making the Gherkin line unreadable:

Scenario: Bulk user import
  When I import the following users:
    | email                | role    | department |
    | alice@example.com    | admin   | Engineering|
    | bob@example.com      | viewer  | Marketing  |
    | carol@example.com    | editor  | Content    |
  Then 3 users should exist in the system

In the step definition, access the table as a DataTable:

@When("I import the following users:")
public void importUsers(DataTable table) {
    List<Map<String, String>> rows = table.asMaps(String.class, String.class);
    for (Map<String, String> row : rows) {
        userService.create(
            row.get("email"),
            row.get("role"),
            row.get("department")
        );
    }
}

Or map directly to a typed list:

@When("I import the following users:")
public void importUsers(DataTable table) {
    List<UserRow> users = table.asList(UserRow.class);
    userService.bulkCreate(users);
}

For that to work, UserRow must have fields matching the column headers exactly, and a DataTableType converter registered — more on that in the parameter types section.

Doc Strings

Doc Strings pass multi-line content — JSON, XML, SQL, email bodies — to a step:

Scenario: Process incoming webhook
  When the system receives a webhook:
    """json
    {
      "event": "payment.completed",
      "orderId": "ORD-001",
      "amount": 99.00
    }
    """
  Then the order "ORD-001" status should be "paid"

The optional content type hint (json after the opening """) is accessible in the step definition but is not validated by Cucumber. Use it for readability and for IDE syntax highlighting plugins.

@When("the system receives a webhook:")
public void receiveWebhook(String body) {
    webhookProcessor.handle(body);
}

Step Definitions: The Implementation Layer

Cucumber Expressions vs Regular Expressions

Cucumber Expressions are the default since Cucumber 6. They're readable, less error-prone, and cover 90% of cases:

// Cucumber Expression — captures a quoted string
@When("I search for {string}")
public void search(String query) { ... }

// Cucumber Expression — captures an integer
@Then("the result count is {int}")
public void resultCount(int count) { ... }

// Cucumber Expression — captures a float
@Then("the price is {float}")
public void price(float amount) { ... }

// Cucumber Expression — captures a word (no spaces, no quotes)
@Given("I am a {word} user")
public void userRole(String role) { ... }

Built-in parameter types: {int}, {float}, {long}, {double}, {byte}, {short}, {biginteger}, {bigdecimal}, {string} (quoted), {word}, {} (anonymous, matches anything).

Switch to regular expressions when:

  • You need optional groups
  • You need alternation ((add|remove))
  • The pattern is genuinely complex
// Regex — optional "not" prefix
@Then("^the user should( not)? be active$")
public void userActiveState(String negation) {
    boolean shouldBeActive = negation == null;
    // ...
}

Do not mix styles in the same project without a reason. Cucumber Expressions are cleaner and should be the default; regex is the escape hatch.

Custom Parameter Types

Register custom types with @ParameterType to avoid repeating parsing logic across step definitions:

package com.example.bdd.types;

import io.cucumber.java.ParameterType;

public class ParameterTypes {

    @ParameterType("published|draft|archived")
    public PostStatus postStatus(String value) {
        return PostStatus.valueOf(value.toUpperCase());
    }

    @ParameterType("[A-Z]{2}-\\d+")
    public OrderId orderId(String value) {
        return new OrderId(value);
    }
}

Now use them directly in Gherkin:

Scenario: Publish a draft post
  Given a post in "draft" status
  When I publish the post
  Then the post status should be "published"
@Given("a post in {postStatus} status")
public void postWithStatus(PostStatus status) { ... }

@Then("the post status should be {postStatus}")
public void assertPostStatus(PostStatus expected) { ... }

The @ParameterType regex is what appears in the Gherkin (without quotes in this example — for quoted matching, wrap in "([^"]+)" and include the quotes in the annotation pattern).

DataTable Type Converters

For mapping Data Table rows to typed objects:

@DataTableType
public UserRow userRow(Map<String, String> entry) {
    return new UserRow(
        entry.get("email"),
        entry.get("role"),
        entry.get("department")
    );
}

Register this in any class on the glue path. Cucumber discovers it automatically.

Reusable Step Definitions

Step definitions become reusable when they're written at the right level of abstraction. The wrong level:

// Too technical — implementation detail exposed
@When("I POST to \\/api\\/users with body \\{\"email\":\"alice@example.com\"\\}")
public void postToUsersEndpoint() { ... }

The right level:

// Describes behaviour, not implementation
@When("I register with email {string} and password {string}")
public void registerUser(String email, String password) {
    // HTTP call lives here, not in Gherkin
}

The business-readable step can be reused across multiple scenarios. The technical one cannot.

Organising Step Definition Classes

There is no rule that every feature file needs its own step definition class. Organise by domain concept, not by feature file:

steps/
  AuthSteps.java       ← login, logout, session assertions
  UserSteps.java       ← user creation, profile management
  OrderSteps.java      ← cart, checkout, order status
  CommonSteps.java     ← generic HTTP assertions, shared utilities

Steps are discovered globally — any step from any class can be used in any feature file. This enables genuine reuse.


Anti-Patterns

1. Implementation Details in Gherkin

# Bad — exposes implementation
When I send a POST request to "/api/v2/orders" with Content-Type "application/json"
And the request body is "{ \"productId\": 42, \"quantity\": 1 }"
# Good — describes behaviour
When I add product "Wireless Headphones" to my cart

Gherkin that reads like an HTTP client test is not BDD. It couples the specification to the implementation — change the endpoint, and the specification breaks even though the behaviour is unchanged.

2. Scenario-as-Journey (the Monster Scenario)

# Bad — tests the entire user journey in one scenario
Scenario: Full e-commerce flow
  Given I am a new user
  When I register
  And I browse the catalogue
  And I add 3 items to my cart
  And I apply a discount code
  And I check out
  And I receive a confirmation email
  Then the order appears in my account history

This scenario is untestable in isolation, slow, and when it fails, you don't know where. Split it into focused scenarios, each testing one behaviour. Use Background or API setup calls to establish preconditions without re-testing prior steps.

3. Scenario Outline Abuse

# Bad — single data point doesn't need an Outline
Scenario Outline: User can log in
  Given I am on the login page
  When I log in as "<user>"
  Then I see the dashboard

  Examples:
    | user  |
    | alice |

If there's only one example row, use a plain Scenario. Outlines are for parameterisation across multiple meaningful data variants.

4. Given Steps That Test

# Bad — Given step asserts something
Given the order total is £120.00
# This is a Then, masquerading as a Given

Given steps establish state. They should not contain assertions. If you need to verify a precondition is correct before continuing, extract the setup logic and verify it in a Then step in a prior scenario.

5. Vague Then Steps

# Bad — what does "works" mean?
Then the checkout works correctly
# Good — specific, falsifiable
Then the order confirmation page displays order ID "ORD-8821"
And a confirmation email is sent to "alice@example.com"
And the inventory for "Wireless Headphones" decreases by 1

A Then step that can't fail is not a test.


Living Documentation

The payoff of well-written Gherkin is that it stays accurate. Generated reports from tools like cucumber-reporting or Allure produce HTML that product managers and stakeholders can read without understanding Java.

Configure the HTML plugin in your suite runner:

@ConfigurationParameter(
    key = PLUGIN_PROPERTY_NAME,
    value = "pretty, html:target/cucumber-reports/index.html, json:target/cucumber-reports/report.json"
)

For richer reports, add the maven-cucumber-reporting plugin:

<plugin>
  <groupId>net.masterthought</groupId>
  <artifactId>maven-cucumber-reporting</artifactId>
  <version>5.8.1</version>
  <executions>
    <execution>
      <id>execution</id>
      <phase>verify</phase>
      <goals>
        <goal>generate</goal>
      </goals>
      <configuration>
        <projectName>My Project</projectName>
        <outputDirectory>${project.build.directory}/site/cucumber-reports</outputDirectory>
        <jsonFiles>
          <param>**/cucumber-reports/*.json</param>
        </jsonFiles>
      </configuration>
    </execution>
  </executions>
</plugin>

The output is a navigable HTML report with scenario results, pass/fail history, and step-level timing — directly shareable with non-technical stakeholders as proof of what the system does and doesn't do.


Good Gherkin is disciplined writing. Every scenario has one job. Every step describes behaviour, not implementation. Step definitions are small, reusable, and Spring-managed. The result is a test suite that doubles as accurate documentation and fails loudly when the code breaks.

HelpMeTest extends BDD coverage to production monitoring — write test scenarios in plain English and let AI run them continuously, with usage-based pricing at $0.003 per test run, no code required.

Read more

Start now free