The Page Object Model (Playwright + TypeScript, Ch.4)

Published: (June 8, 2026 at 09:11 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

In Chapter 3 we learned to

locate and assert cleanly. But look at our login test from Chapter 2 — the

selectors and the steps still live inside the test. Write five tests that need

a logged-in user and you’ll copy that block five times. Change the login form and

you’ll edit it five times.

The Page Object Model (POM) fixes that: one class per screen that owns its

locators and the actions a user can take there. Tests then speak in behavior.

Code for this chapter is tagged ch-04 in the repo:

https://github.com/aktibaba/playwright-qa-course — see

src/pages/LoginPage.ts and src/tests/ui/login.spec.ts.

Build the Page Object

A Page Object is just a class. It takes the page in its constructor, exposes

locators as fields, and exposes actions as methods:

// src/pages/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";

export interface Credentials {
  email: string;
  password: string;
}

export class LoginPage {
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByPlaceholder("Email");
    this.password = page.getByPlaceholder("Password");
    this.submit = page.getByRole("button", { name: "Login" });
  }

  async goto(): Promise {
    await this.page.goto("/#/login");
    await expect(this.submit).toBeVisible();
  }

  async submitCredentials({ email, password }: Credentials): Promise {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }

  async loginAs(credentials: Credentials): Promise {
    await this.goto();
    await this.submitCredentials(credentials);
  }
}
Enter fullscreen mode


Exit fullscreen mode

Notice the design choices:

Locators are defined once, in the constructor — the only place that knows the page’s markup.

  • Methods are named after intent (loginAs), not mechanics.

goto() waits for the form to be ready, so callers never have to.

  • It’s plain TypeScript — Credentials makes the call sites self-documenting.

    Use it: tests that read as behavior

// src/tests/ui/login.spec.ts
import { test, expect, request as apiRequest } from "@playwright/test";
import { LoginPage } from "@pages/LoginPage";
import { env } from "@utils/env";

const SEED_USER = { email: "playwright@test.io", password: "Password123!" };

test.describe.configure({ mode: "serial" });

test.describe("Login (Page Object)", () => {
  test.beforeAll(async () => {
    const ctx = await apiRequest.newContext();
    await ctx.post(`${env.apiURL}/test/reset`); // ensure the seed user exists
    await ctx.dispose();
  });

  test("a seeded user can log in", async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.loginAs(SEED_USER);

    await expect(page.getByRole("link", { name: "New Article" })).toBeVisible();
    await expect(
      page.getByRole("navigation").getByText("playwright"),
    ).toBeVisible();
  });
});
Enter fullscreen mode


Exit fullscreen mode

The test body is now three readable lines. The @pages/LoginPage import works

because of the paths alias we set up in Chapter 2 — no ../../../ chains.

Where do assertions go?

A useful rule: Page Objects model the page; tests make the claims. Keep

expect about business outcomes (New Article is visible → we’re logged in) in

the test. The one exception is a readiness wait inside an action — like goto()

asserting the form rendered — which is about the action being complete, not about

what the test is verifying.

A real wrinkle: test data and a cross-project race

This test logs a real seeded user in, so it needs the known seed data present —

hence the reset in beforeAll. But our api project also resets the database,

and if an API reset fires while this UI test is mid-login, login fails

intermittently. (I hit exactly this: ~7 of 10 runs failed.)

The honest stopgap for now is to make the ui project run after the api

project, so their database access never overlaps:

// playwright.config.ts (ui project)
{
  name: "ui",
  testDir: "./src/tests/ui",
  dependencies: ["api"],   // api finishes before any UI test starts
  use: { baseURL: env.webURL, ...devices["Desktop Chrome"] },
}
Enter fullscreen mode


Exit fullscreen mode

That’s a blunt instrument — we serialized two whole projects to dodge a data race.

The right fix is giving each test its own isolated data (a fresh user per

test, created through the API), which we build in Part 4. I’m leaving the stopgap

in so you can see the problem the isolation layer is designed to solve.

What POM bought us

One place to fix. The login form’s markup is known only to LoginPage.

Readable tests. loginAs(user) says what, not how.

Reuse. Every future test that needs a logged-in user calls one method.

But we still wrote new LoginPage(page) by hand, and the test still hard-codes

SEED_USER and resets the DB itself. Those are the next dominoes.

Next up

Chapter 5 — Forms, tables, and dialogs: we put the Page Object Model to work on

richer interactions (the article editor, tag inputs, confirmation dialogs), and

grow a small family of Page Objects. Then in Part 2 we make them effortless to use

by turning them into fixtures. Tag: ch-05.

Following along? Star the repo

and tell me: do you put assertions inside your Page Objects, or keep them in tests?

0 views
Back to Blog

Related posts

Read more »