Cucumber + Selenium: End-to-End Browser Automation with BDD

Cucumber + Selenium: End-to-End Browser Automation with BDD

Cucumber and Selenium are a classic combination: Cucumber provides the human-readable scenario layer, Selenium drives real browsers underneath. Together they deliver automated acceptance tests that non-technical stakeholders can read and engineers can run in CI.

This guide covers the complete setup — Maven project, Page Object Model, parallel execution, and GitHub Actions — so your Cucumber + Selenium suite is production-ready from day one.

Why Combine Cucumber with Selenium?

Selenium alone gives you browser automation, but test code is pure Java/Python/etc that only developers can read. Cucumber adds a collaboration layer:

  • Business analysts write or review Gherkin scenarios
  • Developers implement step definitions backed by Selenium
  • QA engineers run the suite and add new scenarios without touching Java

The .feature files become the living specification: they describe what the application must do, and failing scenarios pinpoint exactly which behaviour broke.

Project Setup (Maven + Java)

<!-- pom.xml -->
<dependencies>
  <!-- Cucumber -->
  <dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>7.15.0</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <version>7.15.0</version>
    <scope>test</scope>
  </dependency>

  <!-- JUnit 5 -->
  <dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-suite</artifactId>
    <version>1.10.1</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.1</version>
    <scope>test</scope>
  </dependency>

  <!-- Selenium 4 -->
  <dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.18.1</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.github.bonigarcia</groupId>
    <artifactId>webdrivermanager</artifactId>
    <version>5.7.0</version>
    <scope>test</scope>
  </dependency>

  <!-- Assertions -->
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.1</version>
    <scope>test</scope>
  </dependency>
</dependencies>

The Page Object Model

The Page Object Model (POM) is essential for maintainable Cucumber + Selenium tests. Each page in your application gets a corresponding class that encapsulates its selectors and interactions.

Without POM, selectors are scattered across step definitions. When a developer renames a CSS class, you update 20 files. With POM, you update one page class.

Page Object Example

// src/test/java/com/example/pages/LoginPage.java
package com.example.pages;

import org.openqa.selenium.*;
import org.openqa.selenium.support.*;
import org.openqa.selenium.support.ui.*;

import java.time.Duration;

public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    @FindBy(id = "email")
    private WebElement emailField;

    @FindBy(id = "password")
    private WebElement passwordField;

    @FindBy(css = "button[type='submit']")
    private WebElement submitButton;

    @FindBy(css = ".error-alert")
    private WebElement errorAlert;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }

    public void open(String baseUrl) {
        driver.get(baseUrl + "/login");
        wait.until(ExpectedConditions.visibilityOf(emailField));
    }

    public void login(String email, String password) {
        emailField.clear();
        emailField.sendKeys(email);
        passwordField.clear();
        passwordField.sendKeys(password);
        submitButton.click();
    }

    public String getErrorMessage() {
        return wait.until(ExpectedConditions.visibilityOf(errorAlert)).getText();
    }

    public boolean isOnLoginPage() {
        return driver.getCurrentUrl().endsWith("/login");
    }
}
// src/test/java/com/example/pages/DashboardPage.java
package com.example.pages;

import org.openqa.selenium.*;
import org.openqa.selenium.support.*;
import org.openqa.selenium.support.ui.*;
import java.time.Duration;

public class DashboardPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    @FindBy(css = ".welcome-message")
    private WebElement welcomeMessage;

    @FindBy(css = ".user-menu")
    private WebElement userMenu;

    public DashboardPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }

    public boolean isLoaded() {
        try {
            wait.until(ExpectedConditions.urlContains("/dashboard"));
            return true;
        } catch (TimeoutException e) {
            return false;
        }
    }

    public String getWelcomeMessage() {
        return wait.until(ExpectedConditions.visibilityOf(welcomeMessage)).getText();
    }
}

Shared Driver Management

Use a ScenarioContext class with Cucumber's PicoContainer to share the WebDriver across step definition classes within the same scenario:

<!-- Add to pom.xml -->
<dependency>
  <groupId>io.cucumber</groupId>
  <artifactId>cucumber-picocontainer</artifactId>
  <version>7.15.0</version>
  <scope>test</scope>
</dependency>
// src/test/java/com/example/context/ScenarioContext.java
package com.example.context;

import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.*;

public class ScenarioContext {

