Why Page Object Goes Unquestioned
Open any test automation repository and you will find a pages/ directory. Nobody argues about it. It arrived with the project scaffold, it survived three refactors, and at some point it stopped being a decision and became furniture. That is the problem: the Page Object Pattern is applied as a default rather than chosen as a trade-off.
The pattern solved real problems in the Selenium reality it came from. XPath selectors broke whenever a designer touched a <div>. Every interaction needed an explicit WebDriverWait wrapper, and copying that wrapper into forty tests was unbearable. StaleElementReferenceException punished anyone who held a reference across a DOM update. In Java and C#, a class was the only unit of organization available. Page Object absorbed all of that. Teams today inherit the pattern without inheriting the context. The thesis of this post is narrow: Page Object is not an abstraction tool, it is a tool for hiding repeated complexity. Where there is no repeated complexity, there is nothing to hide.
What Playwright’s Locator API Already Solved
Three of the four classic justifications for Page Object are now framework-level features.
Auto-waiting. locator.click() waits for the element to be attached, visible, stable, and enabled before acting. There is no wait wrapper to centralize.
Lazy locators. A Locator is a query description, not an element handle. It resolves at action time, every time. Stale element errors do not exist as a category, so holding selectors in a long-lived object buys nothing.
Semantic selectors. getByRole('button', { name: 'Add to cart' }) is already stable against markup changes, already readable, and already carries intent. The main reason to centralize selectors was that //div[@class='btn btn-primary'][2] was unreadable and fragile. That input is gone.
What remains is the fourth justification: workflow repetition. Logging in, seeding a cart, walking a four-step wizard — those are still worth extracting. Everything else is ceremony.
Here is the same test written both ways.
// tests/cart.spec.ts — direct locators
import { test, expect } from '@playwright/test';
test('adding an item updates the cart badge', async ({ page }) => {
await page.goto('/products/mechanical-keyboard');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toHaveText('1 item in cart');
});
// pages/product.page.ts — the page object version
import { type Page, type Locator } from '@playwright/test';
export class ProductPage {
readonly page: Page;
readonly addToCartButton: Locator;
readonly cartStatus: Locator;
constructor(page: Page) {
this.page = page;
this.addToCartButton = page.getByRole('button', { name: 'Add to cart' });
this.cartStatus = page.getByRole('status');
}
async open(slug: string): Promise<void> {
await this.page.goto(`/products/${slug}`);
}
async addToCart(): Promise<void> {
await this.addToCartButton.click();
}
}
// tests/cart.pom.spec.ts
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/product.page';
test('adding an item updates the cart badge', async ({ page }) => {
const productPage = new ProductPage(page);
await productPage.open('mechanical-keyboard');
await productPage.addToCart();
await expect(productPage.cartStatus).toHaveText('1 item in cart');
});
Thirty lines across two files to express what five lines already expressed. If ProductPage is used by one test, that is a pure loss. The question is not “is this clean?” — it is “what does the second file buy me?”
Three Situations Where Page Object Actually Pays Off
1. The same multi-step workflow appears in three or more tests. Three is the practical threshold, not two. Two occurrences are a coincidence; three is a pattern. A login flow, a checkout sequence, or a multi-step form wizard crossing three test files earns an extraction. Note the word multi-step: wrapping one click is never the case.
2. The UI changes often and the change is concentrated in one screen. If a single screen is under active redesign and twelve tests touch it, centralizing its interactions limits the blast radius of each redesign to one file. The criterion is concentration, not change in the abstract. If changes are spread evenly across twenty screens, a page class per screen does not reduce the edit count — it just moves it.
3. People who know the domain but not Playwright will read the tests. When a product owner, a domain analyst, or a support engineer is expected to read a spec and confirm that it describes the right business rule, checkout.payWithExpiredCard() communicates and page.getByLabel('Card number').fill('4000000000000069') does not. If the only readers are the three engineers who wrote the tests, this justification does not apply to you.
If none of the three holds, write direct locators.
Where Page Object Becomes Overhead
Single-use page classes. A class consumed by one spec is a file, an import, a constructor call, and a name you must invent — in exchange for nothing. The cost is not the lines, it is the navigation. Every reader now opens two files to understand one test.
Getter-only classes. This is the most common failure mode:
// pages/login.page.ts — zero value
import { type Page } from '@playwright/test';
export class LoginPage {
constructor(private readonly page: Page) {}
get emailInput() { return this.page.getByLabel('Email'); }
get passwordInput() { return this.page.getByLabel('Password'); }
get submitButton() { return this.page.getByRole('button', { name: 'Sign in' }); }
}
Every getter is a one-to-one rename of an expression that was already readable. The class adds an indirection without adding a behavior. When the label text changes you edit one line either way — in the page class, or in the test. The centralization argument only bites when the selector appears in many places, and a one-to-one wrapper does not create that condition, it merely renames it.
Assertions leaking into page classes. A method like async verifyErrorIsShown() moves the test’s expectation out of the test. Now the spec file reads await loginPage.verifyErrorIsShown() and you cannot tell what is actually asserted without opening the other file. Worse, when the assertion message appears in a CI failure report, it points at the page class, not at the behavior under test. Page objects should expose locators and actions; the spec owns the expectations.
Inheritance hierarchies between page classes. The worst of the set:
// pages/base.page.ts
import { type Page, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
async waitForHeader(): Promise<void> {
await expect(this.page.getByRole('banner')).toBeVisible();
}
}
// pages/authenticated.page.ts
export abstract class AuthenticatedPage extends BasePage {
async signOut(): Promise<void> {
await this.page.getByRole('button', { name: 'Sign out' }).click();
}
}
// pages/account.page.ts
export class AccountPage extends AuthenticatedPage {
async openBillingTab(): Promise<void> {
await this.page.getByRole('tab', { name: 'Billing' }).click();
}
}
The mechanical damage: a method on BasePage is implicitly callable from every page in the system, so the compiler can no longer tell you which screens actually support an action. A change in waitForHeader can break a test three levels down that never mentioned headers. And since pages in a web app do not form a taxonomy — a checkout page is not a kind of account page — the hierarchy encodes a fiction and then forces every new screen to pick a parent.
The Alternative: Fixture-Based Task Abstraction
Abstract the task, not the page. Setup work — authentication, data seeding, navigating to a known state — belongs in fixtures. Repeated business flows belong in plain async functions. Neither requires a class hierarchy, and both compose.
// fixtures.ts
import { test as base, expect, type Page } from '@playwright/test';
type Customer = { id: string; email: string; password: string };
type Fixtures = {
customer: Customer;
signedInPage: Page;
};
export const test = base.extend<Fixtures>({
customer: async ({ request }, use) => {
const email = `user-${crypto.randomUUID()}@example.test`;
const password = 'Correct-Horse-9';
const response = await request.post('/api/customers', {
data: { email, password },
});
expect(response.ok()).toBeTruthy();
const { id } = await response.json();
await use({ id, email, password });
await request.delete(`/api/customers/${id}`);
},
signedInPage: async ({ page, customer }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(customer.email);
await page.getByLabel('Password').fill(customer.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Your account' })).toBeVisible();
await use(page);
},
});
export { expect };
// tasks/cart.ts
import { type Page, expect } from '@playwright/test';
export async function addToCart(page: Page, slug: string): Promise<void> {
await page.goto(`/products/${slug}`);
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toContainText('in cart');
}
// tests/checkout.spec.ts
import { test, expect } from '../fixtures';
import { addToCart } from '../tasks/cart';
test('a signed-in customer can complete checkout', async ({ signedInPage }) => {
await addToCart(signedInPage, 'mechanical-keyboard');
await signedInPage.getByRole('link', { name: 'Checkout' }).click();
await signedInPage.getByLabel('Card number').fill('4242424242424242');
await signedInPage.getByRole('button', { name: 'Pay now' }).click();
await expect(signedInPage.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
The test reads as a sequence of business steps. The login flow exists once. The customer record is created and destroyed per test, which is also what keeps tests independent — a precondition for running them in parallel. (On why every test must produce its own data, there is a longer treatment in Turkish: test verisi yönetimi.)
A Decision Framework: Which Abstraction, When
Three layers, in order of increasing cost. Start at the cheapest and move up only when a question forces you to.

The state question is the one that genuinely still calls for a class: a data grid with paging, sorting, and filters, or a canvas editor where the next action depends on what the previous one selected. That is an object with behavior, not a bag of selectors.
Dismantling an Existing Page Object Layer Incrementally
Deleting a large page object layer in one pull request is not an option — it produces a diff nobody can review and a suite nobody trusts. Do it in ordered passes, each one green before the next starts.

Pass one: inline getter-only classes. Mechanical, low risk, usually the largest share of files.
Pass two: flatten inheritance. Copy inherited methods down into each concrete class first — accept the duplication — then delete the base classes. Duplication you can see beats coupling you cannot.
Pass three: convert the remainder. Setup methods become fixtures; repeated business flows become exported functions.
Pass four: mark the survivors. The classes that remain should carry a one-line comment saying which of the three justifications keeps them alive.
The criterion at every step is the same and it is not negotiable: the suite stays green without any change to assertions. If you find yourself editing an expectation during a refactor, you are changing behavior, not structure — stop and split the commit. Flakiness that appears during this work is a signal that the old layer was hiding a timing bug, not that the refactor broke something; diagnose it as a flaky test, not as a regression (flaky testleri teşhis etmek, in Turkish).
Typically this ends with a handful of classes surviving out of dozens, and the maintenance win shows up in a specific place: UI changes stop producing edits in files that have nothing to do with the changed screen.
How to Defend This Decision on Your Team
The objection arrives in one shape: “Page Object is a best practice.” The answer is that a best practice is a solution bound to a context, and when the context changes the practice changes with it. Page Object was the right answer to brittle XPath, manual waits, and stale element references. Name those three conditions out loud and ask which of them still holds in your repository. If the answer is none, the practice has outlived its premise — and you are not rejecting a principle, you are noticing that its inputs are gone.
Then take the argument out of the realm of taste by writing thresholds into your code review checklist. Phrasings that work because they are falsifiable:
- A page class that contains only getters and no multi-step method is rejected. Inline it.
- No abstraction before the third occurrence. Two copies of a flow stay duplicated.
- Page classes do not inherit from other page classes.
- Assertions live in spec files, never in page classes.
- A page class imported by exactly one spec is inlined into that spec.
Each of these can be answered with yes or no by reading the diff, which is the whole point: a reviewer no longer has to argue about architecture philosophy at 5pm, they just point at the rule. Scaling an automation suite is mostly a matter of making this kind of rule explicit rather than implicit — a theme covered more broadly in test otomasyonunda ölçeklenebilirlik (Turkish).
What to Do Next
Open your repository and count. For every file in pages/, answer two questions: does it contain any method that performs more than one action, and how many spec files import it. Classes that fail the first question are inlining candidates today. Classes that are imported exactly once are inlining candidates today. The count itself is usually the most persuasive artifact you can bring to the discussion — far more than any argument about patterns.
Then change the default for new work. The next test you write starts with direct locators in the spec file. When the same flow shows up a second time, copy it. When it shows up a third time, extract it into a fixture or a task function — and only reach for a class if the component you are driving actually holds state.