Building a Complete BDD Framework
DI, Page Objects, reporting, CI/CD, best practices
Framework Architecture
Here’s the complete project structure we’ll build:
cucumber-bdd-framework/
├── pom.xml
├── config/
│ ├── config.properties
│ └── config-staging.properties
├── src/test/
│ ├── java/com/gyancode/
│ │ ├── runner/
│ │ │ ├── TestRunner.java
│ │ │ └── FailedTestRunner.java
│ │ ├── stepdefinitions/
│ │ │ ├── LoginSteps.java
│ │ │ ├── CartSteps.java
│ │ │ └── CommonSteps.java
│ │ ├── hooks/
│ │ │ └── Hooks.java
│ │ ├── pages/
│ │ │ ├── BasePage.java
│ │ │ ├── LoginPage.java
│ │ │ ├── DashboardPage.java
│ │ │ └── CartPage.java
│ │ ├── context/
│ │ │ └── TestContext.java
│ │ ├── config/
│ │ │ └── ConfigReader.java
│ │ └── utils/
│ │ ├── DriverManager.java
│ │ ├── WaitHelper.java
│ │ └── ScreenshotHelper.java
│ └── resources/
│ ├── features/
│ │ ├── authentication/
│ │ │ └── login.feature
│ │ └── shopping/
│ │ └── cart.feature
│ └── testng.xml
Dependency Injection with PicoContainer
Step definitions live in separate classes, but they need to share state (the WebDriver, test data, page objects). PicoContainer is the simplest DI solution for Cucumber:
Add Dependency
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
TestContext — The Shared State
package com.gyancode.context;
import org.openqa.selenium.WebDriver;
import java.util.HashMap;
import java.util.Map;
public class TestContext {
private WebDriver driver;
private final Map<String, Object> scenarioData = new HashMap<>();
public WebDriver getDriver() {
return driver;
}
public void setDriver(WebDriver driver) {
this.driver = driver;
}
public void setData(String key, Object value) {
scenarioData.put(key, value);
}
@SuppressWarnings("unchecked")
public <T> T getData(String key) {
return (T) scenarioData.get(key);
}
public void clearData() {
scenarioData.clear();
}
}
Injecting TestContext into Steps
// PicoContainer injects TestContext via constructor — no config needed!
public class LoginSteps {
private final TestContext context;
private LoginPage loginPage;
// PicoContainer sees the constructor parameter and injects automatically
public LoginSteps(TestContext context) {
this.context = context;
}
@Given("I am on the login page")
public void iAmOnLoginPage() {
context.getDriver().get(ConfigReader.getBaseUrl() + "/login");
loginPage = new LoginPage(context.getDriver());
}
@When("I login with {string} and {string}")
public void iLoginWith(String username, String password) {
loginPage.login(username, password);
context.setData("currentUser", username);
}
}
public class DashboardSteps {
private final TestContext context;
public DashboardSteps(TestContext context) {
this.context = context;
}
@Then("the welcome message should display my username")
public void verifyWelcome() {
String user = context.getData("currentUser");
DashboardPage dashboard = new DashboardPage(context.getDriver());
Assert.assertTrue(dashboard.getWelcomeMessage().contains(user));
}
}
public class Hooks {
private final TestContext context;
public Hooks(TestContext context) {
this.context = context;
}
@Before("@ui")
public void setupBrowser() {
WebDriver driver = DriverManager.createDriver();
context.setDriver(driver);
}
@After("@ui")
public void closeBrowser(Scenario scenario) {
if (scenario.isFailed() && context.getDriver() != null) {
ScreenshotHelper.capture(context.getDriver(), scenario);
}
DriverManager.quitDriver(context.getDriver());
}
}
Page Object Integration
BasePage
package com.gyancode.pages;
import org.openqa.selenium.*;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.*;
import java.time.Duration;
public abstract class BasePage {
protected WebDriver driver;
protected WebDriverWait wait;
public BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(driver, this);
}
protected void click(WebElement element) {
wait.until(ExpectedConditions.elementToBeClickable(element)).click();
}
protected void type(WebElement element, String text) {
WebElement el = wait.until(ExpectedConditions.visibilityOf(element));
el.clear();
el.sendKeys(text);
}
protected String getText(WebElement element) {
return wait.until(ExpectedConditions.visibilityOf(element)).getText();
}
protected boolean isDisplayed(WebElement element) {
try {
return wait.until(ExpectedConditions.visibilityOf(element))
.isDisplayed();
} catch (TimeoutException e) {
return false;
}
}
}
LoginPage
package com.gyancode.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
public class LoginPage extends BasePage {
@FindBy(id = "username") private WebElement usernameField;
@FindBy(id = "password") private WebElement passwordField;
@FindBy(id = "login-btn") private WebElement loginButton;
@FindBy(css = ".error-message") private WebElement errorMessage;
@FindBy(css = ".welcome-text") private WebElement welcomeText;
public LoginPage(WebDriver driver) {
super(driver);
}
public void login(String username, String password) {
type(usernameField, username);
type(passwordField, password);
click(loginButton);
}
public String getErrorMessage() {
return getText(errorMessage);
}
public boolean isErrorDisplayed() {
return isDisplayed(errorMessage);
}
}
Step Definition — Clean and Thin
public class LoginSteps {
private final TestContext context;
private LoginPage loginPage;
public LoginSteps(TestContext context) {
this.context = context;
}
@Given("I am on the login page")
public void navigateToLogin() {
context.getDriver().get(ConfigReader.getBaseUrl() + "/login");
loginPage = new LoginPage(context.getDriver());
}
@When("I login with {string} and {string}")
public void performLogin(String username, String password) {
loginPage.login(username, password);
}
@Then("I should see an error {string}")
public void verifyError(String expected) {
Assert.assertEquals(expected, loginPage.getErrorMessage());
}
}
Configuration Management
config.properties
# config/config.properties
base.url=https://staging.example.com
browser=chrome
headless=false
implicit.wait=10
explicit.wait=15
screenshot.on.failure=true
parallel.count=4
ConfigReader
package com.gyancode.config;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;
public class ConfigReader {
private static final Properties properties = new Properties();
public static void loadProperties() {
String env = System.getProperty("env", "config");
String filePath = "config/" + env + ".properties";
try (FileInputStream fis = new FileInputStream(filePath)) {
properties.load(fis);
} catch (IOException e) {
throw new RuntimeException("Cannot load config: " + filePath, e);
}
}
public static String getBaseUrl() {
return getProperty("base.url");
}
public static String getBrowser() {
return getProperty("browser", "chrome");
}
public static boolean isHeadless() {
return Boolean.parseBoolean(getProperty("headless", "false"));
}
public static int getImplicitWait() {
return Integer.parseInt(getProperty("implicit.wait", "10"));
}
public static int getExplicitWait() {
return Integer.parseInt(getProperty("explicit.wait", "15"));
}
private static String getProperty(String key) {
String sysValue = System.getProperty(key);
return sysValue != null ? sysValue : properties.getProperty(key);
}
private static String getProperty(String key, String defaultValue) {
String value = getProperty(key);
return value != null ? value : defaultValue;
}
}
# Run with different environments
mvn test # Uses config.properties
mvn test -Denv=config-staging # Uses config-staging.properties
mvn test -Dbrowser=firefox -Dheadless=true # Override specific values
Driver Management
package com.gyancode.utils;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.edge.EdgeDriver;
import java.time.Duration;
public class DriverManager {
public static WebDriver createDriver() {
String browser = ConfigReader.getBrowser().toLowerCase();
boolean headless = ConfigReader.isHeadless();
WebDriver driver;
switch (browser) {
case "firefox" -> {
FirefoxOptions options = new FirefoxOptions();
if (headless) options.addArguments("--headless");
driver = new FirefoxDriver(options);
}
case "edge" -> driver = new EdgeDriver();
default -> {
ChromeOptions options = new ChromeOptions();
if (headless) options.addArguments("--headless=new");
options.addArguments("--disable-gpu");
options.addArguments("--no-sandbox");
options.addArguments("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
}
driver.manage().window().maximize();
driver.manage().timeouts().implicitlyWait(
Duration.ofSeconds(ConfigReader.getImplicitWait())
);
return driver;
}
public static void quitDriver(WebDriver driver) {
if (driver != null) {
driver.quit();
}
}
}
Parallel Execution
TestNG Parallel Runner
@CucumberOptions(
features = "src/test/resources/features",
glue = {"com.gyancode.stepdefinitions", "com.gyancode.hooks"},
plugin = {
"pretty",
"html:target/cucumber-reports/cucumber.html",
"json:target/cucumber-reports/cucumber.json",
"rerun:target/failed-scenarios.txt"
},
monochrome = true
)
public class TestRunner extends AbstractTestNGCucumberTests {
@Override
@DataProvider(parallel = true)
public Object[][] scenarios() {
return super.scenarios();
}
}
testng.xml for Thread Control
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Cucumber BDD Suite" parallel="methods"
thread-count="4" data-provider-thread-count="4">
<test name="Cucumber Tests">
<classes>
<class name="com.gyancode.runner.TestRunner"/>
</classes>
</test>
</suite>
Rerun Failed Scenarios
@CucumberOptions(
features = "@target/failed-scenarios.txt",
glue = {"com.gyancode.stepdefinitions", "com.gyancode.hooks"},
plugin = {"pretty", "html:target/rerun-reports/rerun.html"}
)
public class FailedTestRunner extends AbstractTestNGCucumberTests {
}
# Run full suite, then retry failures
mvn test -Dtest=TestRunner
mvn test -Dtest=FailedTestRunner
Reporting
Built-in Cucumber Reports
plugin = {
"pretty", // Console output
"html:target/cucumber-reports.html", // HTML report
"json:target/cucumber.json", // JSON (for tools)
"junit:target/cucumber.xml", // JUnit XML (for CI)
"timeline:target/timeline", // Timeline view
"rerun:target/failed-scenarios.txt" // Failed scenario list
}
Allure Reports Integration
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-cucumber7-jvm</artifactId>
<version>2.25.0</version>
</dependency>
@CucumberOptions(
plugin = {
"io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"pretty"
}
)
# Generate and open Allure report
mvn test
allure serve target/allure-results
Extent Reports Integration
<dependency>
<groupId>tech.grasshopper</groupId>
<artifactId>extentreports-cucumber7-adapter</artifactId>
<version>1.14.0</version>
</dependency>
@CucumberOptions(
plugin = {
"com.aventstack.extentreports.cucumber.adapter.ExtentCucumberAdapter:",
"pretty"
}
)
Create src/test/resources/extent.properties:
extent.reporter.spark.start=true
extent.reporter.spark.out=target/extent-reports/SparkReport.html
screenshot.dir=target/extent-reports/screenshots/
screenshot.rel.path=screenshots/
CI/CD Integration
GitHub Actions
name: Cucumber BDD Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
- name: Run smoke tests
run: mvn test -Dcucumber.filter.tags="@smoke" -Dheadless=true
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: cucumber-reports
path: target/cucumber-reports/
Jenkins Pipeline
pipeline {
agent any
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Smoke Tests') {
steps {
sh 'mvn test -Dcucumber.filter.tags="@smoke" -Dheadless=true'
}
}
stage('Regression Tests') {
steps {
sh 'mvn test -Dcucumber.filter.tags="@regression" -Dheadless=true'
}
}
stage('Rerun Failures') {
when { expression { fileExists('target/failed-scenarios.txt') } }
steps {
sh 'mvn test -Dtest=FailedTestRunner -Dheadless=true'
}
}
}
post {
always {
cucumber buildStatus: 'UNSTABLE',
fileIncludePattern: '**/cucumber.json'
archiveArtifacts artifacts: 'target/cucumber-reports/**'
}
}
}
Framework Best Practices
Declarative vs Imperative Scenarios
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| UI details in Gherkin | Feature files break when UI changes | Write business-level steps, hide UI details in step defs |
| Coupled scenarios | Scenario 2 depends on Scenario 1’s state | Each scenario must be fully independent |
| Fat step definitions | 50+ lines of Selenium code in one step | Delegate to Page Objects and utilities |
| God feature file | 100+ scenarios in one file | Split by feature area, max 10-15 scenarios per file |
| Hard-coded waits | Thread.sleep() everywhere | Use explicit waits with ExpectedConditions |
| No tag strategy | Can’t run subsets, CI runs everything | Tag by type (@smoke, @regression), priority, and feature |
The Golden Rules
Complete pom.xml Reference
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gyancode</groupId>
<artifactId>cucumber-bdd-framework</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<cucumber.version>7.15.0</cucumber.version>
<selenium.version>4.16.0</selenium.version>
<testng.version>7.9.0</testng.version>
</properties>
<dependencies>
<!-- Cucumber -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>${cucumber.version}</version>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-testng</artifactId>
<version>${cucumber.version}</version>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<!-- Selenium -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<!-- TestNG -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<scope>test</scope>
</dependency>
<!-- Allure Reporting -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-cucumber7-jvm</artifactId>
<version>2.25.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
<configuration>
<includes>
<include>**/TestRunner.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>