Cucumber Best Practices: Writing Maintainable Gherkin and Step Definitions

Cucumber Best Practices: Writing Maintainable Gherkin and Step Definitions

Cucumber is easy to start with and surprisingly hard to do well. The gap between a Cucumber test suite that helps the team and one that becomes a maintenance burden comes down to a handful of decisions about how you write Gherkin and structure step definitions.

This guide focuses on practices that keep Cucumber tests valuable as the codebase grows.

The Core Principle: Scenarios Describe Behavior, Not Implementation

The most important rule in Cucumber is that scenarios describe what the system does, not how it does it. When scenarios leak implementation details, they break every time the implementation changes — even when the behavior is unchanged.

Too implementation-focused:

# BAD — knows about the database and UI components
Scenario: Save user preferences
  Given I click the "Settings" button with id "settings-btn-primary"
  When I toggle the checkbox with name "email_notifications"
  And I click the button with text "Save" in the ".preferences-form" container
  Then the "email_notifications" column in the "users" table should be "false"

Behavior-focused:

# GOOD — describes intent, agnostic to implementation
Scenario: Disable email notifications
  Given I am on the account settings page
  When I turn off email notifications
  Then I should not receive email notifications for account activity

The second scenario survives a complete UI redesign, a database schema change, or switching from a checkbox to a toggle switch. The first breaks when the button ID changes.

Write Declarative, Not Imperative Scenarios

Imperative Gherkin describes every click and form field. Declarative Gherkin describes the business intent.

Imperative (hard to read, brittle):

Scenario: Log into the application
  Given I navigate to "http://example.com/login"
  When I enter "alice@example.com" in the "email" field
  And I enter "p@ssw0rd" in the "password" field
  And I click the "Log In" button
  Then I should see the text "Welcome, Alice" on the page
  And the URL should be "http://example.com/dashboard"

Declarative (expressive, stable):

Scenario: Successful login
  Given I am a registered user
  When I log in with valid credentials
  Then I should be on my dashboard
  And I should see a personalized greeting

The declarative version reads like documentation. The imperative version reads like a test script. Documentation survives refactoring; test scripts don't.

Keep Scenarios Short and Focused

A scenario should test one thing. When a scenario has 15 steps, it's testing a workflow, not a behavior. Long scenarios have these problems:

  1. They fail for multiple reasons — hard to diagnose
  2. They're hard to read — cognitive load is too high
  3. They couple unrelated behaviors — change one thing, multiple scenarios break

Too long — testing multiple behaviors:

Scenario: E-commerce purchase flow
  Given I am a logged-in customer
  When I search for "laptop"
  And I filter by price range $500-$1000
  And I click on the first result
  And I add it to my cart
  And I view my cart
  And I proceed to checkout
  And I enter my shipping address
  And I enter my payment details
  And I confirm the order
  Then I should see an order confirmation
  And I should receive a confirmation email
  And the item should be removed from inventory
  And my payment should be charged

Better — focused scenarios:

Scenario: Add item to cart from search results
  Given I have searched for "laptop"
  When I add the first result to my cart
  Then my cart should contain 1 item

Scenario: Complete checkout with credit card
  Given I have items in my cart
  And I have entered my shipping address
  When I pay with a credit card
  Then my order should be confirmed
  And I should receive a confirmation email

Aim for 3-7 steps per scenario. If you're writing more, split into multiple scenarios or move repeated setup to a Background.

Use Background for Common Preconditions

If every scenario in a feature starts with the same Given steps, move them to Background:

Repetitive:

Scenario: View open tickets
  Given I am logged in as an admin
  And the ticket system is running
  When I navigate to the tickets page
  Then I see all open tickets

Scenario: Close a ticket
  Given I am logged in as an admin
  And the ticket system is running
  When I close ticket #1234
  Then ticket #1234 should be marked as closed

With Background:

Background:
  Given I am logged in as an admin

Scenario: View open tickets
  When I navigate to the tickets page
  Then I see all open tickets

Scenario: Close a ticket
  When I close ticket #1234
  Then ticket #1234 should be marked as closed

But don't overuse Background. If the preconditions aren't universal to every scenario in the feature, don't put them there — it creates confusion when a scenario's behavior depends on Background steps the reader must scroll up to find.

Step Definition Reuse

Write step definitions to be reused across features. Generic, composable steps scale; feature-specific steps bloat.

Too specific (not reusable):

@Given("I have a bank account with balance 500 for the transfer test")
public void setupTransferTest() { ... }

Generic (reusable):

@Given("I have a bank account with a balance of {int}")
public void i_have_a_bank_account_with_balance(int balance) { ... }

Organize step definitions by domain concept, not by feature file:

