DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • TestNG vs. JUnit: A Comparative Analysis of Java Testing Frameworks
  • Exploring Shadow DOM With Examples Using Cypress
  • Stop Fine-Tuning Everything: A Decision Framework for Model Adaptation
  • Lift-and-Shift vs. Modernize: A Decision Framework for Enterprise Workloads

Trending

  • Harness Engineering for AI: Why the Model Is Only Half the System
  • You Already Have an AI Working Agreement. Write It Down.
  • Agents, Tools, and MCP: A Mental Model That Actually Helps
  • Jakarta NoSQL 1.1: Advancing Polyglot Persistence for Jakarta EE 12
  1. DZone
  2. Coding
  3. Frameworks
  4. Designing a Page Object Model + TestNG Hybrid Framework: Patterns That Actually Scale

Designing a Page Object Model + TestNG Hybrid Framework: Patterns That Actually Scale

Basic Page Object Model doesn't scale. Here are the four real-world patterns needed to maintain a 2,400-test Selenium suite.

By 
Rajasekhar sunkara user avatar
Rajasekhar sunkara
·
Jul. 27, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
136 Views

Join the DZone community and get the full member experience.

Join For Free

Most articles on Page Object Model are written by people who maintain twelve tests. This is one written by somebody who has lived inside a 2,400-test web automation suite for three years and watched it ossify, get rebuilt, and ossify again.

I don’t think POM is wrong. I think the version of POM that gets taught — one class per page, methods that wrap WebElement clicks — falls apart somewhere around 300 tests. The version we run today still calls itself POM, and the page classes look like the original ones, but underneath there are three or four patterns layered on that nobody told me about when I started. This article is about those patterns.

The stack: Java 17, Selenium 4.14, TestNG 7.8, Maven, running locally and on a Selenium Grid 4 in Kubernetes. About 2,400 tests, ~38 minutes wall time on 24 parallel nodes, ~6% flake rate that we are continuously fighting to keep under 8%.

What the Textbook POM Gives You, and Where It Stops

The textbook is fine for one page. You write a LoginPage, it has a loginAs(user, pass) method, your test calls it. You feel good. Now you have 40 pages, half of them inherit a header and footer, three have modal dialogs, one is a wizard with seven steps, and you’ve got a single BasePage class that’s 1,100 lines long and includes a method called Wait-For-Thingie-To-Be-Ready-But-Only-If-FlagX-Is-Set.

The pain points I hit, in the order I hit them:

Pages that have shared regions (the global header, the side nav, a footer that’s actually loaded async). If you put header methods on every page class, you get duplication. If you put them on BasePage, you get a 1,100-line god class.

Pages that are really states. A “shopping cart” isn’t one page; it’s empty-cart, populated-cart, and during-checkout. The same URL, three behaviors.

Wait strategies that need to be page-specific. The dashboard takes 4-7 seconds to load because it’s running 11 GraphQL queries; the settings page loads instantly. A single global Thread.sleep(5000) in BasePage is how you get a 90-minute test suite.

Tests that need to set up state without going through the UI. We have a checkout test that needs the user to already have three items in their cart. Going through the UI to add three items is 14 seconds per test, times the 200 tests that need a populated cart = a lot of compute.

Cross-browser differences. Chrome and Firefox behave differently around shadow DOM. A click that works in Chrome might silently no-op in Firefox 119. POM by itself doesn’t tell you where to put the workaround.

The hybrid framework is the answer to those five problems. There are four patterns layered on top of textbook POM.

Pattern 1: Component Classes for Shared Regions

The first move is to stop pretending the header and footer belong to the page. They don’t. They are components that happen to render on the page.

Java
 
