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.)
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
test/├── e2e/│ ├── login/ # UI-login specs (real form + 2FA)│ └── smoke/ # read-only page-load checks├── page-objects/│ ├── auth/loginPage.ts│ ├── products/productsPage.ts│ ├── …/ # one folder per feature domain│ ├── topNavBar.ts # shared component object│ └── pageFactory.ts # owns goto() + page construction├── fixtures/│ └── test.ts # extended `test` with an auth session├── test-data/ # users, fixtures, parameterized cases└── utils/ # plain helper functions
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). Organize by domain, not by file type.
The layers, and the one rule that matters
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 flows 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
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. It also takes a skipGoto flag for when you've already arrived (e.g. you clicked a nav link to get there).
import type { Page } from "@playwright/test"import { LoginPage } from "./auth/loginPage"import { ProductsPage } from "./products/productsPage"import { waitForPageToLoad } from "../utils/waitForPageToLoad"export interface GotoOptions {skipGoto?: boolean}class PageFactory {constructor(private readonly page: Page) {}private async navigate(path: string, options?: GotoOptions) {if (options?.skipGoto) returnawait this.page.goto(path)await waitForPageToLoad(this.page)}async loginPage(options?: GotoOptions): Promise<LoginPage> {await this.navigate("/login", options)return new LoginPage(this.page)}async productsPage(options?: GotoOptions): Promise<ProductsPage> {await this.navigate("/products", options)return new ProductsPage(this.page)}}export const pageFactory = (page: Page): PageFactory => new PageFactory(page)
waitForPageToLoad is a plain util, not magic. goto() already waits for the load event, so the helper's job is to wait for whatever your app shows when it's actually usable:
// utils/waitForPageToLoad.tsimport type { Page } from "@playwright/test"export async function waitForPageToLoad(page: Page): Promise<void> {await page.waitForLoadState("domcontentloaded")// Swap in your app's own "ready" signal: a main landmark, a heading, a spinner going away.await page.getByRole("main").waitFor({ state: "visible" })}
In a spec, navigation is always await pageFactory(page).somePage(), never a raw page.goto().
Page objects: composition, not inheritance
There is no BasePage. Page objects hold a page reference, expose locators and actions, and pull shared behavior from plain util functions and small component objects (like a top nav bar) rather than from a class hierarchy. (Inheritance trees get tangled fast.)
export class ProductsPage {readonly topNavBar: TopNavBarconstructor(private readonly page: Page) {this.topNavBar = new TopNavBar(page)}getAddToCartButton(): Locator {return this.page.getByRole("button", { name: "Add to Cart" })}getViewCartButton(): Locator {return this.page.getByRole("button", { name: "View Cart" })}}
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
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.
getByRole('button', { name: 'Login' })getByText,getByLabel,getByPlaceholdergetByTestIdwhen role/label/text genuinely can't target it
- 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:
// Icon-font noise in the header breaks a plain name match.getNavLink(name: string): Locator {return this.page.getByRole('link').filter({has: this.page.getByText(name, { exact: true }),})}
Specs read like a manual script
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":
import { expect, test } from "../../fixtures/test"import { pageFactory } from "../../page-objects/pageFactory"test.describe("Products", { tag: ["@smoke", "@products"] }, () => {test("should navigate to Products page", async ({ page }) => {const onDashboardPage = await pageFactory(page).dashboardPage()await onDashboardPage.topNavBar.clickProducts()const onProductsPage = await pageFactory(page).productsPage({skipGoto: true,})await expect(onProductsPage.getAddToCartButton()).toBeVisible()await expect(onProductsPage.getViewCartButton()).toBeVisible()})})
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
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:
export const test = base.extend<AuthFixtures>({apiLogin: [true, { option: true }],loginUser: [getAdminUser(), { option: true }],page: async ({ page, apiLogin, loginUser }, use) => {if (apiLogin) await loginViaApi(page.request, loginUser)await use(page)},})
loginViaApi does the unglamorous real-world work: GET the login page, scrape the CSRF token, POST the credentials, and you've got a session without touching the UI. 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
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.
- "should navigate to Products page" (checks the landmark buttons)
- "should show Cart heading from Cart tab"
- "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 parameterized list of cases and tag each with its variant, so one spec body covers them all.
Configuration, honestly
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.
export default defineConfig({testDir: "./test/e2e",fullyParallel: true,retries: isCI ? 2 : 0,reporter: isCI ? [["html", { open: "never" }], ["list"]] : [["list"]],use: {baseURL: process.env.BASE_URL,trace: isCI ? "on-first-retry" : "on",screenshot: isCI ? "only-on-failure" : "off",},projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],})
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
- Domain folders, one per feature
- One
pageFactoryownsgoto()+ construction - Composition: util functions + component objects
- Assertions only in specs; user-facing locators
- A generic
pages/dump - A separate navigation helper class
- A deep
BasePageinheritance tree expect()leaking into page objects; CSS/XPath everywhere
Wrapping up
Vanilla Playwright with a disciplined POM gives you E2E tests that are quick to read and change, 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.
Part 2: once the suite grows past a handful of specs, Playwright Page Factory + Composition builds on this setup with shared UI as composed components, and a pageFactory fixture so specs never wire the factory by hand.
For a runnable starting point, see crashnaut/playwright-pom-boilerplate on GitHub. It's a simpler, earlier version of this layout.
If you want the whole environment (Node, pnpm, browsers) set up in one go, I covered that in Nix Flakes for SDET Setup.