UI Testing with Cucumber BDD: Making Tests Readable for Everyone

Written on September 15, 2024 by ibsanju.

Last updated June 26, 2025.

See changes
11 min read
––– views

Ever had that awkward conversation where your product manager asks "What exactly are we testing?" and you point to a bunch of cryptic assert() statements? Yeah, I've been there too. That's where Cucumber BDD comes to the rescue!

Today I'm sharing how to set up UI testing with Cucumber that's so readable, even your non-technical stakeholders can understand what's being tested. We'll cover everything from writing Gherkin scenarios to implementing robust step definitions with Selenium WebDriver.

TL;DR: What We'll Cover

  • Setting up Cucumber with Selenium WebDriver for UI testing
  • Writing effective Gherkin scenarios that everyone can understand
  • Implementing maintainable step definitions
  • Best practices for keeping your tests DRY and organized
  • Advanced features like hooks and Page Object Model integration
  • Running tests and generating beautiful reports

Let's dive in! 🚀

Why Cucumber BDD for UI Testing?

Before we jump into code, let me share why I love this approach:

The Problem: Traditional UI tests often look like this:

driver.findElement(By.id("username")).sendKeys("user123");
driver.findElement(By.id("password")).sendKeys("pass456");
driver.findElement(By.id("loginBtn")).click();
// What are we actually testing here? 🤔

The Solution: Cucumber scenarios read like this:

Given the user is on the login page
When the user enters valid credentials
Then the user should be redirected to the dashboard

See the difference? The second version tells a story that anyone can understand!

Setting Up Your Testing Environment

Step 1: Project Structure

First, let's create a clean Maven project structure:

ui-testing-cucumber/
├── src/
│   └── test/
│       ├── java/
│       │   ├── runners/
│       │   ├── step_definitions/
│       │   └── pages/
│       └── resources/
│           └── features/
└── pom.xml

Step 2: Maven Dependencies

Here's your pom.xml setup with all the goodies:

<dependencies>
    <!-- Cucumber Core Dependencies -->
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-java</artifactId>
        <version>7.14.0</version>
    </dependency>
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-junit</artifactId>
        <version>7.14.0</version>
    </dependency>
 
    <!-- Selenium WebDriver -->
    <dependency>
        <groupId>org.seleniumhq.selenium</groupId>
        <artifactId>selenium-java</artifactId>
        <version>4.14.0</version>
    </dependency>
 
    <!-- WebDriverManager (trust me, this saves headaches) -->
    <dependency>
        <groupId>io.github.bonigarcia</groupId>
        <artifactId>webdrivermanager</artifactId>
        <version>5.5.3</version>
    </dependency>
</dependencies>

Note: WebDriverManager automatically handles browser driver downloads - no more "driver not found" errors! 🎉

Writing Effective Gherkin Scenarios

Let's create our first feature file: src/test/resources/features/login.feature

Feature: User Login Functionality
  As a registered user
  I want to log into the application
  So that I can access my dashboard
 
  Background:
    Given the user is on the login page
 
  Scenario: Successful login with valid credentials
    When the user enters username "john.doe@example.com"
    And the user enters password "SecurePass123!"
    And the user clicks the login button
    Then the user should be redirected to the dashboard
    And the welcome message should display "Welcome back, John!"
 
  Scenario: Login fails with invalid password
    When the user enters username "john.doe@example.com"
    And the user enters password "WrongPassword"
    And the user clicks the login button
    Then an error message "Invalid credentials" should be displayed
    And the user should remain on the login page
 
  Scenario Outline: Login validation with different invalid inputs
    When the user enters username "<username>"
    And the user enters password "<password>"
    And the user clicks the login button
    Then an error message "<error_message>" should be displayed
 
    Examples:
      | username              | password     | error_message                |
      |                       | ValidPass123 | Username is required         |
      | john.doe@example.com  |              | Password is required         |
      | invalid-email         | ValidPass123 | Please enter a valid email   |

What makes this good Gherkin?

  • Uses the "As a... I want... So that..." format for context
  • Each scenario focuses on one specific behavior
  • Steps are written from the user's perspective
  • Uses concrete examples rather than vague descriptions

Implementing Step Definitions

Now let's implement the step definitions. I'll show you both a basic approach and a more advanced one using Page Objects.

Basic Step Definitions

Create src/test/java/step_definitions/LoginSteps.java:

package step_definitions;
 
import io.cucumber.java.en.*;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
 
import java.time.Duration;
 
import static org.junit.Assert.*;
 