public class GlobalHeader {
private final WebDriver driver;
private final WebDriverWait wait;

@FindBy(css = "[data-test='header-search']")
private WebElement searchInput;

@FindBy(css = "[data-test='header-cart-icon']")
private WebElement cartIcon;

@FindBy(css = "[data-test='header-cart-count']")
private WebElement cartCount;

public GlobalHeader(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(driver, this);
}

public CartPage openCart() {
cartIcon.click();
return new CartPage(driver);
}

public int getCartItemCount() {
wait.until(ExpectedConditions.visibilityOf(cartCount));
return Integer.parseInt(cartCount.getText().trim());
}

public SearchResultsPage search(String query) {
searchInput.clear();
searchInput.sendKeys(query);
searchInput.sendKeys(Keys.ENTER);
return new SearchResultsPage(driver);
}
}


Then in any page that has the header (which is every page after login), the header is a field, not inherited behavior:

Java
 
public class HomePage {
private final WebDriver driver;
public final GlobalHeader header;
public final GlobalFooter footer;
public final SideNav nav;

@FindBy(css = "[data-test='homepage-hero']")
private WebElement hero;

public HomePage(WebDriver driver) {
this.driver = driver;
this.header = new GlobalHeader(driver);
this.footer = new GlobalFooter(driver);
this.nav = new SideNav(driver);
PageFactory.initElements(driver, this);
}

public boolean isHeroVisible() {
return hero.isDisplayed();
}
}


In tests:

HomePage home = new HomePage(driver); int cartCount = home.header.getCartItemCount();

The reason this scales: when the header changes, and headers always change, you fix one class. Not 40. We did the migration from BasePage containing the header methods to dedicated component classes in early 2023. It took two engineers about four days. Worth every hour. Sangeeta, who was new to the team at the time, did most of it; she said later it was the only refactor where she ended up writing fewer lines of code than she deleted.

Components nest. Our CheckoutPage has a CheckoutSidebar which has a PromoCodeWidget which has its own apply/clear methods. Test code reads down the tree:

checkout.sidebar.promoCode.apply("HOLIDAY20");

It reads like the actual product. That’s the test of a good POM split: does the test code map to how a user would describe what they’re doing?

Pattern 2: Loadable Component for State-Aware Pages

Borrowed shamelessly from Selenium’s LoadableComponent, but we ended up writing our own because Selenium’s version assumes you can get() a URL to load, which doesn’t work for SPAs and modal dialogs.

Java
 
public abstract class LoadablePage<T extends LoadablePage<T>> {
protected final WebDriver driver;
protected final WebDriverWait wait;

protected LoadablePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
}

/** Returns true once the page is loaded enough to interact with. */
protected abstract boolean isLoaded();

/** Override to return a useful error if isLoaded() times out. */
protected String loadError() {
return getClass().getSimpleName() + " did not load within timeout.";
}

@SuppressWarnings("unchecked")
public T waitUntilLoaded() {
try {
wait.until(d -> isLoaded());
} catch (TimeoutException e) {
throw new RuntimeException(loadError() + " Current URL: " + driver.getCurrentUrl(), e);
}
return (T) this;
}
}


A page extends it:

Java
 
public class DashboardPage extends LoadablePage<DashboardPage> {
@FindBy(css = "[data-test='dashboard-widgets-loaded']")
private WebElement loadedSentinel;

public DashboardPage(WebDriver driver) {
super(driver);
PageFactory.initElements(driver, this);
}

@Override
protected boolean isLoaded() {
try {
return loadedSentinel.isDisplayed();
} catch (NoSuchElementException | StaleElementReferenceException e) {
return false;
}
}
}


The trick is the [data-test='dashboard-widgets-loaded'] element. It’s a hidden div that the application renders when all 11 GraphQL queries on the dashboard have resolved. We had to ask the frontend team to add it. They pushed back at first (“you should test the user-visible state, not internal state”) and then I showed them the 27 tests that were flaking because we were waiting on the wrong element. They added the div.

This is the negotiation move that nobody writes about: getting data-test attributes added to the application is half of POM in practice. Aman on the frontend team and I had a recurring 15-minute Tuesday standup for about three months in 2023 where we went through “tests flaked, here are the elements I need stable selectors for,” and he’d merge the PR by Wednesday. That meeting did more for our flake rate than any wait-strategy refactor.

Pattern 3: Test Data Builders, Not UI Setup

