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 appearStep 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
findElementmore 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. InjectingTestContextinto page objects couples the POM layer to Cucumber — page objects should be usable independently of the test runner. - One giant
PageHelperstatic 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 throwsAssertionErrormakes the page object responsible for test decisions. The page object becomes untestable in isolation. PreferloginPage.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.
- Create
pages/LoginPage.javaandpages/DashboardPage.javawith locators and interaction methods (no Selenium in step definitions). - Create
LoginSteps.javathat constructs page objects from theTestContextdriver and delegates to them. AllThenassertions should call page object getters. - 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. - Add a 4th scenario that logs in and then asserts the product list is not empty. This needs a
Thenstep inDashboardSteps.javausingDashboardPage.getProductNames(). - Intentionally break a locator in
LoginPage(wrong ID). Run the suite. Confirm the error points toLoginPage, not to the step definition or feature file. - Stretch: add a
BasePageclass with theWebDriver,WebDriverWait, and shared helper methods (clickWithWait,typeInField,isVisible). HaveLoginPageandDashboardPageextend 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.