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-PatternProblemSolution
UI details in GherkinFeature files break when UI changesWrite business-level steps, hide UI details in step defs
Coupled scenariosScenario 2 depends on Scenario 1’s stateEach scenario must be fully independent
Fat step definitions50+ lines of Selenium code in one stepDelegate to Page Objects and utilities
God feature file100+ scenarios in one fileSplit by feature area, max 10-15 scenarios per file
Hard-coded waitsThread.sleep() everywhereUse explicit waits with ExpectedConditions
No tag strategyCan’t run subsets, CI runs everythingTag 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>

Summary