Tests that need preconditions should not click their way through the UI to set up. They should call APIs.

We have a TestDataBuilder that sits next to the page objects. It uses the same authenticated session as the test:

Java
 
public class TestDataBuilder {
private final ApiClient api;
private final String userId;

public TestDataBuilder(ApiClient api, String userId) {
this.api = api;
this.userId = userId;
}

public CartBuilder withCart() {
return new CartBuilder();
}

public class CartBuilder {
private final List<String> productSkus = new ArrayList<>();
private String promoCode;

public CartBuilder withProduct(String sku) {
productSkus.add(sku);
return this;
}

public CartBuilder withProducts(String... skus) {
productSkus.addAll(Arrays.asList(skus));
return this;
}

public CartBuilder withPromoCode(String code) {
this.promoCode = code;
return this;
}

public Cart build() {
// POST /api/cart with the user's auth token
CartResponse resp = api.post("/cart",
Map.of("userId", userId, "skus", productSkus, "promo", promoCode),
CartResponse.class);
return new Cart(resp.cartId);
}
}
}


In the test:

Java
 
@Test
public void checkoutWithFullCart() {
Cart cart = data.withCart()
.withProducts("SKU-1029", "SKU-3344", "SKU-4101")
.withPromoCode("HOLIDAY20")
.build();

CartPage page = new CartPage(driver).waitUntilLoaded();
assertEquals(3, page.getItemCount());

CheckoutPage checkout = page.proceedToCheckout();
// ... rest of test
}


That builder skipped the 14 seconds of UI clicks. Multiplied across 200 tests on every CI run, it cut about 47 minutes off our suite wall time. The other thing it did, which I didn’t expect, was reduce flake, because the test wasn’t fighting the UI for setup; the actual assertion ran cleaner.

The pushback I got on this approach was philosophical: “you’re not testing the cart-add flow if you skip it.” Correct. We test the cart-add flow in one dedicated test. We don’t re-test it 200 times across other suites. This is the same argument as “don’t test the framework”; it just hits earlier than people expect.

Pattern 4: Browser-aware action helpers

When you find a Chrome-Firefox-Safari difference, you want exactly one place to put the workaround. We have an Actions helper class that wraps the most common interactions and dispatches per browser:

Java
 
public class SmartActions {
private final WebDriver driver;
private final BrowserType browser;

public SmartActions(WebDriver driver) {
this.driver = driver;
this.browser = detectBrowser(driver);
}

public void click(WebElement el) {
switch (browser) {
case FIREFOX -> firefoxClick(el);
case SAFARI -> safariClick(el);
default -> el.click();
}
}

private void firefoxClick(WebElement el) {
// Firefox 119 has a bug where clicks on elements with
// pointer-events: none parents silently fail. JS click bypasses.
if (isInsideShadowDom(el)) {
((JavascriptExecutor) driver).executeScript("arguments[0].click();", el);
} else {
el.click();
}
}

private void safariClick(WebElement el) {
// Safari needs a scroll-into-view before click on long pages
((JavascriptExecutor) driver).executeScript(
"arguments[0].scrollIntoView({block: 'center'});", el);
try { Thread.sleep(150); } catch (InterruptedException e) {}
el.click();
}
}


That Thread.sleep(150) in SafariClick offends every clean-code instinct in your body. It’s there because it works and the documented WebDriverWait alternatives don’t. The Safari driver has its own race condition between scroll and click that I tracked through 60 hours of debugging and ended up logging as a bug against safaridriver. They acknowledged it, and I haven’t seen a fix.

Page objects use SmartActions instead of calling .click() directly:

public CartPage proceedToCheckout() { actions.click(checkoutButton); return new CartPage(driver); } 

When a new browser quirk shows up, you fix it in one place and 2,400 tests inherit the fix.

TestNG configuration that actually parallelizes

Selenium’s parallelization story is fine. TestNG’s parallelization story is fine. Getting them to play nice with a remote grid took longer than I want to admit.

