Cucumber with Page Object Model

9 min read

Cucumber tells you what to test. Selenium knows how to automate it. The Page Object Model is the layer in between — the translation layer that keeps Selenium details out of step definitions and keeps step definitions readable. When all three work together, a BDD framework becomes genuinely maintainable.

The problem without POM

Step definitions without page objects accumulate Selenium directly:

@When("the user logs in with {string} and {string}")
public void login(String email, String password) {
    driver.findElement(By.id("email")).sendKeys(email);
    driver.findElement(By.id("password")).sendKeys(password);
    driver.findElement(By.cssSelector("[data-testid='submit']")).click();
    new WebDriverWait(driver, Duration.ofSeconds(10))
        .until(ExpectedConditions.urlContains("/dashboard"));
}

This works — once. When the email input's ID changes, every step definition that touches the login form breaks. You search through step classes looking for By.id("email"). The step definition has become a test script.

The layered architecture

With POM, each layer has a single job:

Feature files     → describe WHAT (business behaviour)
Step definitions  → translate Gherkin to page object calls (GLUE)
Page objects      → encapsulate HOW (Selenium interactions)
WebDriver         → drives the browser
// Step definition — thin, readable
@When("the user logs in with {string} and {string}")
public void login(String email, String password) {
    loginPage.loginAs(email, password);
}
// Page object — owns all Selenium for the login page
public class LoginPage {
    private final WebDriver driver;
    private final WebDriverWait wait;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }
 
    public void loginAs(String email, String password) {
        driver.findElement(By.id("email")).sendKeys(email);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        wait.until(ExpectedConditions.urlContains("/dashboard"));
    }
 
    public String getErrorMessage() {
        return driver.findElement(By.cssSelector(".error-message")).getText();
    }
}

When the email input ID changes, you update LoginPage in one place. The step definition, the feature file, and every other scenario that uses the login page are untouched.

A complete Cucumber + POM example

Feature: User Login
 
  Background:
    Given the user is on the login page
 
  Scenario: Successful login
    When the user logs in with "standard_user@saucedemo.com" and "secret_sauce"
    Then the dashboard should be displayed
 
  Scenario: Login fails with wrong password
    When the user logs in with "standard_user@saucedemo.com" and "wrong_pass"
    Then an error message should appear

Step definitions — all delegating to page objects:

public class LoginSteps {
    private final TestContext context;
    private final LoginPage loginPage;
    private final DashboardPage dashboardPage;
 
    public LoginSteps(TestContext context) {
        this.context = context;
        this.loginPage = new LoginPage(context.getDriver());
        this.dashboardPage = new DashboardPage(context.getDriver());
    }
 
    @Given("the user is on the login page")
    public void onLoginPage() {
        loginPage.navigateTo();
    }
 
    @When("the user logs in with {string} and {string}")
    public void loginAs(String email, String password) {
        loginPage.loginAs(email, password);
    }
 
    @Then("the dashboard should be displayed")
    public void verifyDashboard() {
        assertTrue(dashboardPage.isLoaded(),
            "Dashboard was not displayed after login");
    }
 
    @Then("an error message should appear")
    public void verifyError() {
        assertTrue(loginPage.isErrorDisplayed(),
            "No error message was shown for invalid login");
    }
}

Page objects:

public class LoginPage {
    private static final String URL = "https://www.saucedemo.com";
    private final WebDriver driver;
    private final WebDriverWait wait;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }
 
    public void navigateTo() {
        driver.get(URL);
    }
 
    public void loginAs(String username, String password) {
        driver.findElement(By.id("user-name")).sendKeys(username);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.id("login-button")).click();
    }
 
    public boolean isErrorDisplayed() {
        return !driver.findElements(By.cssSelector("[data-test='error']")).isEmpty();
    }
}
 
public class DashboardPage {
    private final WebDriver driver;
 
    public DashboardPage(WebDriver driver) {
        this.driver = driver;
    }
 
    public boolean isLoaded() {
        return driver.getCurrentUrl().contains("inventory");
    }
 
