Skip to main content

← Crash log Run 008

Playwright Page Factory + Composition: Scaling a Vanilla POM

Combine a page factory, composition pattern, skipGoto navigation, and a Playwright fixture — so specs stay thin as the suite grows.

If you already have a vanilla Playwright + Page Object Model setup — one pageFactory that owns navigation, page objects that own interactions, assertions only in specs — you have the foundation. The next question, once the suite grows past a handful of smoke tests, is how to keep that discipline without every spec file importing and instantiating the factory by hand.

This is Part 2. It picks up where Part 1, Vanilla Playwright with Page Object Model, left off. The additions are deliberate and small:

  1. Composition — shared UI regions (nav bars, modals, sidebars) as components composed inside page objects.
  2. Conditional navigation — skipGoto when the browser is already on the route.
  3. Fixture-wrapped factory — inject pageFactory into every test so specs never call pageFactory(page) themselves.

None of this requires BDD, BasePage, or a second navigation class. It's the same architecture, tightened for scale.

The problem at scale

Early on, this is fine:

It works. But as you add dozens or hundreds of specs, the repetition adds up: same import, same (page) wiring, easy to forget skipGoto after an in-app navigation click. The factory is the front door to your POM — it should be as automatic as page itself.

Meanwhile, shared UI like a top navigation bar starts getting copy-pasted across page objects, or worse, accessed directly from specs. That's where composition earns its keep.

Folder structure (target state)

Page objects map to routes or features. Components map to reusable regions that appear on multiple pages. The factory creates page objects only — components are constructed inside page constructors.

What's still absent on purpose: navigationPage.ts, BasePage, and a generic pages/ dump.

Page factory: navigation + construction

The factory owns goto, waits for load (waitForPageToLoad is the small util from Part 1), and returns an initialized page object. Every method accepts optional GotoOptions:

When to use skipGoto: true

Use it when a prior step already put the browser on that URL:

  • Clicked a nav link (topNavBar.clickProducts())
  • Submitted a form that redirected
  • Logged in and landed on the home route

Without skipGoto, the factory calls goto again — slower, and sometimes resets state you were trying to test.

Composition: components inside page objects

A page object represents a route or feature (ProductsPage, LoginPage). A component represents a reusable region (TopNavBar, a modal, a sidebar panel).

Compose in the page constructor:

Specs reach shared UI through the page — never new TopNavBar(page) in a spec:

When to extract a component

  • The same region appears on two or more pages (classic: authenticated top nav).
  • A single page object file is getting unwieldy — split by visible region, not arbitrary layers.

Do not extract components for markup that only appears on one page. That's premature abstraction.

Scoping locators inside components

When markup gives you a stable container, scope everything to it:

Real apps fight back. When icon-font glyphs pollute accessible names in a header, page-level getByRole + filter({ has: getByText(...) }) inside the component is fine — with a comment explaining why.

Components follow the same laws as page objects: no expect(), get* / click* / fill* prefixes, locators live inside the component.

Wrap the factory in a Playwright fixture

Once you have more than a few specs, inject the factory the same way you inject page:

Note the import alias: pageFactory as createPageFactory. The fixture key and the factory function share a name — aliasing keeps the fixture file unambiguous.

Specs destructure { pageFactory } and never import pageFactory directly:

Keep { page, pageFactory } when you assert on URL or other browser-level state:

Pairing with API login

If your default fixture also logs in via API (the Playwright equivalent of cy.login()), UI-login specs opt out in the describe block — but still get pageFactory:

One import path for every spec. No special-case @playwright/test import for login folders unless you have a reason.

Sub-pages without factory methods

Not every view needs a pageFactory entry. List → edit flows can return a sub-page object from an action on the list page:

Reserve factory methods for routes you goto directly. Sub-views reached only through in-app navigation can be constructed by page object actions.

The laws (unchanged, still worth repeating)

  1. Assertions only in *.spec.ts. Page objects and components never call expect().
  2. Interactions and waits in page objects / components. Specs read like manual scripts.
  3. Navigate via pageFactory.xxxPage() — through the fixture, not raw page.goto() in specs.
  4. User-facing locators first — getByRole before CSS; page.locator() last.
  5. Don't hide whole journeys in opaque helpers; thin factory navigation is the allowed shortcut.

This vs what you usually inherit

This setup
  • pageFactory fixture in every spec
  • Composition for shared UI (TopNavBar, modals)
  • skipGoto after in-app navigation
  • Feature folders + components/ for shared regions
  • Factory methods only for direct goto routes
Common drift
  • pageFactory(page) imported in every file
  • Copy-pasted nav locators across page objects
  • Redundant goto after every nav click
  • navigationPage.ts alongside pageFactory.ts
  • expect() leaking into page objects

Wrapping up

Page factory + composition is not a different architecture — it's the same vanilla POM with clearer boundaries as the suite grows. The factory stays the single front door. Components absorb shared UI. skipGoto respects navigation you've already done. The fixture removes boilerplate so every new spec starts with { pageFactory } and reads like a script.

If you're setting up from scratch, start with Vanilla Playwright with Page Object Model. If you're scaling an existing suite, add the fixture first, then extract components when you see the same region on a second page.