What is BDD?
Behavior-Driven Development (BDD) is a software development methodology that extends Test-Driven Development by writing test specifications in plain, human-readable language that both technical and non-technical stakeholders can understand. The goal is to bridge the communication gap between product owners, developers, and QA engineers — creating a shared language around expected system behaviour before a single line of implementation code is written.
BDD uses Gherkin — a structured natural language — to describe application behaviour in terms of scenarios. Each scenario describes a specific business rule or user journey using three core keywords:
- Given — The precondition or initial context (state of the world before the interaction)
- When — The action or event performed by the user or system
- Then — The expected outcome or assertion
These Gherkin scenarios serve as living documentation — they are both the specification and the automated test. When the tests pass, the documentation is accurate. When requirements change, the feature file changes, and the documentation is automatically updated.
Architecture Diagram
Maven Dependencies
Add these dependencies to your pom.xml:
<dependencies>
<!-- Cucumber Java bindings -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<!-- Cucumber JUnit 5 integration -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 platform suite -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<version>1.10.1</version>
<scope>test</scope>
</dependency>
<!-- Selenium WebDriver -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.18.1</version>
</dependency>
<!-- WebDriverManager — automatic driver management -->
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
<!-- Allure Cucumber reporting -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-cucumber7-jvm</artifactId>
<version>2.25.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Feature File Syntax
Feature files are plain text with a .feature extension and live in src/test/resources/features/. They describe the expected behaviour in Gherkin:
# src/test/resources/features/login.feature
Feature: User Authentication
As a registered user
I want to be able to log in to my account
So that I can access personalised content
Background:
Given the user is on the login page
@smoke @auth
Scenario: Successful login with valid credentials
When the user enters email "user@example.com" and password "SecurePass123"
And the user clicks the login button
Then the user should be redirected to the dashboard
And the welcome message should contain "Welcome"
@auth @regression
Scenario: Login fails with invalid password
When the user enters email "user@example.com" and password "WrongPassword"
And the user clicks the login button
Then an error message "Invalid email or password" should be displayed
@auth @regression
Scenario: Login fails with empty fields
When the user clicks the login button
Then an error message "Email is required" should be displayed
Gherkin keywords
- Feature — Describes the high-level feature being tested (appears once per file)
- Background — Steps that run before every scenario in the file (like a shared precondition)
- Scenario — A single test case with a specific set of steps
- Given / When / Then / And / But — Step keywords that provide context, action, and assertion
- @tagName — Tags for grouping and filtering scenarios
Step Definitions
Step definitions are Java methods annotated with @Given, @When, or @Then. The annotation value is a regex or Cucumber Expression that matches the Gherkin step text. Parameters are extracted from the step and passed to the method:
// src/test/java/steps/LoginSteps.java
package steps;
import io.cucumber.java.en.*;
import org.openqa.selenium.WebDriver;
import pages.LoginPage;
import static org.assertj.core.api.Assertions.assertThat;
public class LoginSteps {
private final WebDriver driver = DriverManager.getDriver();
private final LoginPage loginPage = new LoginPage(driver);
@Given("the user is on the login page")
public void theUserIsOnTheLoginPage() {
loginPage.open();
}
// {string} captures a quoted string parameter
@When("the user enters email {string} and password {string}")
public void theUserEntersCredentials(String email, String password) {
loginPage.enterEmail(email);
loginPage.enterPassword(password);
}
@When("the user clicks the login button")
public void theUserClicksLoginButton() {
loginPage.clickLogin();
}
@Then("the user should be redirected to the dashboard")
public void theUserShouldBeRedirectedToDashboard() {
assertThat(driver.getCurrentUrl()).contains("/dashboard");
}
// {string} captures the expected text
@Then("the welcome message should contain {string}")
public void theWelcomeMessageShouldContain(String expectedText) {
assertThat(loginPage.getWelcomeText()).contains(expectedText);
}
@Then("an error message {string} should be displayed")
public void anErrorMessageShouldBeDisplayed(String expectedError) {
assertThat(loginPage.getErrorText()).isEqualTo(expectedError);
}
}
JUnit 5 Runner
With Cucumber 7 and JUnit 5, the runner is a suite configuration class rather than a test class:
// src/test/java/runner/CucumberRunner.java
package runner;
import org.junit.platform.suite.api.*;
@Suite
@IncludeEngines("cucumber")
@ConfigurationParameter(
key = "cucumber.features",
value = "src/test/resources/features"
)
@ConfigurationParameter(
key = "cucumber.glue",
value = "steps,hooks"
)
@ConfigurationParameter(
key = "cucumber.plugin",
value = "pretty, html:target/cucumber-reports/report.html, " +
"json:target/cucumber-reports/report.json, " +
"io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm"
)
@ConfigurationParameter(
key = "cucumber.filter.tags",
value = "@smoke or @regression"
)
public class CucumberRunner {
// This class body intentionally left empty
}
Run the suite with Maven: mvn test -Dtest=CucumberRunner
Scenario Outline for Data-Driven Tests
Scenario Outline combined with an Examples table allows you to run the same scenario multiple times with different input data — replacing hardcoded values with <placeholder> variables:
# features/login.feature
@data-driven
Scenario Outline: Login attempts with various credentials
Given the user is on the login page
When the user enters email "<email>" and password "<password>"
And the user clicks the login button
Then "<outcome>" should be observed
Examples:
| email | password | outcome |
| user@example.com | ValidPass123 | redirected to dashboard |
| user@example.com | WrongPass | Invalid email or password |
| notregistered@ex.com | AnyPass123 | Invalid email or password |
| admin@example.com | AdminPass456 | redirected to admin panel |
Cucumber runs this as 4 separate test cases — one per row in the Examples table. Each row is reported individually in the test report, making it easy to see which specific data combination failed.
Data Tables
Data Tables allow you to pass structured multi-row data to a single step — useful for form submissions with many fields or testing batch operations:
# Feature file step with a data table
When the user fills in the registration form with:
| field | value |
| First Name | Alice |
| Last Name | Smith |
| Email | alice@example.com |
| Phone | +353 87 123 4567 |
| Role | Administrator |
// Step definition — receives data as List<Map<String, String>>
import io.cucumber.datatable.DataTable;
import java.util.List;
import java.util.Map;
@When("the user fills in the registration form with:")
public void theUserFillsInRegistrationForm(DataTable dataTable) {
List<Map<String, String>> rows = dataTable.asMaps(String.class, String.class);
for (Map<String, String> row : rows) {
registrationPage.fillField(row.get("field"), row.get("value"));
}
registrationPage.submit();
}
Hooks
Hooks run setup and teardown code around scenarios. They live in a dedicated class in the glue package:
// src/test/java/hooks/TestHooks.java
package hooks;
import io.cucumber.java.*;
import io.cucumber.java.Scenario;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
public class TestHooks {
private final WebDriver driver = DriverManager.getDriver();
@Before
public void setUp(Scenario scenario) {
System.out.println("Starting scenario: " + scenario.getName());
// Additional setup: clear cookies, set implicit wait, etc.
driver.manage().deleteAllCookies();
}
@After
public void tearDown(Scenario scenario) {
// Capture screenshot on failure and embed in report
if (scenario.isFailed()) {
byte[] screenshot = ((TakesScreenshot) driver)
.getScreenshotAs(OutputType.BYTES);
scenario.attach(screenshot, "image/png", "Failure Screenshot");
}
DriverManager.quitDriver();
}
@Before(value = "@admin", order = 10)
public void loginAsAdmin() {
// Special setup for admin scenarios
AuthHelper.loginViaApi("admin@example.com", "AdminPass123");
}
@AfterStep
public void afterEachStep(Scenario scenario) {
// Optionally capture a screenshot after every step for debugging
// Only enable this in debug mode to avoid massive report sizes
}
}
@Before hook that logged in via API (bypassing the UI) was the single most important performance optimisation. The login flow involved OAuth, MFA simulation, and a 5-second redirect chain. By moving this to an API call in the hook, we cut scenario setup time from 8 seconds to under 1 second across 200+ scenarios — saving over 20 minutes per full regression run.
Tags and Filtering
Tags let you categorize scenarios and run subsets from the CLI or CI configuration:
# Feature file tags
@smoke
Scenario: Quick login verification
@regression @auth
Scenario: Password reset flow
@wip
Scenario: New feature under development (excluded from CI)
@slow @performance
Scenario: Concurrent login load test
# Run only @smoke tests
mvn test -Dcucumber.filter.tags="@smoke"
# Run @regression but not @wip
mvn test -Dcucumber.filter.tags="@regression and not @wip"
# Run either @smoke or @critical
mvn test -Dcucumber.filter.tags="@smoke or @critical"
Reporting
Cucumber generates an HTML report natively. For richer reports, integrate Allure:
# Generate Allure report after test run
mvn allure:serve # Generates and opens in browser
# Or generate static report
mvn allure:report # Creates target/site/allure-maven-plugin/
The Allure report shows scenario results grouped by feature, with embedded screenshots, step timings, and tag filtering. For CI pipelines, use the JSON output and the Allure GitHub Action to publish the report as a CI artifact.
Full Example: Feature + Step Def + Page Object
# features/checkout.feature
Feature: Product Checkout
@smoke
Scenario: Complete checkout with a single product
Given the user is logged in as "alice@example.com"
And the user has added "Wireless Headphones" to the cart
When the user proceeds to checkout
And the user enters payment details for card "4111111111111111"
Then the order confirmation page should be displayed
And a confirmation email should be queued for "alice@example.com"
// pages/CheckoutPage.java
package pages;
import org.openqa.selenium.*;
import org.openqa.selenium.support.*;
import org.openqa.selenium.support.ui.*;
public class CheckoutPage {
private WebDriver driver;
private WebDriverWait wait;
@FindBy(id = "card-number") private WebElement cardNumberInput;
@FindBy(css = "[data-testid='place-order-btn']") private WebElement placeOrderBtn;
@FindBy(css = "[data-testid='order-confirmation']") private WebElement confirmationBanner;
public CheckoutPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(driver, this);
}
public void open() {
driver.get(ConfigReader.getBaseUrl() + "/checkout");
}
public void enterCardNumber(String cardNumber) {
wait.until(ExpectedConditions.visibilityOf(cardNumberInput));
cardNumberInput.clear();
cardNumberInput.sendKeys(cardNumber);
}
public void clickPlaceOrder() {
placeOrderBtn.click();
}
public boolean isConfirmationDisplayed() {
return wait.until(ExpectedConditions.visibilityOf(confirmationBanner)).isDisplayed();
}
}
GitHub Actions CI
# .github/workflows/cucumber.yml
name: Cucumber BDD Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
cucumber:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Run Cucumber tests
run: mvn test -Dcucumber.filter.tags="@smoke or @regression" -Dwebdriver.chrome.headless=true
- name: Generate Allure report
if: always()
run: mvn allure:report
- name: Upload Allure report
uses: actions/upload-artifact@v4
if: always()
with:
name: allure-report
path: target/site/allure-maven-plugin/
- name: Upload Cucumber JSON report
uses: actions/upload-artifact@v4
if: always()
with:
name: cucumber-report
path: target/cucumber-reports/
BDD Framework Comparison
| Feature | Cucumber (Java) | SpecFlow (.NET) | Behave (Python) | Robot Framework |
|---|---|---|---|---|
| Language | Java | C# / .NET | Python | Python (keyword-driven) |
| Gherkin support | Full (Feature / Scenario / Outline) | Full (same spec) | Full (same spec) | BDD plugin only |
| Reporting | HTML, JSON, Allure, JUnit | HTML, JSON, Allure | JSON, Allure, HTML formatter | Built-in HTML, LOG files |
| Learning curve | Medium (Java + Maven) | Medium (.NET ecosystem) | Low (Python) | Low (keyword-driven) |
| IDE support | Excellent (IntelliJ plugin) | Excellent (Visual Studio) | Good (VS Code) | Good (RIDE, VS Code) |
| Community | Very large | Medium | Medium | Large |
| Best for | Java enterprise teams | .NET / Microsoft shops | Python QA teams | Acceptance testing, non-dev testers |
Best Practices
1. Keep steps business-level, not technical
Gherkin should read like a business requirement, not a test script. Avoid implementation details in your steps:
# Bad — implementation leaking into Gherkin
When I click the element with id "login-btn"
# Good — business intent is clear
When the user submits the login form
2. Write steps to be reusable across scenarios
Design step definitions that work with parameters rather than hard-coding values. A step like the user enters email {string} is reusable across every scenario that involves email input. A step like the user enters email "admin@example.com" is not reusable at all.
3. Keep Given steps free of UI interactions
The Given phase should establish preconditions, not test the process of getting there. Use API calls, direct database inserts, or programmatic setup in Given steps. Reserve UI interaction for When steps.
4. One scenario per business rule
Each scenario should test exactly one outcome. Scenarios that test multiple things are harder to diagnose when they fail — you don't know which assertion caused the failure without reading through the entire scenario.
5. Tag consistently and enforce tag conventions
Define a tag convention that the whole team follows: @smoke for critical path, @regression for full regression, @wip for work in progress (excluded from CI), and @slow for tests that take over 30 seconds. Consistency makes CI tag filtering reliable.
Back to Blog