    public List<String> getProductNames() {
        return driver.findElements(By.cssSelector(".inventory_item_name"))
            .stream()
            .map(WebElement::getText)
            .collect(Collectors.toList());
    }
}

Initialising page objects

Page objects need the driver. With PicoContainer injecting TestContext, there are two approaches:

Option A — instantiate in step definition constructor (shown above). Simple; the page object is created once per scenario at construction time. Works when the driver is always set by the time the step definition constructor runs (i.e., @Before runs before step definition construction, which it does).

Option B — factory/lazy initialisation in TestContext:

public class TestContext {
    private WebDriver driver;
 
    public LoginPage getLoginPage() {
        return new LoginPage(driver);
    }
 
    public DashboardPage getDashboardPage() {
        return new DashboardPage(driver);
    }
}

Option B keeps page object creation centralised. Option A is simpler for most projects. Pick one and be consistent.

Assertions: in step definitions or page objects?

Both approaches are valid. Two positions teams take:

Assertions in step definitions (most common): page objects expose state via getters; step definitions assert. Step definitions express the test intent; page objects stay as pure interaction abstractions.

@Then("the product list should contain {string}")
public void verifyProduct(String name) {
    assertTrue(dashboardPage.getProductNames().contains(name));
}

Assertions in page objects (sometimes called fluent page assertions): page objects expose fluent assertion methods. Step definitions call them. Common in teams that want page objects to read like a domain language.

@Then("the product list should contain {string}")
public void verifyProduct(String name) {
    dashboardPage.shouldContainProduct(name);  // assertion inside
}

The Selenium with Java course's POM chapter covers the tradeoffs in depth. For Cucumber specifically: if a page's assertion methods are shared across many Then steps in multiple feature files, the page-object-assertion pattern reduces duplication. Otherwise, assertions in step definitions keep page objects testable in isolation.

The full architecture

⚠️ Common mistakes

  • Fat step definitions. A step definition with 10 lines of Selenium is a page object that hasn't been extracted yet. If a step definition calls findElement more than once, move that logic to a page object.
  • Page objects that reach into TestContext. Page objects should take the driver (or a base page with the driver) and nothing else from the BDD world. Injecting TestContext into page objects couples the POM layer to Cucumber — page objects should be usable independently of the test runner.
  • One giant PageHelper static utility class. Instead of proper page objects, teams sometimes create a single class with static methods for every page. This defeats the locator-centralisation benefit and makes parallel execution unsafe. One class per page, instance methods, driver injected through the constructor.
  • Page objects that assert. A loginPage.assertLoginFailed() that throws AssertionError makes the page object responsible for test decisions. The page object becomes untestable in isolation. Prefer loginPage.isErrorDisplayed() returning a boolean, with the assertion in the step definition.

🎯 Practice task

Build a complete Cucumber + POM project for Sauce Demo login and product browsing. 45–60 minutes.

  1. Create pages/LoginPage.java and pages/DashboardPage.java with locators and interaction methods (no Selenium in step definitions).
  2. Create LoginSteps.java that constructs page objects from the TestContext driver and delegates to them. All Then assertions should call page object getters.
  3. Write 3 scenarios in login.feature (successful login, wrong password, locked-out user). Run them all — they should exercise page objects exclusively from the step definitions.
  4. Add a 4th scenario that logs in and then asserts the product list is not empty. This needs a Then step in DashboardSteps.java using DashboardPage.getProductNames().
  5. Intentionally break a locator in LoginPage (wrong ID). Run the suite. Confirm the error points to LoginPage, not to the step definition or feature file.
  6. Stretch: add a BasePage class with the WebDriver, WebDriverWait, and shared helper methods (clickWithWait, typeInField, isVisible). Have LoginPage and DashboardPage extend it. Confirm the helpers are available in both page objects.

This completes Chapter 3. Chapter 4 covers advanced scenarios: full Cucumber + Selenium integration, API BDD with Rest Assured, parallel execution, and reporting.

// tip to track lessons you complete and pick up where you left off across devices.