Vanilla Playwright with Page Object Model: A Clean Setup

profile
Mike Sell
crashnaut.com

Vanilla Playwright with Page Object Model: A Clean Setup

Not every team needs BDD. If only engineers write your tests, the Cucumber/Gherkin layer is overhead you can skip — you keep the same Page Object Model underneath, just without the translation step. (If you do want the Gherkin layer, I wrote about that in Playwright BDD; both setups share this same architecture.)

Here's the whole thing in one picture: specs and page objects both meet at a single pageFactory — the factory owns navigation and hands the right page object back to the spec.

Tests Code login.spec.ts products.spec.ts smoke.spec.ts pageFactory loginPage productsPage cartPage
Specs depend only on the pageFactory; the factory owns navigation and constructs the right page object.

This is the vanilla version, drawn from a real production suite. It's deliberately boring in the right places. The whole design comes down to a few rules about where code is allowed to live.

The shape of it link

Two things are deliberately absent: there's no generic pages/ dump (page objects live in feature folders), and there's no navigationPage.ts (the factory handles navigation). Organise by domain, not by file type.

The layers, and the one rule that matters link

Everything hangs off a single principle: assertions live in specs; interactions live in page objects. The full set of laws are all corollaries of that boundary:

  1. Assertions stay in *.spec.ts. Page objects never call expect().
  2. Interactions and waits stay in page objects. Specs read like a manual script.
  3. User-facing locators first. getByRole before CSS, XPath last.
  4. Don't hide multi-step journeys behind opaque helpers — thin factory navigation is the only allowed shortcut.
  5. Avoid explicit timeout: on waits and assertions; use the config defaults.

pageFactory owns navigation link

One class constructs page objects and handles goto(). No separate navigation helper, no BasePage.navigate(). Each method navigates, waits for load, and returns the page object — and takes a skipGoto flag for when you've already arrived (e.g. you clicked a nav link to get there).

In a spec, navigation is always await pageFactory(page).somePage() — never a raw page.goto().

Page objects: composition, not inheritance link

There is no BasePage. Page objects hold a page reference, expose locators and actions, and pull shared behaviour from plain util functions and small component objects (like a top nav bar) rather than from a class hierarchy. Inheritance trees get tangled fast; composition stays flat.

Method names follow fixed prefixes so a page object is scannable at a glance: get* returns a Locator, click* clicks, fill* fills inputs, waitFor* waits for state.

Locators: reach for the accessible ones first link

This is where most flaky suites are won or lost. Playwright's locator priority is the policy: target what the user perceives, not how the markup happens to be built today.

:::compare ::do[Reach for these first]

Real UIs fight back, and the page object is exactly where you absorb that. When icon-font glyphs pollute an accessible name and break a plain getByRole('link', { name }), combine a role query with a text filter — and leave a comment so the next person knows why:

Specs read like a manual script link

Because all the machinery lives below them, specs end up short and narrative. Page-object variables are named onXxxPage, so a test reads as "on the dashboard page, click products; on the products page, the buttons are visible":

Note the skipGoto: true: the click already landed us on the page, so the factory constructs the page object without re-navigating.

Log in once, via the API link

Logging in through the UI before every test is slow and turns an auth hiccup into a hundred red specs. So the default path logs in through the API — the Playwright equivalent of Cypress's cy.login() — by extending the base test with an auth fixture:

loginViaApi does the unglamorous real-world work: GET the login page, scrape the CSRF token, POST the credentials, and you've got a session — no UI, no waiting. Specs that genuinely test the login form import @playwright/test directly instead, and the handful that test API login itself opt out with test.use({ apiLogin: false }).

Smoke tests: name them for what they actually assert link

Smoke specs are read-only "did the page load?" checks. The trap is letting the title promise more than the assertions deliver — a green "should show products list" that only checks a heading is a lie waiting to mislead someone during an incident.

:::compare ::do[Name fits the assertion]

When the same flow must hold for several user variants (regions, roles), don't copy-paste specs — loop over a parametrised list of cases and tag each with its variant, so one spec body covers them all.

Configuration, honestly link

The config is small on purpose: base URL from an env var, Chromium on a desktop-Chrome profile, fully parallel, retries and artifacts only on CI.

Trace-on for local runs is the quiet hero here — when something fails on your machine, the trace viewer already has the whole story.

This setup vs the one you usually inherit link

:::compare ::do[This setup]

Wrapping up link

Vanilla Playwright with a disciplined POM gives you fast, readable, maintainable E2E tests without a BDD layer. The architecture is almost entirely about boundaries: navigation in the factory, interactions in page objects, assertions in specs, accessible locators throughout. Hold those lines and the suite stays pleasant to work in as it grows.

If you want the whole environment — Node, pnpm, browsers — set up in one go, I covered that in Nix Flakes for SDET Setup.

Outgoing links

Feel free to update this blog post on GitHub, thanks in advance!

Share this post

Support me

I appreciate it if you would support me if you have enjoyed this post and found it useful, thank you in advance.

Buy Me a Coffee at ko-fi.com