public class LoginSteps {
    private WebDriver driver;
    private WebDriverWait wait;
 
    // Constructor to initialize driver
    public LoginSteps() {
        WebDriverManager.chromedriver().setup();
        driver = new ChromeDriver();
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        driver.manage().window().maximize();
    }
 
    @Given("the user is on the login page")
    public void userIsOnLoginPage() {
        driver.get("https://example.com/login");
 
        // Wait for page to load completely
        wait.until(ExpectedConditions.presenceOfElementLocated(By.id("loginForm")));
    }
 
    @When("the user enters username {string}")
    public void userEntersUsername(String username) {
        WebElement usernameField = driver.findElement(By.id("username"));
        usernameField.clear();
        usernameField.sendKeys(username);
    }
 
    @When("the user enters password {string}")
    public void userEntersPassword(String password) {
        WebElement passwordField = driver.findElement(By.id("password"));
        passwordField.clear();
        passwordField.sendKeys(password);
    }
 
    @When("the user clicks the login button")
    public void userClicksLoginButton() {
        WebElement loginButton = driver.findElement(By.id("loginButton"));
        loginButton.click();
    }
 
    @Then("the user should be redirected to the dashboard")
    public void userShouldBeRedirectedToDashboard() {
        wait.until(ExpectedConditions.urlContains("/dashboard"));
        assertTrue("User should be on dashboard page",
                  driver.getCurrentUrl().contains("/dashboard"));
    }
 
    @Then("the welcome message should display {string}")
    public void welcomeMessageShouldDisplay(String expectedMessage) {
        WebElement welcomeMessage = wait.until(
            ExpectedConditions.presenceOfElementLocated(By.className("welcome-message"))
        );
        assertEquals("Welcome message mismatch",
                    expectedMessage, welcomeMessage.getText());
    }
 
    @Then("an error message {string} should be displayed")
    public void errorMessageShouldBeDisplayed(String expectedError) {
        WebElement errorElement = wait.until(
            ExpectedConditions.presenceOfElementLocated(By.className("error-message"))
        );
        assertEquals("Error message mismatch",
                    expectedError, errorElement.getText());
    }
 
    @Then("the user should remain on the login page")
    public void userShouldRemainOnLoginPage() {
        assertTrue("User should still be on login page",
                  driver.getCurrentUrl().contains("/login"));
    }
}

Advanced: Using Page Object Model

Honestly speaking, for any serious UI testing, you'll want to use the Page Object Model. It makes your tests much more maintainable. Here's how:

Create src/test/java/pages/LoginPage.java:

package pages;
 
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
 
import java.time.Duration;
 
public class LoginPage {
    private WebDriver driver;
    private WebDriverWait wait;
 
    // Page elements using @FindBy annotations
    @FindBy(id = "username")
    private WebElement usernameField;
 
    @FindBy(id = "password")
    private WebElement passwordField;
 
    @FindBy(id = "loginButton")
    private WebElement loginButton;
 
    @FindBy(className = "error-message")
    private WebElement errorMessage;
 
    @FindBy(className = "welcome-message")
    private WebElement welcomeMessage;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }
 
    public void navigateToLoginPage() {
        driver.get("https://example.com/login");
        wait.until(ExpectedConditions.presenceOfElementLocated(By.id("loginForm")));
    }
 
    public void enterUsername(String username) {
        usernameField.clear();
        usernameField.sendKeys(username);
    }
 
    public void enterPassword(String password) {
        passwordField.clear();
        passwordField.sendKeys(password);
    }
 
    public void clickLoginButton() {
        loginButton.click();
    }
 
    public String getErrorMessage() {
        wait.until(ExpectedConditions.visibilityOf(errorMessage));
        return errorMessage.getText();
    }
 
    public String getWelcomeMessage() {
        wait.until(ExpectedConditions.visibilityOf(welcomeMessage));
        return welcomeMessage.getText();
    }
 
    public boolean isOnLoginPage() {
        return driver.getCurrentUrl().contains("/login");
    }
 
    public boolean isOnDashboard() {
        wait.until(ExpectedConditions.urlContains("/dashboard"));
        return driver.getCurrentUrl().contains("/dashboard");
    }
}

Now update your step definitions to use the Page Object:

package step_definitions;
 
import io.cucumber.java.en.*;
import pages.LoginPage;
 
import static org.junit.Assert.*;
 
public class LoginStepsWithPageObject {
    private LoginPage loginPage;
 