The key insight: parallel="methods" plus thread-count in your testng.xml is necessary but not sufficient. You also need a WebDriver factory that creates a new driver per thread, and a BeforeMethod that doesn’t accidentally leak drivers across threads.

Java
 
public class DriverFactory {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

public static WebDriver get() {
if (DRIVER.get() == null) {
DRIVER.set(create());
}
return DRIVER.get();
}

private static WebDriver create() {
String browser = System.getProperty("browser", "chrome");
String gridUrl = System.getProperty("grid.url", "http://selenium-hub:4444/wd/hub");
DesiredCapabilities caps = new DesiredCapabilities();
caps.setBrowserName(browser);
try {
return new RemoteWebDriver(new URL(gridUrl), caps);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}

public static void quit() {
WebDriver d = DRIVER.get();
if (d != null) {
d.quit();
DRIVER.remove();
}
}
}

BaseTest:

public abstract class BaseTest {
protected WebDriver driver;

@BeforeMethod(alwaysRun = true)
public void setUp() {
driver = DriverFactory.get();
driver.manage().window().setSize(new Dimension(1440, 900));
}

@AfterMethod(alwaysRun = true)
public void tearDown() {
DriverFactory.quit();
}
}


The ThreadLocal matters. Without it, two parallel test threads will share a driver and corrupt each other. The first time we deployed to the grid, we had a 22% flake rate that was almost entirely shared-driver corruption. Adding ThreadLocal fixed it in an afternoon.

testng.xml for the parallel run:

XML
 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="full-regression" parallel="methods" thread-count="24">
<listeners>
<listener class-name="com.example.framework.RetryListener"/>
<listener class-name="com.example.framework.ScreenshotOnFailureListener"/>
</listeners>
<test name="regression">
<packages>
<package name="com.example.tests.regression"/>
</packages>
</test>
 </suite>


The retry listener handles transient failures (network blip, grid node death). One retry, no more. We had a phase where engineers were setting it to retry 3-5 times, and the suite would “pass” but actually be hiding real bugs. One retry. That’s the rule. If a test needs three retries to pass, it’s a flaky test, and we file it as a bug, not a feature.

What I’d tell you to skip

Two things I tried and reverted on.

We spent a quarter trying to use Cucumber as the test runner because some product stakeholder wanted “BDD.” We wrote ~80 step definitions, and they slowed everything down. The page objects were now wrapped in step definitions, which made the test code less expressive, not more. The product stakeholder lost interest by Q2, and we ripped Cucumber out. If you’re considering Cucumber: be very sure the product side will actually read the .feature files. They usually don’t.

We also tried to auto-generate page objects from the application’s component library. Some early @FindBy attempts used the React component names as selectors. It worked for trivial pages and broke on anything dynamic. The lesson, six months in: page objects encode test intent, not application structure. Generating them from the app’s components produces page classes that mirror the implementation, which is the opposite of what you want; a page object should outlive the implementation that backs it.

What we’re working on now

The current evolution is moving the heavier setup builders behind a service that the test suite calls. Right now, TestDataBuilder calls our app’s API directly; we’re abstracting that into a “test fixture service” that can also seed the database directly when the API doesn’t expose what we need. The argument for it: there are still 30 or so tests that go through the UI to set up state because the API doesn’t have an endpoint. 

We could either add the endpoints (the right answer; the frontend team has been saying yes for nine months) or seed via SQL (the wrong answer; the fast answer). We’re doing both, in parallel, depending on which is faster for the specific test.

Anyway, that’s the framework. POM at the bottom, components above it, loadable pages on top of those, builders for setup, smart actions for browser quirks, ThreadLocal for grid parallelism. Four patterns, all of which I learned by getting them wrong first.

Object model TestNG Framework

Opinions expressed by DZone contributors are their own.

Related

  • TestNG vs. JUnit: A Comparative Analysis of Java Testing Frameworks
  • Exploring Shadow DOM With Examples Using Cypress
  • Stop Fine-Tuning Everything: A Decision Framework for Model Adaptation
  • Lift-and-Shift vs. Modernize: A Decision Framework for Enterprise Workloads

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook