RizTech Academy logo
RizTech Academy
Web Automation with PlaywrightLesson 4 of 635 min

Page objects and keeping tests maintainable

Your first few tests are fine written straight through. But a real suite has dozens, and many touch the same screens — the login form, the transactions table. If every test spells out how to log in, the day the login form changes you edit every test. The Page Object Model is the standard way to stop that: put the knowledge of how to use a screen in one place, so a change to the screen is a change in one file. This lesson is page objects and keeping a suite maintainable.

The problem: duplication across tests

Look at what several AgentPay tests need to do before they can test anything: go to /login, fill email, fill password, click sign in, wait for the dashboard. Written inline in every test, that is five lines copied everywhere — and the AgentPay login form's labels or button text appear in a dozen files. Change "Sign in" to "Log in" and a dozen tests break and must each be edited. Duplication is the enemy of a maintainable suite (the same lesson the practices module teaches for all code).

The course already applied the first fix: a helper. AgentPay's loginAs centralises the login flow:

export async function loginAs(page: Page, email: string, password: string) {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}

Now the login knowledge lives in one place. A page object is this idea, organised: one class (or module) per screen, holding that screen's locators and the actions you can do on it.

A page object

A page object wraps a page and exposes meaningful operations, hiding the locators inside:

export class LoginPage {
  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async signIn(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }

  error() {
    return this.page.getByRole('alert');
  }
}

And a TransactionsPage for the table:

export class TransactionsPage {
  constructor(private readonly page: Page) {}
  async open() { await this.page.getByRole('link', { name: 'Transactions' }).click(); }
  async filterByStatus(status: string) { await this.page.getByLabel('Status').selectOption(status); }
  async nextPage() { await this.page.getByRole('button', { name: 'Next' }).click(); }
  rows() { return this.page.getByRole('row'); }
  statusCells() { return this.page.locator('tbody tr td:nth-child(6)'); }
}

A test now reads at the level of what the user does, not how:

test('filters to failed transactions', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.signIn('admin@agentpay.test', 'admin123');

  const txns = new TransactionsPage(page);
  await txns.open();
  await txns.filterByStatus('failed');
  await expect.poll(async () => (await txns.statusCells().allInnerTexts()).every((s) => s === 'failed')).toBe(true);
});

When the login form changes, you edit LoginPage once and every test keeps working. That is the whole payoff.

What belongs in a page object — and what does not

The discipline that keeps page objects useful:

  • Put in: the screen's locators and the actions on it (signIn, filterByStatus, nextPage), and optionally small accessors that return locators (error(), rows()).
  • Keep out: assertions. A page object describes the screen and how to use it; the test decides what to assert. Do not put expect(...) inside page-object methods (a signIn that also asserts the dashboard appeared forces that expectation on every caller). Return locators and let the test assert. (Helpers like loginAs that bundle a "log in and land on dashboard" precondition are a reasonable exception for setup, but a page object's core methods stay assertion-free.)
  • Keep it thin. A page object is a convenience layer over locators, not a place for clever logic. If it grows complicated, the screen probably needs splitting.

Don't over-build it

A caution the practices module echoes: do not build page objects before you need them. For a handful of tests, helper functions like loginAs are enough — that is why AgentPay uses helpers rather than a full page-object framework; the suite is small. Reach for page objects when a screen is used by many tests and the duplication is real. Building an elaborate object model for three tests is the same mistake as applying a design pattern where it was not needed: structure has a cost, and you pay it only when it earns its keep.

The principle underneath all of it is the one from the practices module: don't repeat yourself, and put each piece of knowledge in one place. Page objects are just that principle applied to UI tests — so that a change to a screen is a change in one file, and the suite stays cheap to maintain as it grows.

Check your work

The problem. Many tests touch the same screens; inline steps duplicate locators everywhere, so a screen change breaks and requires editing many tests. First fix: a helper (AgentPay's loginAs) centralises a flow.

Page object. One class/module per screen holding its locators and the actions on it (goto, signIn, filterByStatus, nextPage), hiding locators inside. Tests then read as what the user does; a screen change is edited in one file.

In vs out. Put in locators and actions (and small locator accessors). Keep assertions out — the test decides what to assert; page-object methods return locators. Keep objects thin — a convenience layer, not logic.

Don't over-build. Helpers suffice for a small suite (why AgentPay uses them); adopt page objects when a screen has many tests and real duplication. Premature structure is a cost, like an unneeded design pattern. The root principle: DRY — each piece of knowledge in one place.

Practice

  1. Write a LoginPage page object for AgentPay with goto, signIn, and an error() accessor.
  2. Rewrite two tests to use it and confirm they still pass; then change the button text in the app and fix only the page object.
  3. Write a TransactionsPage with open, filterByStatus, nextPage, and rows(); use it in a test.
  4. Take a page object that (wrongly) asserts inside a method and refactor the assertion back into the test; explain why.
  5. Argue when AgentPay's loginAs helper is enough and when you would switch to page objects.
  6. Give an example of over-building a page object, and what you would do instead.

Official documentation

Next: fixtures, setup and test data.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship