Vanilla Playwright with Page Object Model: A Clean Setup
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.
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:
- Assertions stay in
*.spec.ts. Page objects never callexpect(). - Interactions and waits stay in page objects. Specs read like a manual script.
- User-facing locators first.
getByRolebefore CSS, XPath last. - Don't hide multi-step journeys behind opaque helpers — thin factory navigation is the only allowed shortcut.
- 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]
getByRole('button', { name: 'Login' })getByText,getByLabel,getByPlaceholdergetByTestIdwhen role/label/text genuinely can't target it ::dont[Avoid these]- CSS chains and XPath (
page.locator()is the last resort) .nth()/.first()to paper over ambiguitygetByTestIdwhengetByRolealready works :::
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]
- "should navigate to Products page" — checks the landmark buttons
- "should show Cart heading from Cart tab" ::dont[Name oversells]
- "should show products list" — implies table rows it never checks
- "should complete checkout" — implies an action and an output :::
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]
- Domain folders, one per feature
- One
pageFactoryownsgoto()+ construction - Composition: util functions + component objects
- Assertions only in specs; user-facing locators ::dont[The setup to avoid]
- A generic
pages/dump - A separate navigation helper class
- A deep
BasePageinheritance tree expect()leaking into page objects; CSS/XPath everywhere :::
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!