POM Anti-Patterns and When to Skip POM
The Page Object Model is simple enough that most suites adopt it, and flexible enough that most suites eventually misuse it. This chapter collects the mistakes that turn page objects from an asset into the thing everyone is afraid to touch, and ends with the cases where a different pattern fits better.
The assertions debate#
Should a page object contain assertions? The three sources most people cite do not agree:
- The Selenium documentation: "Page objects themselves should never make verifications or assertions." The one exception is checking that the page "and possibly critical elements on the page, were loaded correctly", done while instantiating the page object.
- Martin Fowler (2013): "I favor having no assertions in page objects", with an exception for invariants of the page rather than the things a specific test is checking. He suggests shared assertion helpers to avoid duplication instead.
-
Playwright's POM guide: its example page object has a
getStarted()method that clicks a link and then callsawait expect(this.gettingStartedHeader).toBeVisible().
These are less contradictory than they look once you separate two kinds of check:
| Kind | Example | Where it belongs |
|---|---|---|
| Precondition: "am I where I think I am?" |
LoggedInPage waits for /logged-in-successfully/ in its constructor |
Page object |
| Outcome: "did the feature behave correctly?" | The heading says "Logged In Successfully" | Test |
The Playwright example's expect is a precondition: getStarted() confirms that navigation finished before the next method acts. That is exactly what our constructor checks do in Selenium, and what Playwright's own auto-waiting would do on the next action anyway.
Outcome assertions inside page objects cause three concrete problems:
-
The test stops saying what it checks.
loginPage.loginAndVerifySuccess()hides the expected result in another file. A reviewer reading the test cannot tell whether "success" means the URL, the heading, or a cookie. -
Negative tests get awkward. A
login()that asserts success cannot be reused for the wrong-password test, so a second method appears, then a flag parameter, then both. -
Failures point at the wrong place. The stack trace's top frame is in
LoginPage.java, and the report attributes the failure to the page object rather than to the behaviour.
Our rule for this course, and a reasonable default for yours: page objects may wait for and verify their own state; tests own every outcome assertion. If many tests repeat the same group of assertions, extract an assertion helper (assertLoggedInAs(home, "student")) in a separate module, not a page object method.
Fifteen tests end with the same three checks that the user is logged in as student. Where should those checks live?
Outcome assertions belong to the test side. A shared helper removes the duplication without turning the page object into a place where failures get attributed to the page.
God objects#
The BasePage that has grown to 60 methods: scrollToElement, switchToFrame, acceptAlert, uploadFile, waitForSpinner, getTableCell, clickByJavaScript, dragAndDrop... Every page inherits all of them, nobody knows which are used, and changing one risks every test.
The same happens to page objects for big screens. A 2,000-line DashboardPage with 150 locators is not a page object anymore; it is a namespace.
Fixes:
- Keep
BasePageto waits and the two or three interactions every page needs. Our base page has three helpers. - Move specialised interactions into components (chapter 03): a
FileUploader, aDataTable, aDatePicker, each with a root element. Pages that contain one, compose one. - Prefer composition to inheritance.
CheckoutPage extends CartPage extends ProductPage extends BasePagemakes every page carry its ancestors' locators, and a change toProductPagebreaks checkout tests.
CheckoutPage extends CartPage extends ProductPage, and a change to ProductPage just broke the checkout tests. What is the better structure?
Inheritance makes every page carry its ancestors' locators. Composition shares only the parts that really repeat. Moving everything into BasePage creates a god object.
Over-abstraction#
The opposite failure. Some frameworks wrap every element in its own class (TextBox, Button, Link) or build a generic layer that drives the UI by strings:
// avoid: a framework inside the framework
ui.perform("LoginPage", "username", "type", "student");
ui.perform("LoginPage", "submit", "click", null);
This throws away the compiler's help, makes stack traces useless, and forces every new interaction through a layer only its author understands. If a team wants tests written by non-programmers, that is a reason to look at a BDD tool with a proper language layer, not to reimplement one inside the page objects.
Signs of over-abstraction to watch for:
- A page object method that only forwards to another method with the same name.
- Interfaces with a single implementation "in case we add another browser" (WebDriver is already that interface).
- Configuration-driven locators (locators loaded from YAML or a spreadsheet) for a UI that only one team works on. You lose "go to definition" and gain nothing.
Fowler's article names the right level directly: a page object should "allow a software client to do anything and see anything that a human can", while it "hides the underlying widgetry". Methods named after what a user does on the page, in the application's vocabulary, hit that level; a generic string-driven layer misses it.
Smaller smells#
Tests that reach past the page object. A test that calls driver.findElement(...) next to page object calls has two sources of truth for that page. When it happens, add the missing method to the page object.
Page objects that return WebElements (in Selenium). Tests then click, type and wait on raw elements, and you are back to locator knowledge spread across tests. Return data (strings, numbers, booleans, lists) or other page objects. In Playwright, exposing Locators for web-first assertions is fine, as chapter 02 explained.
Sleeps. Thread.sleep(2000) or time.sleep(2) inside a page object slows every test that uses it and still fails on a slow day. Replace each with a wait for the specific condition the sleep was hoping for.
Branching on page state. if (cookieBanner.isDisplayed()) cookieBanner.accept(); inside a page object makes the test pass whether or not the banner appeared. Decide what the state should be (for instance, set the consent cookie before the test starts) and let the test fail if reality disagrees.
Page objects holding test state. A page object that remembers "the user we logged in as" or "the order we created" couples it to one test's story. Keep that in the test or in a fixture.
Order-dependent tests. Test B assumes test A already logged in or created data. They pass in sequence and fail in parallel or when run alone. Every test sets up its own preconditions, ideally through fixtures (chapter 05) or an API.
A page object contains if (cookieBanner.isDisplayed()) cookieBanner.accept();. What is the problem?
Branching on page state hides real changes in behaviour. Setting the state before the test makes the test deterministic.
When not to use POM#
Page objects earn their keep when many tests touch the same screens over a long time. They are overhead when:
- The suite is tiny or short-lived. Three smoke tests, or a one-off migration check, do not need a class per page. Write the script; extract page objects when the second test needs the same steps.
- The test is not about the UI. API tests and contract tests should call the API directly. Setting up data through the UI to test something else is slow and fragile, even with excellent page objects.
- The assertion is the whole page. Visual regression tests compare screenshots; they need a URL and a stable state, not a page object API.
And there is a pattern that some teams prefer for large, behaviour-focused suites: Screenplay. Instead of modelling pages, Screenplay models actors (a user with abilities, such as "browse the web" or "call the API"), tasks they perform ("log in", "add an item to the cart"), and questions they ask about the system ("the visible error message"). Tasks are composed from smaller interactions, so the unit of reuse is a business action rather than a screen. Serenity BDD (Java) and Serenity/JS implement it.
Screenplay shines when one business task spans several pages, or several channels (UI plus API), and when tests are written from user stories. The cost is more types and more indirection for simple flows. Many teams find that well-built page objects with components and fluent returns, which is where this course ended up, already get most of the benefit.
If you test native apps, the same anti-patterns apply to screen objects; our Appium page object article shows the mobile version of these structures. For a broader look at flaky-test root causes, including several listed above, see handling test flakiness.