Cucumber Best Practices: Making BDD Work in Real Projects
Cucumber is one of the most widely used BDD frameworks, and it has one of the highest rates of teams abandoning it after six months. The reason is almost never the tool itself — it's the practices teams adopt when they first set it up. Cucumber gets blamed for the brittle, slow, unmaintainable test suite that actually resulted from not establishing clear conventions early on.
This guide covers the practices that make Cucumber work long-term in real projects with real teams.
Project Structure That Scales
The default Cucumber project structure works fine for ten feature files. It collapses badly at a hundred. Establish a clear directory layout from day one:
src/test/
├── resources/
│ └── features/
│ ├── authentication/
│ │ ├── login.feature
│ │ └── registration.feature
│ ├── checkout/
│ │ ├── cart.feature
│ │ ├── payment.feature
│ │ └── confirmation.feature
│ └── catalog/
│ ├── search.feature
│ └── product-detail.feature
└── java/
└── com/example/
├── steps/
│ ├── authentication/
│ │ ├── LoginSteps.java
│ │ └── RegistrationSteps.java
│ ├── checkout/
│ │ ├── CartSteps.java
│ │ └── PaymentSteps.java
│ └── common/
│ ├── NavigationSteps.java
│ └── AssertionSteps.java
├── pages/
│ ├── LoginPage.java
│ ├── DashboardPage.java
│ └── CheckoutPage.java
├── hooks/
│ ├── WebDriverHooks.java
│ └── DatabaseHooks.java
└── config/
├── DriverFactory.java
└── TestConfig.javaMirror the feature directory structure in the steps directory. When a test fails and a developer asks "where is the step definition for this?", they should be able to answer the question themselves without searching the entire codebase.
For JavaScript projects using Cucumber.js, a similar pattern applies:
features/
├── authentication/
│ ├── login.feature
│ └── registration.feature
├── checkout/
│ └── cart.feature
└── support/
├── hooks/
│ ├── browserHooks.js
│ └── databaseHooks.js
├── pages/
│ ├── LoginPage.js
│ └── CartPage.js
├── steps/
│ ├── authSteps.js
│ └── cartSteps.js
└── world.jsStep Definition Organization
The most common mistake in step definitions is writing steps that are too long. Step definitions should be thin — they orchestrate calls to page objects or service clients. Business logic has no business being inside a step definition.
Anti-pattern: step definition doing too much
@When("the user adds {string} to the cart")
public void userAddsToCart(String productName) {
driver.findElement(By.cssSelector(".search-box")).sendKeys(productName);
driver.findElement(By.cssSelector(".search-button")).click();
WebElement product = wait.until(
ExpectedConditions.elementToBeClickable(
By.xpath("//h2[contains(text(),'" + productName + "')]")
)
);
product.click();
driver.findElement(By.id("add-to-cart-btn")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector(".cart-notification")
));
}Better: delegate to a page object
@When("the user adds {string} to the cart")
public void userAddsToCart(String productName) {
catalogPage.searchFor(productName);
productPage.addToCart();
}The page object handles all the UI interaction details. The step definition captures the intent.
Page Objects vs the Screenplay Pattern
The Page Object Model (POM) is the standard approach: one class per page or significant UI component, exposing business-level methods:
public class LoginPage {
private final WebDriver driver;
private final By emailField = By.id("email");
private final By passwordField = By.id("password");
private final By submitButton = By.cssSelector("[type='submit']");
private final By errorMessage = By.cssSelector(".error-banner");
public LoginPage(WebDriver driver) {
this.driver = driver;
}
public void loginAs(String email, String password) {
driver.findElement(emailField).sendKeys(email);
driver.findElement(passwordField).sendKeys(password);
driver.findElement(submitButton).click();
}
public String getErrorMessage() {
return driver.findElement(errorMessage).getText();
}
public boolean isErrorDisplayed() {
return driver.findElements(errorMessage).size() > 0;
}
}POM works well for most projects. It gets awkward when the same UI element appears on multiple pages (navigation, modals, notifications) or when a test needs to interact with many pages in sequence.
The Screenplay Pattern addresses this by inverting the model: instead of pages that actors navigate, you have actors who perform tasks using abilities:
// Screenplay pattern in JavaScript (Serenity/JS)
const { Actor, BrowseTheWeb } = require('@serenity-js/web');
const { PlaywrightPage } = require('@serenity-js/playwright');
// Define a task
const Login = {
as: (email, password) =>
Task.where(`#actor logs in as ${email}`,
Navigate.to('/login'),
Enter.theValue(email).into(LoginForm.emailField()),
Enter.theValue(password).into(LoginForm.passwordField()),
Click.on(LoginForm.submitButton()),
)
};
// Use in step definitions
When('{actor} logs in with valid credentials', async (actor) => {
await actor.attemptsTo(
Login.as('alice@example.com', 'SecurePass123')
);
});Screenplay shines for complex, multi-actor scenarios (e.g., testing real-time collaboration) and when you want truly composable test logic. It has a steeper learning curve. For most teams, start with POM and migrate if you hit its limits.
Parallel Execution
Running Cucumber tests sequentially is the fastest path to a slow CI pipeline. Parallel execution requires planning:
Java with JUnit 5 and Cucumber:
// junit-platform.properties
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamic
cucumber.execution.parallel.config.dynamic.factor=2Java with Maven Surefire:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>4</forkCount>
<reuseForks>true</reuseForks>
<includes>
<include>**/*Runner.java</include>
</includes>
</configuration>
</plugin>JavaScript with Cucumber.js:
{
"parallel": 4,
"format": ["progress", "json:reports/cucumber.json"]
}For parallel execution to work reliably, each scenario must be fully independent:
- No shared mutable state — each scenario gets its own browser session, its own test user, its own data
- Unique test data — use random suffixes or UUIDs for usernames, emails, product names created during tests
- Clean up after yourself — hooks that delete test data created by the scenario
- Thread-safe driver management — use
ThreadLocal<WebDriver>in Java
public class DriverFactory {
private static final ThreadLocal<WebDriver> driverThreadLocal = new ThreadLocal<>();
public static WebDriver getDriver() {
if (driverThreadLocal.get() == null) {
driverThreadLocal.set(createDriver());
}
return driverThreadLocal.get();
}
public static void quitDriver() {
if (driverThreadLocal.get() != null) {
driverThreadLocal.get().quit();
driverThreadLocal.remove();
}
}
}Hooks: Setup and Teardown Done Right
Cucumber provides @Before, @After, @BeforeStep, and @AfterStep hooks. Use them for infrastructure concerns, not scenario logic:
public class WebDriverHooks {
@Before
public void setUp(Scenario scenario) {
WebDriver driver = DriverFactory.getDriver();
// Attach scenario name for better logging
((ChromeDriver) driver).executeCdpCommand(
"Target.setDiscoverTargets",
Map.of("discover", true)
);
}
@After
public void tearDown(Scenario scenario) {
WebDriver driver = DriverFactory.getDriver();
if (scenario.isFailed()) {
byte[] screenshot = ((TakesScreenshot) driver)
.getScreenshotAs(OutputType.BYTES);
scenario.attach(screenshot, "image/png", "Screenshot on failure");
}
DriverFactory.quitDriver();
}
}Hook best practices:
- Use
@Before(order = 10)to control hook execution order when multiple hooks apply - Tag-scoped hooks:
@Before("@database")only runs before scenarios tagged@database - Keep hooks fast — slow setup multiplies across every scenario
- Never put assertions in hooks — hook failures produce confusing error messages
Tagging Strategy That Works
Tags are the primary mechanism for organizing and selectively running your Cucumber suite. A consistent tagging strategy saves hours of CI configuration confusion:
@checkout @smoke @critical
Feature: Order Placement
@happy-path
Scenario: Successful order with credit card
@error-handling @payment
Scenario: Order rejection when card is declined
@performance @slow
Scenario: Placing 100 simultaneous orders
@wip
Scenario: Order with split payment (not yet implemented)Running subsets:
# Smoke suite — runs in CI on every commit
cucumber --tags <span class="hljs-string">"@smoke"
<span class="hljs-comment"># Full regression — runs nightly
cucumber --tags <span class="hljs-string">"@regression and not @wip"
<span class="hljs-comment"># Critical path only — runs before release
cucumber --tags <span class="hljs-string">"@critical"
<span class="hljs-comment"># Exclude slow tests from local dev
cucumber --tags <span class="hljs-string">"not @slow and not @wip"Tag your test runner configurations explicitly so engineers don't have to remember the tag syntax:
// SmokeTestRunner.java
@CucumberOptions(
features = "src/test/resources/features",
glue = "com.example.steps",
tags = "@smoke",
plugin = {"pretty", "html:target/smoke-report"}
)
public class SmokeTestRunner {}
// RegressionTestRunner.java
@CucumberOptions(
features = "src/test/resources/features",
glue = "com.example.steps",
tags = "@regression and not @wip",
plugin = {"pretty", "json:target/cucumber.json", "html:target/regression-report"}
)
public class RegressionTestRunner {}Reporting: Making Results Visible
The default Cucumber output is fine for local development. For CI and stakeholder communication, invest in better reporting.
Allure Report with Cucumber (Java):
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-cucumber7-jvm</artifactId>
<version>2.24.0</version>
</dependency>@CucumberOptions(
plugin = {
"io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"json:target/cucumber.json"
}
)Cucumber HTML Reports (JavaScript):
// cucumber.config.js
module.exports = {
default: {
format: [
'progress-bar',
'html:reports/cucumber-report.html',
'json:reports/cucumber-report.json',
'junit:reports/cucumber-junit.xml'
],
formatOptions: {
snippetInterface: 'async-await'
}
}
}Multiple format report generation gives you JSON for programmatic consumption (trend tracking, dashboards), HTML for human review, and JUnit XML for CI integration (GitHub Actions, Jenkins, CircleCI all consume JUnit format natively).
Keeping Tests Maintainable Over Time
The most important maintainability practice is treating feature files as first-class code — they go through review, they get refactored, and they get deleted when the feature they test is removed.
Conduct quarterly scenario audits:
- Remove scenarios for features that no longer exist
- Merge scenarios that overlap significantly
- Update scenarios when business language changes
- Promote well-exercised
@wipscenarios to mainstream tags
Step definition maintenance:
- Run a step coverage report regularly to find unused step definitions:
cucumber --dry-runreports step definitions with no matching steps - Parameterize steps aggressively to reduce the total step definition count
- Document ambiguous steps with comments — especially ones that look similar
// Handles "a logged-in user", "an authenticated user", "a signed-in user"
// All three phrasings exist in feature files from different authors — do not deduplicate
@Given("a logged-in user")
@Given("an authenticated user")
@Given("a signed-in user")
public void authenticatedUser() {
sessionHelper.createAuthenticatedSession();
}Cucumber and PicoContainer (dependency injection):
Sharing state between step definition classes without static fields requires dependency injection. PicoContainer is the simplest option:
// Shared state class
public class CheckoutContext {
public String orderId;
public BigDecimal totalAmount;
public String confirmationEmail;
}
// Step class A — receives context via constructor
public class CartSteps {
private final CheckoutContext context;
private final CartPage cartPage;
public CartSteps(CheckoutContext context, CartPage cartPage) {
this.context = context;
this.cartPage = cartPage;
}
@When("the user proceeds to checkout")
public void proceedToCheckout() {
context.totalAmount = cartPage.getTotalAmount();
cartPage.clickCheckout();
}
}
// Step class B — receives the same context instance
public class ConfirmationSteps {
private final CheckoutContext context;
public ConfirmationSteps(CheckoutContext context) {
this.context = context;
}
@Then("the order total matches the cart total")
public void verifyOrderTotal() {
BigDecimal displayedTotal = confirmationPage.getOrderTotal();
assertThat(displayedTotal).isEqualTo(context.totalAmount);
}
}PicoContainer creates one instance of each injected class per scenario, so CheckoutContext is automatically reset between scenarios.
Common Integration Anti-Patterns
Cucumber for unit tests — Don't use Cucumber to test individual functions or classes. Cucumber is for business-level behavior testing. If you need unit tests, use JUnit, Jest, or pytest directly. Gherkin overhead on unit tests adds verbosity with no communication benefit.
Skipping the "Why" in step definitions — When a step is non-obvious, add a comment explaining why it's written that way. Future maintainers will thank you:
@Given("the payment service is in degraded mode")
public void paymentServiceDegraded() {
// Sets the payment service to return 503 with a 2s delay
// This simulates the outage scenario from incident INC-4821
// Do not use the full-failure mock — that triggers circuit breakers
mockServer.setPaymentServiceMode(ServiceMode.DEGRADED);
}Cucumber as the only test layer — BDD scenarios are expensive to write and maintain. They should test behaviors that matter to the business, not every code path. Unit tests cover edge cases and error conditions cheaply. Cucumber covers the happy paths and the business-critical errors.
Teams that follow these practices end up with Cucumber suites that get maintained and extended over years. Teams that skip them usually abandon Cucumber within a year, blaming the tool for problems that were actually process and structure failures.