Skip to content
intermediate Phase 8 · Page Object Patterns

Page Object Basics

Encapsulate selectors and page actions so UI details live in a single maintained place.

1h
0 problems
Topic Progress 0%

One Place for Every Selector: The Single-File Repair Guarantee

A suite of forty tests interacts with the login screen through selectors copied into each file. The header redesign renames the email field's label, and forty tests fail identically. Forty edits later — one of them missed, found only in the nightly run — the suite is green again, and someone suggests automating less. The Page Object Model exists to make that scenario a one-file change, and the mechanism is the oldest idea in software engineering: encapsulation, applied to UI knowledge.

A page object is a class (or module) that represents one screen or component and owns everything the test code needs to interact with it: selectors, URL, and the actions the screen offers. Test code never mentions a selector; it calls the object.

// pages/LoginPage.ts — ALL login knowledge lives here
export class LoginPage {
  readonly path = '/login';
  private email = (p: Page) => p.getByLabel('Email address');
  private password = (p: Page) => p.getByLabel('Password');
  private submit = (p: Page) => p.getByRole('button', { name: 'Sign in' });

  async goto(page: Page) { await page.goto(this.path); }
  async signIn(page: Page, email: string, password: string) {
    await this.email(page).fill(email);
    await this.password(page).fill(password);
    await this.submit(page).click();
  }
}

// tests/forgot-password.spec.ts — no selector ever appears
test('locked-out user sees unlock guidance', async ({ page }) => {
  const login = new LoginPage();
  await login.goto(page);
  await login.signIn(page, 'locked@example.com', 'hunter2');
  await expect(page.getByRole('alert'))
    .toContainText('account locked');
});

The redesign renames the label; one file changes; forty tests are untouched. That is the single-file repair guarantee, and it is why the pattern spread from Selenium's Java suites into every framework that followed — Playwright's own docs call the same idea "page objects", which tells you the pattern is framework-independent.

Scope the object to a screen, not to the app. One page object per route or per significant view (CheckoutPage, OrderConfirmationPage), each holding its own selectors and actions. The test file becomes the composition layer: it decides which screens to visit and in what order, which keeps the scenario readable while the mechanics stay encapsulated. Selectors follow the convention from Robust Locator Strategy — role/label first, test ids where semantics cannot express the target — and the object is the natural enforcement point: the convention governs a handful of page files instead of hundreds of test lines.

Two boundaries to respect from day one. First, the page object holds interactions, not verification: calling signIn() returns when the click happened, and what the test expected afterward is the test's business — the next chapter explains why assertion-free objects are worth the discipline. Second, keep the object thin over locators and rich in meaning: a method named applyPromo('SAVE10') survives a DOM rewrite; a method named clickSubmitButton2() merely relocates the brittleness. Both boundaries are easy to violate under deadline, which is what the next chapter's composition patterns and the fluent-API chapter exist to keep honest.

A practical adoption note for teams with an existing selector-sprinkled suite: migrate by flow, not by file. When a login test breaks in a redesign, that is the moment to extract LoginPage — motivated, verified by the same failing-then-passing test, and immediately paying rent. Mass extraction across two hundred files produces review fatigue and half-migrated madness; extraction at the point of pain produces a suite that converges on the pattern naturally, one repaired flow at a time. Each extraction also surfaces duplicates: forty slightly-different login selectors collapse into one method, and the arguments for "why our tests are flaky" visibly shrink with it.

login(), applyPromo(): Designing Intent-Level Methods Instead of Raw Clicks

Raw clicks and fills are mechanics; tests need meaning. The difference shows the moment a flow grows past one step. Consider the checkout screen's promo-code interaction: the user clicks an "Add promo" toggle, waits for a field to appear, types the code, clicks Apply, and waits for the total to update. Written inline in a test, those five actions obscure intent; written as one page-object method, they disappear behind a name a product owner would recognize.

// CheckoutPage.ts — intent-level surface
export class CheckoutPage {
  async applyPromo(page: Page, code: string) {
    await page.getByRole('button', { name: 'Add promo code' }).click();
    const input = page.getByLabel('Promo code');
    await input.fill(code);
    await page.getByRole('button', { name: 'Apply' }).click();
    await expect(page.getByTestId('promo-status')).toBeVisible();  // internal wait-by-assertion
  }

  async total(page: Page): Promise<string> {
    return (await page.getByTestId('order-total').textContent()) ?? '';
  }

  async placeOrder(page: Page) {
    await page.getByRole('button', { name: 'Place order' }).click();
  }
}