    private WebDriver driver;
    public final String baseUrl = System.getenv().getOrDefault(
        "APP_BASE_URL", "https://your-app.example.com");

    public WebDriver getDriver() {
        if (driver == null) {
            WebDriverManager.chromedriver().setup();
            ChromeOptions options = new ChromeOptions();
            if (!"false".equals(System.getenv("HEADLESS"))) {
                options.addArguments("--headless=new");
            }
            options.addArguments(
                "--no-sandbox",
                "--disable-dev-shm-usage",
                "--window-size=1920,1080"
            );
            driver = new ChromeDriver(options);
        }
        return driver;
    }

    public void quitDriver() {
        if (driver != null) {
            driver.quit();
            driver = null;
        }
    }
}

Step Definitions with Page Objects

// src/test/java/com/example/steps/LoginSteps.java
package com.example.steps;

import com.example.context.ScenarioContext;
import com.example.pages.*;
import io.cucumber.java.*;
import io.cucumber.java.en.*;

import static org.assertj.core.api.Assertions.assertThat;

public class LoginSteps {

    private final ScenarioContext ctx;
    private LoginPage loginPage;
    private DashboardPage dashboardPage;

    // PicoContainer injects ScenarioContext
    public LoginSteps(ScenarioContext ctx) {
        this.ctx = ctx;
    }

    @Before
    public void setUp() {
        loginPage = new LoginPage(ctx.getDriver());
        dashboardPage = new DashboardPage(ctx.getDriver());
    }

    @After
    public void tearDown(Scenario scenario) {
        if (scenario.isFailed()) {
            // Capture screenshot on failure
            var screenshot = (byte[]) ((org.openqa.selenium.TakesScreenshot) ctx.getDriver())
                .getScreenshotAs(org.openqa.selenium.OutputType.BYTES);
            scenario.attach(screenshot, "image/png", scenario.getName());
        }
        ctx.quitDriver();
    }

    @Given("I am on the login page")
    public void openLoginPage() {
        loginPage.open(ctx.baseUrl);
    }

    @When("I log in as {string} with password {string}")
    public void loginAs(String email, String password) {
        loginPage.login(email, password);
    }

    @Then("I should land on the dashboard")
    public void verifyDashboard() {
        assertThat(dashboardPage.isLoaded()).isTrue();
    }

    @Then("I should see a greeting for {string}")
    public void verifyGreeting(String name) {
        assertThat(dashboardPage.getWelcomeMessage()).containsIgnoringCase(name);
    }

    @Then("I should see the login error {string}")
    public void verifyLoginError(String expectedError) {
        assertThat(loginPage.getErrorMessage()).contains(expectedError);
    }
}

Feature File

Feature: User Authentication

  @smoke
  Scenario: Login with valid credentials
    Given I am on the login page
    When I log in as "alice@example.com" with password "secret123"
    Then I should land on the dashboard
    And I should see a greeting for "Alice"

  @regression
  Scenario: Login with invalid password
    Given I am on the login page
    When I log in as "alice@example.com" with password "wrongpassword"
    Then I should see the login error "Invalid credentials"

  @regression
  Scenario Outline: Login for multiple users
    Given I am on the login page
    When I log in as "<email>" with password "<password>"
    Then I should land on the dashboard

    Examples:
      | email              | password  |
      | alice@example.com  | secret123 |
      | bob@example.com    | pass456   |

Test Runner

// src/test/java/com/example/RunCucumberTest.java
package com.example;

import org.junit.platform.suite.api.*;

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(
    key = "cucumber.plugin",
    value = "pretty, html:target/cucumber-reports/index.html, json:target/cucumber-reports/report.json"
)
@ConfigurationParameter(key = "cucumber.publish.quiet", value = "true")
public class RunCucumberTest {}

Parallel Execution

Parallel execution reduces suite runtime significantly. Configure Surefire in pom.xml:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.2.2</version>
  <configuration>
    <properties>
      <configurationParameters>
        cucumber.execution.parallel.enabled=true
        cucumber.execution.parallel.config.strategy=dynamic
      </configurationParameters>
    </properties>
  </configuration>
</plugin>

Important: With parallel execution each scenario needs its own browser instance. The ScenarioContext above handles this correctly because PicoContainer creates a new instance per scenario — each scenario gets its own ScenarioContext and therefore its own WebDriver.

