Every framework track in this tutorial eventually shows you a Page Object class. Before you see one written in Playwright, Cypress, or WebdriverIO syntax, it's worth understanding what problem it's actually solving — because the concept is identical across all three, and the syntax differences will make a lot more sense once the underlying idea is separate from any one framework's API.
The Browser Object
Every test framework, no matter its syntax, needs some object that represents "the browser, right now" — the thing you tell to navigate somewhere, find an element, or read the current URL. Playwright calls it page, Cypress wraps it in the cy global, WebdriverIO calls it browser. Different names, same job: it's your one entry point into whatever's currently loaded in the browser.
This matters conceptually because almost every mistake in test architecture traces back to confusing "the browser object, which represents the live browser state" with "a specific element on a specific page, which only exists after that page has loaded." A button doesn't exist before its page has loaded. The browser object always exists, from the first line of your test to the last. Keeping those two categories distinct in your head is the foundation everything else in this chapter builds on.
What a Page Object Actually Is
Strip away the syntax, and a Page Object is just a class with two things in it: where things are (selectors) and what you can do (methods that use those selectors).
LoginPage
- usernameField (a selector)
- passwordField (a selector)
- loginButton (a selector)
- login(username, password) → fills both fields and clicks the button
That's the entire idea. A test that wants to log in calls loginPage.login('standard_user', 'secret_sauce') instead of writing out three lines of raw selector interactions inline. The value isn't that it's shorter — it's that the selector for "the username field" now exists in exactly one place in your entire codebase.
Why This Actually Matters: The Real Problem It Solves
Imagine you don't use Page Objects. You have 40 tests, and 25 of them log in as part of their setup. Each one writes out the same three lines: find the username field, type into it, find the password field, type into it, click login. That's 75 lines of near-identical code spread across 25 files.
Now the application's login form changes — the username field's id attribute gets renamed during a redesign. Every one of those 25 tests breaks, because every one of them hardcoded that selector independently. You now have to find and fix the same change in 25 places, and if you miss even one, it fails in CI in a way that looks like a real regression rather than a maintenance gap.
With a Page Object, that selector exists in one file. The redesign breaks one line of code. Every test that calls loginPage.login(...) is fixed the moment you fix that one line, because they were never talking to the selector directly — they were talking to the abstraction.
This is the entire argument for abstraction in test code, stated as plainly as possible: when something in the application changes, the number of places your test code needs to change should match how many times that thing conceptually appears in your tests — not how many times you happened to type out its selector.
Where Abstraction Goes Wrong
Page Objects have a real, deserved reputation for going badly wrong in practice, and it's worth naming the failure modes here, framework-agnostically, before you see them again in each framework's own chapter:
Over-abstracting single-use interactions. If a PageObject method is called from exactly one test and never will be called from anywhere else, wrapping it doesn't reduce duplication — there was never any duplication to reduce. It just adds a layer of indirection a reader has to click through to see what a test actually does.
Assertions living inside the Page Object. A Page Object's job is to expose selectors and actions — "click this," "type that," "get the current value of this field." The moment a Page Object method contains expect(...), you've hidden what the test is actually checking inside a class the test's author might never open. Assertions belong in the test file, where anyone reading the test can see, in one place, everything it's actually verifying.
One giant Page Object for the whole application. A single class with 200 selectors covering every page in the app defeats the entire point — you're back to one enormous shared surface where an unrelated change on the checkout page can break a Page Object method used by a completely unrelated login test, just because they happen to live in the same file. Scope each Page Object to one real page or one cohesive component.
The Concept, Independent of Syntax
Here's the same idea, described three ways, without committing to any framework's actual API:
- Locators live in one place. Not copy-pasted across test files.
- Actions are named for what a user is doing, not for the raw framework calls underneath them —
login(), notfillField().then(clickButton()). - Tests read like a description of user behavior.
loginPage.login(user, pass)followed by an assertion about what should now be visible — that's what a test file should look like, regardless of which of the three frameworks wrote it.
When you reach each framework's own Page Object Model chapter later in this tutorial, you'll see this same structure implemented with that framework's actual syntax — Playwright's locators, Cypress's chainable commands, WebdriverIO's $()/$$(). The syntax will differ. The reason you're doing it, and the shape of what "doing it well" looks like, won't.