Skip to main content
Back to Blog
Tutorial
2026-05-09

Selenide Page Object Pattern — Best Practices 2026

Build maintainable Selenide page objects. Class structure, locator strategies, return types, fluent API, components, and team conventions.

Selenide Page Object Pattern Best Practices

The Page Object pattern is the most important design pattern for browser test code. Done right, it isolates locator details behind expressive class APIs, makes tests read like user stories, and confines maintenance to a handful of files when the UI changes. Done wrong, it creates a parallel UI in test code that doubles your maintenance burden. Selenide's syntax — $, $$, should, shouldHave — makes Page Objects especially clean, but only if you follow the patterns this guide describes.

This guide is a hands-on walkthrough of Page Object best practices in Selenide for 2026. We cover class structure, naming conventions, locator strategies, return types, fluent APIs, component patterns for reusable UI sections, page initialization, navigation methods, and the team conventions that scale across hundreds of tests. Every example is working Java code using Selenide 7+ and JUnit 5.


Key Takeaways

  • One Page Object per page — not per feature, not per workflow
  • Use SelenideElement fields, not WebElement, to keep retry/wait behavior
  • Methods return the next page (or this) to enable fluent chaining
  • No assertions in Page Objects — assertions belong in tests
  • Use @FindBy only when necessary$ lookups are clearer for most cases
  • Components for reusable sections like headers, navbars, modals

Anti-Pattern: Raw WebDriver in Tests

This is what we're trying to avoid:

@Test
void loginTest() {
    driver.get("/login");
    driver.findElement(By.id("email")).sendKeys("alice@example.com");
    driver.findElement(By.id("password")).sendKeys("secret");
    driver.findElement(By.id("submit")).click();
    new WebDriverWait(driver, Duration.ofSeconds(10))
        .until(d -> d.findElement(By.id("dashboard")).isDisplayed());
    assertTrue(driver.findElement(By.id("user-name")).getText().contains("alice"));
}

Locators are scattered everywhere. Waits are manual. When the login form changes, you edit five test files.


Pattern: Simple Page Object

The minimum viable Page Object:

import com.codeborne.selenide.SelenideElement;
import static com.codeborne.selenide.Selenide.$;

public class LoginPage {
    private final SelenideElement emailField = $("#email");
    private final SelenideElement passwordField = $("#password");
    private final SelenideElement submitButton = $("#submit");

    public LoginPage typeEmail(String email) {
        emailField.setValue(email);
        return this;
    }

    public LoginPage typePassword(String password) {
        passwordField.setValue(password);
        return this;
    }

    public DashboardPage submit() {
        submitButton.click();
        return new DashboardPage();
    }
}

And the test becomes:

@Test
void loginTest() {
    Selenide.open("/login");
    new LoginPage()
        .typeEmail("alice@example.com")
        .typePassword("secret")
        .submit();
    new DashboardPage().assertWelcomes("alice");
}

The test reads like a sentence.


Rule: Return the Next Page

Methods that navigate should return a Page Object of the destination:

public DashboardPage submit() {
    submitButton.click();
    return new DashboardPage();
}

Methods that stay on the same page return this:

public LoginPage typeEmail(String email) {
    emailField.setValue(email);
    return this;
}

This enables fluent chaining.


Rule: No Assertions Inside Page Objects

Page Objects model the UI, not test logic. Assertions belong in tests:

// Bad
public class DashboardPage {
    public void assertLoggedIn(String name) {
        $("#user-name").shouldHave(text(name));
    }
}

// Good
public class DashboardPage {
    public SelenideElement userName() {
        return $("#user-name");
    }
}

// In test:
new DashboardPage().userName().shouldHave(text("alice"));

Or expose query methods that return values, never assertions:

public String getUserName() {
    return $("#user-name").getText();
}

Exception: a single isLoaded() method per page is OK to assert the page has rendered:

public class DashboardPage {
    public DashboardPage shouldBeLoaded() {
        $("#dashboard").shouldBe(visible);
        return this;
    }
}

This is acceptable because it's part of navigation, not test logic.


Rule: Use SelenideElement, Not WebElement

// Bad
private WebElement submit = driver.findElement(By.id("submit"));

// Good
private final SelenideElement submit = $("#submit");

SelenideElement is lazy (evaluates when accessed) and retry-aware. WebElement becomes stale on DOM changes.


Locator Strategy

LocatorWhen to use
$("#id")Stable IDs (best)
$("[data-testid='foo']")Test-only data attributes (also great)
$(".class.modifier")When IDs unavailable
$("xpath://...")Complex hierarchy navigation
$$("selector").findBy(text(...))Find by visible text

Prefer data-testid attributes added by developers specifically for testing. They're the most stable.