    public LoginStepsWithPageObject() {
        // Driver will be injected via hooks (see next section)
        loginPage = new LoginPage(DriverManager.getDriver());
    }
 
    @Given("the user is on the login page")
    public void userIsOnLoginPage() {
        loginPage.navigateToLoginPage();
    }
 
    @When("the user enters username {string}")
    public void userEntersUsername(String username) {
        loginPage.enterUsername(username);
    }
 
    @When("the user enters password {string}")
    public void userEntersPassword(String password) {
        loginPage.enterPassword(password);
    }
 
    @When("the user clicks the login button")
    public void userClicksLoginButton() {
        loginPage.clickLoginButton();
    }
 
    @Then("the user should be redirected to the dashboard")
    public void userShouldBeRedirectedToDashboard() {
        assertTrue("User should be on dashboard", loginPage.isOnDashboard());
    }
 
    @Then("the welcome message should display {string}")
    public void welcomeMessageShouldDisplay(String expectedMessage) {
        assertEquals("Welcome message mismatch",
                    expectedMessage, loginPage.getWelcomeMessage());
    }
 
    @Then("an error message {string} should be displayed")
    public void errorMessageShouldBeDisplayed(String expectedError) {
        assertEquals("Error message mismatch",
                    expectedError, loginPage.getErrorMessage());
    }
 
    @Then("the user should remain on the login page")
    public void userShouldRemainOnLoginPage() {
        assertTrue("User should still be on login page", loginPage.isOnLoginPage());
    }
}

Managing WebDriver with Hooks

Create a driver manager to handle WebDriver lifecycle properly:

package utils;
 
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
 
public class DriverManager {
    private static WebDriver driver;
 
    public static WebDriver getDriver() {
        if (driver == null) {
            initializeDriver();
        }
        return driver;
    }
 
    private static void initializeDriver() {
        WebDriverManager.chromedriver().setup();
 
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--disable-notifications");
        options.addArguments("--disable-popup-blocking");
        // Uncomment for headless mode
        // options.addArguments("--headless");
 
        driver = new ChromeDriver(options);
        driver.manage().window().maximize();
    }
 
    public static void quitDriver() {
        if (driver != null) {
            driver.quit();
            driver = null;
        }
    }
}

Create hooks for setup and teardown in src/test/java/step_definitions/Hooks.java:

package step_definitions;
 
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.Scenario;
import utils.DriverManager;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
 
public class Hooks {
 
    @Before
    public void setUp(Scenario scenario) {
        System.out.println("Starting scenario: " + scenario.getName());
        // Driver is initialized lazily in DriverManager
    }
 
    @After
    public void tearDown(Scenario scenario) {
        // Take screenshot if scenario fails
        if (scenario.isFailed()) {
            byte[] screenshot = ((TakesScreenshot) DriverManager.getDriver())
                    .getScreenshotAs(OutputType.BYTES);
            scenario.attach(screenshot, "image/png", "Screenshot");
        }
 
        System.out.println("Finished scenario: " + scenario.getName());
        DriverManager.quitDriver();
    }
}

Note: The hooks automatically take screenshots when tests fail - super helpful for debugging! 📸

Running Your Cucumber Tests

Create a test runner in src/test/java/runners/TestRunner.java:

package runners;
 
import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;
import org.junit.runner.RunWith;
 
@RunWith(Cucumber.class)
@CucumberOptions(
        features = "src/test/resources/features",
        glue = {"step_definitions"},
        plugin = {
                "pretty",
                "html:target/cucumber-reports",
                "json:target/cucumber-reports/Cucumber.json",
                "junit:target/cucumber-reports/Cucumber.xml"
        },
        tags = "not @ignore" // Skip scenarios tagged with @ignore
)
public class TestRunner {
}

To run specific scenarios, you can use tags:

@smoke
Scenario: Successful login with valid credentials
    # ... scenario steps
 
@regression @negative
Scenario: Login fails with invalid password
    # ... scenario steps

Then run with: @CucumberOptions(tags = "@smoke")

Advanced Features and Best Practices

1. Data Tables for Complex Input

Sometimes you need to pass structured data:

Scenario: User registration with complete profile
    Given the user is on the registration page
    When the user fills the registration form with:
      | Field            | Value                    |
      | First Name       | John                     |
      | Last Name        | Doe                      |
      | Email            | john.doe@example.com     |
      | Phone            | +1-555-123-4567         |
      | Country          | United States            |
    And the user clicks register
    Then the registration should be successful

Step definition:

