Imported from ella79/agentic-playwright-suite (
.claude/skills/playwright-pageobject-testing/SKILL.md). Install upstream withnpx skills add ella79/agentic-playwright-suite --skill playwright-pageobject-testing. Copyright stays with the author.
Page Object Testing Skill
Outcome
Tests that read like the user's intent, break only when the product breaks, and can be changed by someone who has never opened this repo before.
Before writing anything
Confirm these, or ask. Guessing one of them is what produces a case that has to be rewritten:
- The user flow and what counts as success
- The entry point: which page object already reaches it, or whether a new one is needed
- The data the case needs, and whether the
uniqueAccountfixture covers it - Which assertion is the business-critical one, as opposed to a sync check
- Whether this is coverage or a correction, because the suite is capped at twenty per suite
Defaults, not worth asking about: Chromium at 1920x1080, base URL from playwright.config.ts,
per-test account through the fixture.
Working order
Plan, then page object, then spec. A spec cannot be written against locators that do not exist yet, and a page object written after the spec ends up shaped by the spec instead of by the page.
- Read the plan. The case ID, its scenario, its expectation. If there is no plan, there is no case to write.
- Decide the boundary. One page object per URL-addressable page, one component object per modal. Shared chrome belongs in the base, not copied.
- Design the method, not the click. Task-oriented names, awaiting the state transition the user would wait for. Return a value only when the spec asserts on it.
- Add the locator.
readonly, assigned in the constructor, semantic first, a CSS fallback only with an inline reason. - Register the fixture. A page object without a fixture in
testFixtures.tscannot reach a spec. - Write the case. Arrange, act, assert. The headers, the fixtures in the signature, the title in the user's language.
- Validate.
yarn stylefix && yarn typecheck && yarn lint, then the single case, then the suite. A case that only passes on the second attempt is a failing case.
Locator Priority
getByRole(role, { name }): the default. Matches what a user and a screen reader see.getByLabel: form controls with a real label.getByPlaceholder: form controls with only a placeholder (common in this app).getByText: non-interactive content, and elements the app renders as<a>withouthref(those have no link role, sogetByRole("link")will not find them).getByTestId: thedata-qaattribute, mapped viatestIdAttributein the config. Automation Exercise shipsdata-qaon most form controls, so this is a first-class option here, not a fallback.- CSS as a last resort, and only with an inline comment explaining why nothing above works.
// No accessible name: icon-only control with no aria-label
readonly removeItemButton: Locator = row.locator(".cart_quantity_delete");
Page Object Structure
Two bases:
BaseAppPage: a URL-addressable page. Owns navigation (goto…Page()) and shared chrome (header, consent banner).BaseComponentPage: a modal or dialog. Scoped to arootlocator; every child locator is resolved inside that root so two open dialogs can never cross-match.
export class CartPage extends BaseAppPage {
readonly proceedToCheckoutButton: Locator;
constructor(page: Page) {
super(page);
this.proceedToCheckoutButton = page.getByText("Proceed To Checkout");
}
async gotoCartPage(): Promise<void> {
await this.goto(url.cart);
}
async proceedToCheckout(): Promise<void> {
await this.proceedToCheckoutButton.click();
}
}
Rules:
- Locators are
readonlyproperties assigned in the constructor. Never inline a locator in a spec. - Method names describe the user's task (
addToCart), not the mechanics (clickAddButton). - A method that navigates to another page returns that page object; a method that opens a modal returns the modal's component object.
- Export every class from
utils/pageObjects/index.ts, and register it as a fixture inutils/fixtures/testFixtures.ts.
Artefacts
One stem names every artefact a feature owns. Each suite owns its plan and its spec; everything
under utils/ is shared by both.
| Artefact | Owner | Path | Shape |
|---|---|---|---|
| Functional plan | Functional suite | specs/test-plans/<area>-test-plan.md |
references/test-plan-template.md |
| Functional spec | Functional suite | tests/<area>/<name>.spec.ts |
Below, under Spec Structure |
| Visual plan | Visual suite | specs/vr-test-plans/<area>-vr-test-plan.md |
The visual skill |
| Visual spec | Visual suite | vr-tests/<area>.vr.spec.ts |
The visual skill |
| Baselines | Visual suite | Next to the visual spec | Chromium on Linux |
| API plan | API suite | specs/api-test-plans/<area>-api-test-plan.md |
Same shape as a functional plan, Method + Endpoint in place of Page URL/Page Object |
| API spec | API suite | api-tests/<area>.api.spec.ts |
No page, no setup dependency — the request fixture only |
| API client | API suite | utils/apiClients/<area>ApiClient.ts |
One class per resource area, wraps request, mirrors a page object's shape |
| Page object | Both | utils/pageObjects/<area>/<name>Page.ts |
Above, under Page Object Structure |
| Component object | Both | utils/pageObjects/shared/<name>Modal.ts |
Root-scoped, BaseComponentPage |
| Fixture | Both | utils/fixtures/testFixtures.ts |
One per page object |
| Status | Both | specs/STATUS.md |
Counts, findings, open decisions |
The file is camelCase, the class PascalCase with the same stem: cartPage.ts exports CartPage. A
modal one area uses may live in that area's folder; shared/ is for the ones two areas reach.
A page object grows from either side: a locator only a capture needs belongs here exactly like the rest. This file is the only place their standard lives; the visual skill covers what is specific to a screenshot.
One chain has to hold, because the published dashboard reads it: the plan row supplies the whole
test title, <ID>: <Name>, with both halves copied from the row character for character, the spec
carries the // spec: header pointing at that plan, STATUS.md counts the case, and a visual case
matches its baseline name. Break a link and a result stops tracing back to its plan.
Spec Structure
// spec: specs/test-plans/cart-test-plan.md
// seed: specs/seed.spec.ts
import { expect, test } from "../../utils/fixtures/testFixtures";
test.describe("Cart Page", () => {
test("TC-05: Adding a product from the detail page puts it in the cart", async ({
productDetailPage,
cartPage,
}) => {
// ...
});
});
- The
// spec:header is mandatory. It is how coverage is traced back to a plan. - The
// seed:header names the seed spec the environment is bootstrapped from,specs/seed.spec.ts. Both headers are the format Playwright's own generator agent emits. - Page objects arrive as fixtures, named in the test signature. Never
new SomePage(page)in a spec, and never alet poassigned inbeforeEach: the first repeats construction in every test, the second shares mutable state across them. Playwright's fixtures documentation is explicit that fixtures replace both. - A
beforeEachis for navigation shared by every case in the file, and it takes the page object as a fixture too. - Separate
test()blocks for different scenarios or different page states.test.stepfor several properties of the same scenario on the same page. Do not split read-only checks of one page into many tests that each re-runbeforeEachand reload it. test.describegroups related scenarios.test.stepmarks distinct phases within one scenario, not every action. A step is worth adding when a failure would otherwise leave the reader guessing which phase broke, typically an arrange phase followed by the behaviour under test. A scenario that is one action and its verification takes no steps: the step title would only repeat the test title in the report.- Test titles state the behaviour being verified, in the user's language.
TC-05: Cart shows the added product, notTC-05: Test cart. Sentence case: the first letter capitalised, the rest as a sentence would read. The plan'sNamecell carries the same string, so this is one decision made once, not two. test.describenames the feature the file covers, as<Feature> Page:Cart Page,Product Detail Page. Every functional spec in this suite follows it; a spec that does not is the finding.
Fixtures
Everything a test needs arrives through utils/fixtures/testFixtures.ts.
| Fixture | Provides |
|---|---|
homePage, cartPage, and the rest |
One page object per surface, built for the tests that name it |
uniqueAccount |
A throwaway account created through the API, signed in through the login form, deleted through the API afterwards |
page |
Playwright's page with ad, analytics and consent hosts aborted; in the cart specs, signed into an account of its own |
guestPage |
A signed-out page with the same blocking as page, for a case that compares a guest with the signed-in visitor |
request |
Playwright's own API request context, base-URL'd the same as page — API specs only |
A new page object gets a fixture in the same commit that adds the class. Fixtures are on demand, so an unused one costs nothing.
Login and Guest: two ways to be signed in
Every functional and visual project depends on setup (utils/setup/login.setup.ts) and starts
already signed in as a shared account. That project runs once per suite run: it registers the
account through POST /api/createAccount if it does not already exist (tolerant of "already
exists", so it never needs provisioning by hand), signs in through the real login form — the only
way to get a session at all, since the login API answers no Set-Cookie — and saves the result as
storageState. A case that only needs "some logged-in user" as a precondition inherits it for free.
That default is wrong for exactly one kind of case: one that proves something about the boundary
between signed-in and not, where starting already authenticated either breaks the case outright
(uniqueAccount's own sign-in navigates to the login page, which a live session redirects away from)
or defeats the point of it (a guard test that only means something if it starts unguarded). A case
where the whole file needs Guest opts out with:
test.use({ storageState: { cookies: [], origins: [] } });
at the top of the file. A case that needs both identities at once — comparing what a guest sees
against what a signed-in visitor sees, in one test — cannot use test.use() for that, since it sets
the one context the whole test runs in; instead it takes the guestPage fixture:
test("TC-02: ...", async ({ homePage, guestPage }) => {
const guestHome = new HomePage(guestPage);
// ...assert against homePage and guestHome
});
uniqueAccount still does its own registration and its own teardown; what changed is that it is no
longer the only way to reach a signed-in state, only the way to reach a signed-in state that a test
itself proves the creation of. A test that deletes its own account sets account.deleted = true so
teardown does not try again.
Every plan states which of the two its cases assume, in its Metadata table's Precondition row:
Login for the shared signed-in default, Guest for a plan (or a single case inside one) that opts
back out. login-test-plan.md and signup-test-plan.md are Guest for the whole file; TC-02 in
home-test-plan.md and TC-19 in cart-test-plan.md take guestPage for their guest half. Everything else is Login.
The cart belongs to the account, so a case that changes it cannot share the shared account: the
parallel CI jobs emptied each other's cart through it (decision #7 in docs/decisions.md). The
page fixture gives every case in the cart, checkout, payment and confirmation specs,
functional and visual, an account of its own: created through the API, signed in through the login
form, deleted through the API afterwards. The specs do nothing for it. A new spec file that changes
the cart is added to CART_SPECS in testFixtures.ts.
Cleanup belongs to the fixture, never to a trailing step. A test.step at the end of a case
that removes what the case created does not run when an earlier step fails: Playwright skips the
rest, and the data stays behind. The fixture's teardown runs either way.
Decision points
- Page object exists? Extend it with a cohesive method. Otherwise create one in the area folder and register its fixture in the same change.
- Behaviour shared across pages?
BaseAppPagefor chrome and navigation,BaseComponentPagefor anything root-scoped. Otherwise keep it local. - Where does the assertion go? The business outcome in the spec. A sync check that a method needs before it returns may live in the page object.
- Coverage or correction? Coverage against a full cap is a swap decision, and that belongs to the manager.
Assertions
- Web-first matchers only:
toBeVisible,toHaveText,toHaveURL,toHaveCount. They retry. - Assert observable outcomes, not implementation.
await expect(page).toHaveURL(url.checkout)is a real assertion; checking that a click handler ran is not. - One scenario, one reason to fail. If a test needs five unrelated assertions it is two tests.
Nothing exists twice
Duplication is checked at every level, and the test is always the same: does this cover ground that is already covered, or does it merely look similar?
| Level | A defect | Not a defect |
|---|---|---|
| Case | Two cases exercising the same interaction on different data | The same flow asserted at a different state, such as empty against populated |
| Plan | Two plans covering one area, or an area split without a reason | One functional plan and one visual plan for the same area: they answer different questions |
| Spec | Two spec files for one area of one suite | cart.spec.ts and cart.vr.spec.ts, which belong to different suites |
| Class | Two page objects for the same page | Two page objects for two pages that happen to share markup |
| Method | Two methods reaching the same state by the same route | An overload that takes different input to reach a different state |
| Locator | The same element exposed twice under two names in one class | The same selector in two classes, when the markup genuinely appears on two pages |
The last row is the one that is misread most often. #cart_info tbody tr is modelled by both the
cart page and the checkout page, because the application renders that table on both; merging them
would couple two pages that are free to diverge.
Anti-Patterns
| Anti-pattern | Why it is rejected |
|---|---|
waitForTimeout |
Arbitrary, too short under CI load, too slow otherwise. Wait on a condition |
| Inline locator in a spec | The next locator change then has to be made in N places |
test.skip() for a known bug |
Hides intent. test.fixme() says "this should pass and does not" |
nth(0) to resolve ambiguity |
Couples the test to DOM order. Scope to a container instead |
new SomePage(page) in a spec |
The page object is a fixture. Name it in the signature instead |
let po assigned in beforeEach |
Mutable state shared across tests, and no teardown. Use the fixture |
| Asserting on ad or consent content | Third-party, changes without notice, not our product |
| A test that only passes on retry | That is a failing test with extra steps |
Vendor documentation
Read the source rather than repeating it here when a rule needs its rationale. references/playwright-best-practices.md maps each piece of that guidance onto this repository and states where it departs, which the vendor pages cannot.
- Best practices
- Locators
- Assertions
- Fixtures, the shape this repository's page objects follow
- Page object model