steps/
  UserSteps.java     — steps about users (create, authenticate, profile)
  OrderSteps.java    — steps about orders (create, modify, cancel)
  PaymentSteps.java  — steps about payments (charge, refund)
  ApiSteps.java      — generic HTTP request/response steps
  DatabaseSteps.java — generic database inspection steps

Avoid Scenario Interdependence

Scenarios must be independent and runnable in any order. If Scenario B depends on Scenario A having run first, your test suite is fragile.

Wrong — depends on previous scenario:

Scenario: Create a user
  When I create a user with email "test@example.com"
  Then the user should exist

Scenario: Update the user's name  
  # WRONG: assumes previous scenario ran and user exists
  When I update the name for "test@example.com" to "New Name"
  Then the user's name should be "New Name"

Right — each scenario creates its own preconditions:

Scenario: Create a user
  When I create a user with email "test@example.com"
  Then the user should exist

Scenario: Update a user's name
  Given a user exists with email "test@example.com"
  When I update their name to "New Name"
  Then the user's name should be "New Name"

The Given a user exists... step creates the user as part of its own setup. Now the scenarios run independently.

Data Tables Over Multiple Parameters

When a step needs multiple related pieces of data, a data table is cleaner than multiple step parameters:

Ugly with many parameters:

When I create a product with name "Widget", price 29.99, category "Hardware", SKU "WDG-001", and stock 100

Clean with data table:

When I create a product with the following details:
  | name     | price | category | sku     | stock |
  | Widget   | 29.99 | Hardware | WDG-001 | 100   |
@When("I create a product with the following details:")
public void i_create_a_product(DataTable table) {
    Map<String, String> data = table.asMaps().get(0);
    productService.create(
        data.get("name"),
        Double.parseDouble(data.get("price")),
        data.get("category"),
        data.get("sku"),
        Integer.parseInt(data.get("stock"))
    );
}

Using Tags Effectively

Tags organize and filter scenarios. A few conventions that scale:

@smoke          # Core happy path — runs in every CI check
@regression     # Full regression suite — runs before release
@slow           # Tests that take >30s — run in nightly build only
@wip            # Work in progress — skip in CI
@bug:JIRA-1234  # Scenario reproducing a specific bug
@api            # Tests hitting the REST API
@ui             # Tests using a browser
@database       # Tests requiring database access

Avoid business-domain tags (@checkout, @authentication) at the scenario level — those are implicit from the feature file name. Save tags for cross-cutting concerns (speed, environment, status).

CI configuration:

# Quick check (smoke only, <2 minutes)
mvn test -Dcucumber.filter.tags="@smoke"

# Full regression (excluding WIP, <15 minutes)
mvn test -Dcucumber.filter.tags="not @wip and not @slow"

# Nightly full suite
mvn test -Dcucumber.filter.tags="not @wip"

Don't Test Technical Details Through Gherkin

Gherkin is for business behavior. Don't write scenarios for:

  • Performance thresholds (Then the response time should be under 200ms)
  • Security headers (Then the response should have header X-Frame-Options: DENY)
  • Database schema validation
  • Log output verification

These belong in unit tests, performance tests, or security tests — not Cucumber scenarios. When you see these in Gherkin, the team has lost sight of what BDD is for.

Maintain a Step Definition Glossary

As the suite grows, step definitions proliferate. Keep a STEPS.md in the test directory listing the most useful, reusable steps:

## User Steps
- `Given a user exists with email {string}`
- `Given I am logged in as {string}`
- `When I create a user with email {string} and name {string}`

## API Steps
- `When I send a GET request to {string}`
- `When I send a POST request to {string} with body:`
- `Then the response status should be {int}`
- `Then the response should contain {string}`

This prevents duplication — developers check the glossary before writing new step definitions.

When Cucumber Is the Wrong Tool

Cucumber isn't always the right choice. Consider alternatives when:

  • All your testers are developers: JUnit/TestNG with good naming is simpler and equally readable for engineers
  • You need fast unit tests: Cucumber overhead (parsing, reflection, context) makes it slower than pure JUnit
  • No non-technical stakeholders read the tests: If product managers never read the .feature files, the Gherkin ceremony has no payoff
  • API-only service: API tests in plain Java with RestAssured are simpler and more maintainable

Cucumber shines when there's a genuine collaboration need — product owners, business analysts, or QA engineers who don't code participate in reviewing or writing scenarios. If that's not your situation, evaluate whether the overhead is worth it.

Wrapping Up

Good Cucumber comes down to discipline:

  • Scenarios describe behavior, not implementation
  • Steps are declarative and reusable
  • Each scenario is independent
  • Tags organize by cross-cutting concerns, not business domain
  • Keep it short

Teams that follow these practices have Cucumber test suites that grow cleanly. Teams that don't end up with thousands of overlapping step definitions, interdependent scenarios, and feature files that require a PhD to understand. The difference is almost entirely style choices made early in the project.

Read more

Start now free