// the test reads like the business scenario
test('SAVE10 halves the order total', async ({ page }) => {
  const checkout = new CheckoutPage();
  await checkout.goto(page, seedCart());
  const before = await checkout.total(page);
  await checkout.applyPromo(page, 'SAVE10');
  expect(await checkout.total(page)).not.toBe(before);
  // ...assert the discounted total precisely
});

Name methods after user intent, in the user's vocabulary. login(), searchFor('running shoes'), addToCart(), applyPromo() — verbs a stakeholder would use. The naming test: could you narrate the test to a product owner by reading only the method calls? If yes, the abstraction altitude is right. If the test still reads clickLocator3() calls, the object relocated code without adding meaning.

Method granularity — one user-visible action per method, internally as many steps as needed. The promo example hides five steps behind one name because a user thinks of it as one action. Conversely, do not lump whole journeys into a single mega-method (completePurchase() doing login→cart→payment→confirm): tests that need to pause mid-journey — most negative tests do — cannot, and the method grows conditionals, which is a test with its logic buried. The rule of thumb: a method ends where a user would pause.

Return values and state. Query-style methods (total(), isRowPresent('Acme')) return data the test asserts on; action-style methods return void or, when the action navigates, the next page object — the navigation contract the fluent-API topic explores in depth. Both keep the test in charge of expectations: the page object answers "what is" and performs "what the user does"; only the test decides "what should be".

One more practical note for Selenium-flavored suites: the same shape applies with WebElement fields and By locators — the object's constructor receives the WebDriver, methods use WebDriverWait internally, and tests still see only intent-level calls. The pattern predates Playwright by a decade; what changes is the wait ergonomics, not the discipline. In Java, the object is a plain class with a WebDriver field; in Python, a class receiving driver in __init__. Either way the test's import list shows page names, never By.cssSelector strings — and that import list is itself a readable summary of what the suite exercises.

Finally, resist the urge to make every method return the next page object from the start; plain void returns plus explicit object construction in the test are clearer while a flow is still changing. Returning "next" is a readability optimization worth adopting deliberately once the flow stabilizes — the fluent-API chapter shows exactly when and how it pays off, and when it starts hiding navigation a reader needs to see.

Why Page Objects Must Not Assert — and Where the Waits Belong

The two most common page-object design errors are symmetric: objects that assert, and objects that know too much. Both feel helpful in the moment and both corrode the suite's ability to explain failures.

Why assertions don't belong in page objects. A failed assertion must appear at the altitude where the expectation lives — the test — because the test title is what the report, the CI notification, and the flaky dashboard will show. An assertion inside applyPromo() fails under the page object's stack frame and reports "expected visible, got hidden" with no scenario context: was the promo flow broken, or was the discount expectation wrong? The reader cannot tell without opening the object. Worse, assertions inside objects tempt reuse of expectations: a shared expectSaved() baked into save() hard-codes one test's assumption into every test that saves, and now the save-with-validation-error scenario cannot use the same method. Keep the object's vocabulary to acts and queries; the test's to expectations.

The subtle cousin of this rule is wait-by-assertion inside methods, which is allowed and encouraged — applyPromo asserting the promo-status element is visible before returning is a synchronization concern ("the action completed"), not a verification concern ("the total is now discounted"). The test: the internal check should confirm the page finished reacting to the action, never the outcome the scenario cares about. Same API, different intent.

Where waits belong. Condition waits for an action's completion live in the object, because they are part of performing the action reliably: placeOrder() should not return until the confirmation view exists, so every caller gets a settled page. Test-specific waits — "the banner for this particular scenario appears" — stay in the test. This split makes objects reusable across scenarios while keeping scenario timing where the scenario lives.

God objects: the failure mode you will be tempted into. A HomePage that absorbs every selector in the app "for convenience" becomes unreviewable, merge-conflicted by every feature team, and — because everything depends on it — the single file whose breakage fails the whole suite. The discipline that prevents it: one object per screen or per cohesive component, small enough to read in a minute. When a flow spans screens (search → results → detail), the test navigates through the sequence of objects rather than one object absorbing the journey.

A compact review checklist, worth keeping in the team's PR template:

□ Selectors for this screen exist ONLY in its page object
□ Methods named after user intent; each ends where a user would pause
□ No test expectations (toEqual, exact text, counts) inside objects
□ Internal waits confirm the action completed, not the scenario's outcome
□ No cross-screen journeys inside a single object's method
□ Object small enough to review in one sitting — else split by component

