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 activityThe 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 greetingThe 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:
- They fail for multiple reasons — hard to diagnose
- They're hard to read — cognitive load is too high
- 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 chargedBetter — 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 emailAim 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 closedWith 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 closedBut 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 stepsAvoid 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 100Clean 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 accessAvoid 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
.featurefiles, 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.