Do not use a static WebDriver field — that breaks parallel execution.

Handling Dynamic Content

Browser tests often fail due to timing. Always use explicit waits:

// Bad - thread sleep is fragile
Thread.sleep(3000);

// Bad - implicit wait is global and interferes with negative checks
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));

// Good - explicit wait per operation
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement element = wait.until(
    ExpectedConditions.elementToBeClickable(By.cssSelector(".submit-btn"))
);
element.click();

// For custom conditions
wait.until(driver -> {
    String url = driver.getCurrentUrl();
    return url.contains("/dashboard") || url.contains("/error");
});

Custom Selenium Helpers

Wrap common patterns in utility methods to keep step definitions clean:

public class SeleniumHelper {

    private final WebDriver driver;
    private final WebDriverWait wait;

    public SeleniumHelper(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
    }

    public void clickWhenReady(By locator) {
        wait.until(ExpectedConditions.elementToBeClickable(locator)).click();
    }

    public void typeIntoField(By locator, String text) {
        WebElement field = wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
        field.clear();
        field.sendKeys(text);
    }

    public String getVisibleText(By locator) {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(locator)).getText();
    }

    public void waitForUrl(String urlFragment) {
        wait.until(ExpectedConditions.urlContains(urlFragment));
    }

    public void scrollToElement(WebElement element) {
        ((JavascriptExecutor) driver)
            .executeScript("arguments[0].scrollIntoView(true);", element);
    }
}

CI/CD Integration

GitHub Actions

name: Cucumber Selenium Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  cucumber-tests:
    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"

      - name: Cache Maven packages
        uses: actions/cache@v4
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}

      - name: Install Chrome
        uses: browser-actions/setup-chrome@latest

      - name: Run Cucumber tests
        run: mvn test -pl MyApp.AcceptanceTests
        env:
          APP_BASE_URL: https://staging.your-app.com
          HEADLESS: "true"

      - name: Publish HTML report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cucumber-report
          path: target/cucumber-reports/

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: target/surefire-reports/TEST-*.xml

Test Data Management

Keep test data out of feature files when possible. Use Cucumber hooks or @Before steps to seed data via API calls rather than navigating through the UI:

@Before("@needs-product-catalogue")
public void seedProductCatalogue() {
    // Call the app API directly to set up state
    given()
        .baseUri(ctx.baseUrl)
        .auth().oauth2(adminToken)
        .contentType("application/json")
        .body("""
            {"name": "Test Widget", "price": 9.99, "stock": 100}
            """)
        .post("/api/products")
        .then()
        .statusCode(201);
}

This is far faster than navigating through an admin UI to create test data before each scenario.

Common Issues

StaleElementReferenceException: The DOM was updated between finding the element and interacting with it. Re-fetch the element inside a WebDriverWait rather than caching it across multiple steps.

ElementClickInterceptedException: Another element is overlapping your target (e.g. a cookie banner). Dismiss overlays in the @Before hook, or wait for them to disappear before clicking.

Tests pass locally, fail in CI: Usually a timing issue (CI machines are slower) or screen size (CI uses a different default). Set explicit window sizes and increase wait timeouts slightly for CI.

Memory leaks from unclosed browsers: Always quit the driver in @After. If your @After itself throws an exception, the driver may not close. Wrap driver.quit() in a try-finally or register a JVM shutdown hook.

What's Next

Once your Cucumber + Selenium suite is stable:

  • Migrate from Selenium to Selenium + WebDriver BiDi for network interception and better CDP support
  • Consider Playwright-Java as a modern alternative to Selenium — faster, auto-waiting, better parallel support
  • Add visual regression checks with Percy or Applitools in your step definitions
  • Implement custom reporting with Masterthought or Allure for richer analytics beyond the default HTML report

The Cucumber + Selenium stack has the largest community and the longest track record of any BDD + browser testing combination. It's not the fastest or newest option, but it's proven, well-documented, and will integrate with every CI system your team might use.

Read more

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Atlantis automates Terraform plan and apply through pull requests. But Atlantis itself needs testing: workflow configuration, plan output validation, policy enforcement, and server health checks. This guide covers testing Atlantis workflows locally with atlantis-local, validating plan outputs with custom scripts, enforcing Terraform policies with OPA and Conftest, and monitoring Atlantis

By HelpMeTest