Used together with the locator convention, the checklist keeps page objects what the single-file repair guarantee promised: a thin, stable map of each screen that tests navigate by intent — and that composition, factories, and fluent chaining build on starting with Composition and Page Factories.

Quiz

1. Where does the assertion 'the total is now discounted' belong when the test calls checkout.applyPromo('SAVE10')?

Question 1 options

2. What is the correct page-object granularity for a 30-screen application?

Question 2 options

3. Why do page-object methods get names like applyPromo() instead of clickSubmitButton2()?

Question 3 options

4. What is the 'god object' anti-pattern in page-object design?

Question 4 options

Flashcards

Question

What is a page object and what single guarantee does it provide?

Answer

Encapsulation of UI knowledge: one class per screen owning every selector, the URL, and intent-level actions, so test code never mentions a selector. When markup changes, one file changes — forty tests stay untouched. Test files become the composition layer navigating scenario order.

Question

What makes a page-object method 'intent-level'?

Answer

Apply the narration test: could a product owner follow the test by reading method names alone (login, searchFor, addToCart, applyPromo)? And end each method where a user would pause — one user-visible action per method, however many internal steps that hides.

Question

Why no assertions in page objects — and what is the allowed exception?

Answer

Assertions stay in tests so failures report at scenario altitude (the title the CI notification shows) and expectations aren't hard-coded into reusable methods. Allowed inside objects: waits confirming the action completed (promo-status visible). Not allowed: outcome expectations (total equals discounted).

Question

Where do condition waits belong — object or test?

Answer

In the object, as part of performing the action reliably: placeOrder() should not return until the confirmation view exists. In the test, scenario-specific waits ('the banner for this scenario'). This split keeps objects reusable across scenarios while keeping scenario timing where the scenario lives.

Question

What is the god-object anti-pattern and how do you prevent it?

Answer

A single object absorbing every selector 'for convenience': unreviewable, a merge conflict for every feature team, and the one file whose breakage fails everything. Prevention: one object per screen/component, journeys composed in the test across multiple objects.

Revision Notes

Key Takeaways

  • 1. Page objects encapsulate per-screen UI knowledge so tests navigate by intent and markup changes cost one file, not forty
  • 2. Intent-level naming (applyPromo, not clickSubmitButton2) is what makes specs readable to stakeholders and immune to DOM rewrites
  • 3. Assertions live in tests — failures must report at scenario altitude; internal waits confirm action completion only
  • 4. One object per screen, journeys composed in the test; the god object recreates the original problem in one file
  • 5. The pattern is framework-independent — Selenium suites use the same discipline with WebDriverWait inside methods

Interview Tips

  • • "What is the Page Object Model and why use it?" — encapsulation of selectors/actions per screen; the single-file repair guarantee; tests express scenarios, objects express mechanics
  • • "Can page objects contain assertions?" — no: expectations belong to the scenario, so failures keep context and methods stay reusable; but action-completion waits inside are correct and encouraged
  • • "How do you decide a method's boundary?" — one user-visible action; ends where a user would pause; narrate-the-test-to-a-product-owner is the altitude test
  • • "Your suite has one 2000-line page class — what's wrong?" — god object: unreviewable, conflict-prone, single point of failure; split per screen/component and move journeys into tests

Cheat Sheet

Page Object Basics

Shape:

class LoginPage {
  readonly path = '/login';
  private email  = (p: Page) => p.getByLabel('Email address');
  private submit = (p: Page) => p.getByRole('button', { name: 'Sign in' });
  async goto(page: Page) { await page.goto(this.path); }
  async signIn(page: Page, email: string, pw: string) {
    await this.email(page).fill(email);
    await this.submit(page).click();
  }
}
// test: no selector appears — scenario reads as intent

Guarantee: markup change → one file edit, forty tests untouched.

Design rules:

  • one object per screen/component (small enough to review in one sitting)
  • methods named by user intent; end where a user would pause (no mega-journeys)
  • acts + queries only: total() returns data, placeOrder() acts
  • waits for action completion inside; scenario expectations in the test
  • locators follow the convention (role/label → testid) — enforcement point is the object

PR checklist:

□ screen's selectors only in its object □ intent names □ no expectations in objects
□ internal waits = completion only □ no cross-screen journeys in one method

Anti-patterns: asserting in objects (loses scenario context, hard-codes assumptions) · god object (everything in one file) · mechanics names (clickSubmitButton2).

Continue Learning

Before You Start

Related Reading