advanced20 min

Page Objects, Alternatives, and Test-Data Design

Encapsulating a page's locators and actions behind a class, the honest limits of that pattern, and designing test data that's independent between tests.

What you'll learn

  • Design a page object that encapsulates locators and actions for one page or component
  • Explain when a page object adds real value versus when it's unnecessary ceremony
  • Design test data that guarantees isolation between parallel or repeated test runs

Prerequisites

Explanation

A page object is a class that encapsulates a page's (or a reusable component's) locators and the actions available on it, so a test reads in terms of user intent rather than raw locator calls: class LoginPage { constructor(page) { this.page = page; } async signIn(user, pass) { await this.page.getByLabel("Username").fill(user); await this.page.getByLabel("Password").fill(pass); await this.page.getByRole("button", { name: "Sign in" }).click(); } } — a test then calls await loginPage.signIn(user, pass), reading as what the test is doing, not how the page's DOM happens to be structured. If the login form's markup changes later, the fix lives in exactly one place (the page object), not in every test that signs in.

The honest limit worth stating plainly: a page object is genuinely valuable specifically when a page or component's locators/actions are reused across multiple tests — the encapsulation pays for itself through that reuse. Wrapping a page object around a single locator used in exactly one test adds a layer of indirection with no real benefit, purely ceremony for its own sake. Alternatives exist for good reason: small, composable helper functions (async function fillAndSubmitLoginForm(page, user, pass) { ... }) achieve the same reuse without the class-based ceremony when a page object's full structure isn't warranted, and Playwright's fixture system (previous lesson) can itself provide a ready-to-use page object as a fixture, combining both patterns.

Test-data design for isolation means each test — especially when tests run in parallel, Playwright's default — must generate or use data that cannot collide with what another concurrently-running test is doing: a hard-coded email like "test@example.com" used by two parallel tests both trying to register a new account will race and one will fail with a "user already exists" error that has nothing to do with either test's actual subject. The fix is generating unique data per test run — combining the current timestamp with a random suffix to build an email like test-<timestamp>-<random>@example.com, or using a UUID — so every test's data is guaranteed distinct, regardless of how many tests run concurrently or how many times the suite has run before.

Example

A minimal page-object pattern and unique-test-data generation, modeled without a real browser.

class LoginPageModel {
  constructor(actions) { this.actions = actions; } // actions stands in for real Playwright locators
  signIn(username, password) {
    this.actions.push({ action: "fill", field: "username", value: username });
    this.actions.push({ action: "fill", field: "password", value: password });
    this.actions.push({ action: "click", target: "Sign in button" });
    return "signed in as " + username;
  }
}

const actions = [];
const loginPage = new LoginPageModel(actions);
console.log(loginPage.signIn("alice", "secret"));
console.log(actions.length); // 3 -- fill, fill, click, all recorded through ONE method call

function uniqueTestEmail(prefix) {
  return prefix + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "@example.com";
}
console.log(uniqueTestEmail("test") !== uniqueTestEmail("test")); // true -- two calls never collide

Try it yourself

Call uniqueTestEmail 3 times in a loop and confirm all 3 results are distinct (use a Set to check).

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Model a page object: write class SearchBarModel with a constructor(actions) storing the actions array, and a method search(query) that pushes {action:'fill', field:'search', value: query} then {action:'click', target:'Search button'} onto actions, and returns the string 'searched for ' + query.

Checks: search() records both actions and returns the correct summary · the recorded actions have the correct field and target values

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write generateUniqueEmails(prefix, count) returning an array of `count` email strings, each combining prefix with a distinct counter value (e.g. prefix + '-' + i + '@example.com' for i from 0 to count-1) -- ALL must be distinct from each other, modeling deterministic (not random-timing-dependent) unique test-data generation.

Checks: generates entirely distinct emails · follows the correct prefix-counter naming pattern · handles a count of 0

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Wrapping a page object class around a locator used in exactly one test -- this adds indirection with no reuse benefit; a page object earns its complexity through genuine reuse across multiple tests.
  • Hard-coding the same test data (like a fixed email) across multiple tests that might run in parallel -- concurrently-running tests both trying to use that identical, colliding data produce failures unrelated to what either test actually verifies.
  • Letting a page object's methods leak raw Playwright locator objects back to the test -- a well-designed page object exposes actions and outcomes (signIn, search), not its internal locators, keeping the encapsulation genuine.

Knowledge check

Knowledge check

1. When does a page object genuinely pay for its added structure?
2. Why does a hard-coded test email like "test@example.com" cause problems specifically when tests run in parallel?
3. What is a lightweight alternative to a full page-object class when reuse is needed but a class's structure isn't warranted?

Takeaway

A page object earns its structure through genuine reuse across multiple tests, not by default for every locator; test data must be generated to guarantee uniqueness per test run, since Playwright's default parallel execution means colliding shared data causes failures unrelated to what either test actually verifies.

Summary

A page object encapsulates a page's locators and actions behind methods reading as user intent — valuable specifically when reused across multiple tests, with helper functions or fixtures as lighter alternatives. Test data must be generated uniquely per test (timestamp+random, a UUID, or a deterministic counter) to avoid collisions between parallel or repeated test runs.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.