@When("the user fills the registration form with:")
public void userFillsRegistrationForm(DataTable dataTable) {
    Map<String, String> userData = dataTable.asMap(String.class, String.class);
 
    registrationPage.enterFirstName(userData.get("First Name"));
    registrationPage.enterLastName(userData.get("Last Name"));
    registrationPage.enterEmail(userData.get("Email"));
    registrationPage.enterPhone(userData.get("Phone"));
    registrationPage.selectCountry(userData.get("Country"));
}

2. Configuration Management

Create a properties file for environment-specific settings:

# src/test/resources/application.properties
base.url=https://example.com
browser=chrome
implicit.wait=10
explicit.wait=30
headless.mode=false

Load it in your DriverManager:

public class ConfigManager {
    private static Properties properties;
 
    static {
        try {
            properties = new Properties();
            properties.load(ConfigManager.class.getResourceAsStream("/application.properties"));
        } catch (IOException e) {
            throw new RuntimeException("Failed to load configuration", e);
        }
    }
 
    public static String getProperty(String key) {
        return properties.getProperty(key);
    }
 
    public static String getBaseUrl() {
        return getProperty("base.url");
    }
 
    public static boolean isHeadlessMode() {
        return Boolean.parseBoolean(getProperty("headless.mode"));
    }
}

3. Custom Assertion Messages

Make your test failures more descriptive:

// Instead of this:
assertTrue(loginPage.isOnDashboard());
 
// Do this:
assertTrue("Expected user to be redirected to dashboard after successful login, " +
          "but current URL is: " + driver.getCurrentUrl(),
          loginPage.isOnDashboard());

4. Parallel Execution

For faster test runs, configure parallel execution in your pom.xml:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0-M7</version>
    <configuration>
        <parallel>methods</parallel>
        <threadCount>3</threadCount>
        <perCoreThreadCount>true</perCoreThreadCount>
    </configuration>
</plugin>

Heads-up: When running tests in parallel, make sure each test has its own WebDriver instance to avoid conflicts!

Generating Beautiful Reports

The basic HTML reports are nice, but you can get even better ones with plugins like Cluecumber:

<plugin>
    <groupId>com.trivago.rta</groupId>
    <artifactId>cluecumber-report-plugin</artifactId>
    <version>2.9.4</version>
    <executions>
        <execution>
            <id>report</id>
            <phase>post-integration-test</phase>
            <goals>
                <goal>reporting</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <sourceJsonReportDirectory>target/cucumber-reports</sourceJsonReportDirectory>
        <generatedHtmlReportDirectory>target/generated-report</generatedHtmlReportDirectory>
        <customPageTitle>UI Test Results</customPageTitle>
    </configuration>
</plugin>

This generates interactive reports with charts, filtering, and detailed step information!

Integration with CI/CD

For Jenkins or GitHub Actions, add this to your pipeline:

# GitHub Actions example
- name: Run UI Tests
  run: mvn clean test
 
- name: Publish Test Results
  uses: dorny/test-reporter@v1
  if: always()
  with:
    name: Cucumber Tests
    path: target/cucumber-reports/Cucumber.xml
    reporter: java-junit

Common Pitfalls to Avoid

  1. Writing overly technical Gherkin: Keep it business-focused

    # Bad
    When I click element with ID "submitBtn"
     
    # Good
    When I submit the contact form
  2. Not using waits properly: Always use explicit waits

    // Bad
    Thread.sleep(5000);
     
    // Good
    wait.until(ExpectedConditions.elementToBeClickable(submitButton));
  3. Creating monolithic step definitions: Keep them focused and reusable

  4. Ignoring failed tests: If a test is flaky, fix it or remove it

Key Takeaways

  1. Cucumber BDD makes tests readable for everyone - from developers to product managers
  2. Use Page Object Model for maintainable code - it's worth the initial setup
  3. Implement proper waits and error handling - flaky tests are worse than no tests
  4. Keep your Gherkin business-focused - write from the user's perspective
  5. Use hooks for setup/teardown - they keep your tests clean and reliable

What's Next?

Try implementing these patterns in your own projects! Start with a simple login flow and gradually add more complex scenarios. The beauty of Cucumber is that you can start small and scale up.

Have you used Cucumber for UI testing before? What challenges did you face? Drop a comment below - I'd love to hear about your experiences!

Happy testing! 🧪✨


Resources for Further Learning:

Share this article

Enjoying this post?

Don't miss out 😉. Get an email whenever I post, no spam.

Subscribe Now