Pattern: Page Components

For reusable sections like headers and modals, extract Component classes:

public class Navbar {
    private final SelenideElement self;

    public Navbar(SelenideElement root) { this.self = root; }

    public ProfilePage clickProfile() {
        self.$(".profile-link").click();
        return new ProfilePage();
    }

    public Navbar openMenu() {
        self.$(".menu-toggle").click();
        return this;
    }
}

public class DashboardPage {
    private final Navbar navbar = new Navbar($(".navbar"));

    public Navbar navbar() { return navbar; }
}

// Use in test:
new DashboardPage().navbar().clickProfile();

Components are scoped to a root element, so locators inside are relative.


Pattern: Lazy Element Initialization

Avoid initializing elements in constructors — they may not exist yet when the Page Object is created. Use fields:

public class LoginPage {
    private final SelenideElement email = $("#email"); // lazy: resolves on access
    // ...
}

Selenide's $ is lazy by default. Field initialization stores the locator, not the actual element.


Pattern: Navigation

public class LoginPage {
    public static LoginPage open() {
        Selenide.open("/login");
        return new LoginPage();
    }
}

Then in tests:

@Test
void test() {
    LoginPage.open()
        .typeEmail("a@b.com")
        .typePassword("secret")
        .submit();
}

Pattern: Form Data Objects

For pages with many fields, accept a data object:

public class SignupPage {
    public SignupPage fill(SignupForm form) {
        $("#name").setValue(form.name);
        $("#email").setValue(form.email);
        $("#password").setValue(form.password);
        $("#country").selectOption(form.country);
        $("#tos").shouldBe(visible).click();
        return this;
    }
}

public record SignupForm(String name, String email, String password, String country) { }

Then tests:

new SignupPage().fill(new SignupForm("Alice", "a@b.com", "secret123", "USA")).submit();

Pattern: Conditional Methods

For modals or panels that may or may not be present:

public class HomePage {
    public HomePage dismissCookieBannerIfPresent() {
        if ($("#cookie-banner").is(visible)) {
            $("#cookie-banner .dismiss").click();
        }
        return this;
    }
}

Use is() (which returns immediately) for conditional checks, not should (which waits).


Pattern: Multi-Element Operations

public class ProductsPage {
    public ProductsPage assertProductsShown(List<String> names) {
        $$(".product .name").shouldHave(exactTexts(names.toArray(new String[0])));
        return this;
    }
}

This is one of the few cases where having a "should" method in a Page Object is acceptable — when the assertion is part of the page's contract.


Project Structure

src/test/java/
  pages/
    LoginPage.java
    DashboardPage.java
    SignupPage.java
    components/
      Navbar.java
      Footer.java
      Modal.java
  tests/
    LoginTest.java
    SignupTest.java
  config/
    SelenideConfig.java

Naming Conventions

WhatConventionExample
Page classNounPageLoginPage
Component classNounNavbar
Action methodverbsubmit(), clickProfile()
Query methodget/is/has prefixgetUserName(), isLoaded()
Element fieldnounOrAdjectivesubmitButton, errorMessage
Test methodverb_should_outcomelogin_shouldRedirectToDashboard

Common Mistakes

MistakeFix
Assertions in Page ObjectsMove to tests
Returning void from action methodsReturn next Page Object or this
Storing WebElement instead of SelenideElementUse SelenideElement
Initializing elements with findElement() in constructorUse field initializers with $
One mega-page for the whole appSplit per page
Tests directly using $Encapsulate in Page Objects
Reusing the same Page Object across pagesOne class per page
Reading state with getText() then assertingExpose element, assert in test

Test Example

class CheckoutTest {

    @Test
    void completePurchase() {
        ProductsPage products = HomePage.open()
            .navbar()
            .clickProducts();

        products
            .filter("electronics")
            .add("laptop-pro")
            .add("usb-c-cable")
            .openCart()
            .applyCoupon("SAVE10")
            .checkout()
            .fillShipping(testShipping())
            .fillPayment(testPayment())
            .placeOrder()
            .orderConfirmation()
            .shouldHave(text("Order placed"));
    }
}

The test reads like a user story. Locators are nowhere in sight.


Conclusion

Page Objects done right are the difference between a 100-test suite you can maintain and a 100-test suite that drowns your team. Encapsulate locators, return Page Objects from actions, keep assertions in tests, use Components for reusable sections, and follow consistent naming. Combined with Selenide's expressive condition DSL, the result is browser tests that read like spec documentation.

For deeper coverage, see our Selenide Condition cheatsheet and Collection reference.

Explore the QA skills directory for related browser automation patterns.

Selenide Page Object Pattern — Best Practices 2026 | QASkills.sh