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
-
Writing overly technical Gherkin: Keep it business-focused
# Bad When I click element with ID "submitBtn" # Good When I submit the contact form
-
Not using waits properly: Always use explicit waits
// Bad Thread.sleep(5000); // Good wait.until(ExpectedConditions.elementToBeClickable(submitButton));
-
Creating monolithic step definitions: Keep them focused and reusable
-
Ignoring failed tests: If a test is flaky, fix it or remove it
Key Takeaways
- Cucumber BDD makes tests readable for everyone - from developers to product managers
- Use Page Object Model for maintainable code - it's worth the initial setup
- Implement proper waits and error handling - flaky tests are worse than no tests
- Keep your Gherkin business-focused - write from the user's perspective
- 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: