Page Object Model & Fixtures — Enterprise Architecture
Spaghetti tests break when the UI changes. Learn how to combine the classic Page Object Model pattern with Playwright's custom fixtures to build a scalable, maintainable test architecture.
📖 The Story: The Spaghetti Monster
At a fast-growing startup, the QA team wrote tests rapidly. Every test file was 300 lines long, filled with page.click('#login-btn'), page.fill('.input-email'), and page.waitForSelector('.dashboard').
One day, the product team redesigned the UI. The login button was now a link. The email input had a new data-testid. The dashboard class changed.
The QA team spent the next two weeks doing "Find and Replace" across 150 test files. They missed a few. Tests broke in production. The QA lead looked at the mess and said, "We created a spaghetti monster."
They implemented the Page Object Model (POM). They created a LoginPage class. All 150 tests now used loginPage.login(). Six months later, the UI changed again. The QA team updated exactly one line of code in the LoginPage class. All 150 tests passed.
Test logic belongs in tests. UI locators belong in Page Objects. Never mix them.
🎯 Why Use POM & Fixtures?
Maintainability
If a UI locator changes, you update it in one place, not 150 test files.
Readability
Tests read like product requirements: cartPage.applyDiscount('SAVE10').
Reusability
Avoid duplicating login and navigation code across multiple files.
Dependency Injection
Playwright Fixtures handle instantiation automatically, keeping tests clean.
🌍 Real World Example
Imagine testing a complex settings page. You need to toggle a switch, enter a API key, and click save. Without POM, your test is 10 lines of Playwright actions. With POM, it's one line: await settingsPage.configureAPI('my-key', true). If the settings page layout changes tomorrow, you don't touch the test—you just update the configureAPI method.
🧒 Explain Like I'm 10
Think of a universal TV remote. The remote has a "Power" button. When you press "Power", it sends a signal to turn on the TV.
If you buy a new TV, the infrared signal needed to turn it on might be completely different. But you don't throw away the remote. You just reprogram the remote's internal code. To you, the button still says "Power".
A Page Object is the "Power" button. The test presses it. The internal code (locators) can be reprogrammed anytime without the test ever knowing.
🎓 Professional Explanation
The Page Object Model wraps a page's UI structure (locators) and behaviors (actions) into a dedicated TypeScript class. The test files interact with these classes via high-level methods.
Playwright elevates this pattern using Custom Fixtures. Instead of instantiating classes manually (const loginPage = new LoginPage(page)), you use test.extend to inject the Page Object as a fixture. This provides automatic instantiation, consistent lifecycle management, and excellent TypeScript autocompletion.
📊 POM Architecture Flow
Separation of Concerns
Contains test logic, assertions, and data. No locators here.
Contains locators and high-level actions (e.g.,
loginUser()).
Interacts with the actual Browser DOM.
🏗️ Step 1: The Basic POM Class
Here is a standard Page Object for a Login Page. Notice that locators are defined in the constructor, and actions are defined as methods.
import { Page, Locator } from '@playwright/test'; export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; readonly signInButton: Locator; constructor(page: Page) { this.page = page; // Define locators once here this.emailInput = page.getByLabel('Email'); this.passwordInput = page.getByLabel('Password'); this.signInButton = page.getByRole('button', { name: 'Sign in' }); } // Define high-level actions async goto() { await this.page.goto('/login'); } async login(email: string, password: string) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.signInButton.click(); } }
Why define locators in the constructor? Locators in Playwright are lazy. Defining them in the constructor means they are created the moment the Page Object is instantiated, but they don't actually query the DOM until an action (like fill) is called.
⚙️ Step 2: The Pro Way (Custom Fixtures)
While you could do new LoginPage(page) inside every test, the true Playwright way is to create a custom fixture. This acts as dependency injection.
import { test as base } from '@playwright/test'; import { LoginPage } from './pages/LoginPage'; import { DashboardPage } from './pages/DashboardPage'; // Define the types for our new fixtures type CustomFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; }; // Extend the base test export const test = base.extend<CustomFixtures>({ loginPage: async ({ page }, use) => { // Instantiates the class, passes the page, and provides it to the test await use(new LoginPage(page)); }, dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); } }); export { expect } from '@playwright/test';
✨ Step 3: Using Fixtures in Tests
Now your test files import the custom test instead of the base Playwright test. The Page Objects are injected perfectly.
// Notice we import 'test' from our fixtures file, not @playwright/test import { test, expect } from '../fixtures'; test('user can log in', async ({ loginPage, dashboardPage }) => { // Use the injected fixture await loginPage.goto(); await loginPage.login('admin@test.com', 'password123'); // Use the next page fixture await expect(dashboardPage.welcomeMessage).toBeVisible(); });
Look how clean that test is. No page.click(), no locators. Just business logic. If the login UI changes, you update LoginPage.ts and this test remains untouched.
⚠️ Common Mistakes
Mistake 1: Putting Assertions in Page Objects
// ❌ BAD: Assertions inside POM async login(email, pass) { await this.emailInput.fill(email); await this.signInButton.click(); await expect(this.page).toHaveURL('/dashboard'); // No! } // ✅ GOOD: Assertions stay in the test file // POM only does actions
Page Objects should represent actions, not verifications. Mixing assertions into POMs makes them rigid and hard to reuse. Let the test decide what to assert.
Mistake 2: Creating God Objects
Don't put your entire app into one AppPage.ts. Split it up: Header.ts, Footer.ts, CartPage.ts. Small, focused classes are easier to maintain.
✅ Best Practices
- Use Custom Fixtures. It's the native Playwright way. It handles instantiation perfectly.
- Keep locators in the constructor. Centralize them so a UI change requires updating only one block of code.
- Return page objects from actions. If
login()navigates to the Dashboard, have itreturn new DashboardPage(this.page). - Never put test data in POMs. Usernames and passwords should be passed as arguments, not hardcoded in the class.
- Use composition over inheritance. Instead of
BasePage -> CartPage, use small helper mixins or fixtures.
🏗️ Senior Engineer Deep Dive
Why Fixtures beat Manual Instantiation
If you do const loginPage = new LoginPage(page), you have to write that line in every single test. Fixtures use Playwright's dependency injection system. The use callback handles the lifecycle. The object is created when the test requests it and garbage-collected when the test ends. This ensures parallel tests don't share Page Object state.
The Screenplay Pattern vs POM
While POM is great, some teams prefer the "Screenplay Pattern" (Actors, Tasks, Questions). Playwright's fixture system is flexible enough to support either. However, POM with Fixtures remains the most pragmatic, readable, and widely adopted standard for web automation today.
💼 Interview Questions
Show Answer
Show Answer
new LoginPage(page)), custom fixtures (test.extend) inject these objects automatically. This reduces boilerplate, ensures consistent lifecycle management, and leverages TypeScript autocompletion seamlessly.Show Answer
Show Answer
🏋️ Practical Exercise
Refactor to POM
- Find an existing Playwright test that has 5+ lines of
page.click()andpage.fill(). - Create a
pages/directory and a class for that page. - Move the locators into the constructor and wrap the actions in a method.
- Create a
fixtures.tsfile and extend the base test. - Rewrite your test file to import your custom test and use the injected fixture.
Success criteria: The test passes exactly as before, but the test file is now under 10 lines of clean, readable code.
🚀 Mini Project
🏗️ The E-Commerce Architecture
- Create 3 Page Objects:
HomePage.ts,ProductPage.ts,CartPage.ts. - In
HomePage, add a methodsearchFor(item)that returns aProductPageobject. - In
ProductPage, addaddToCart()that returns aCartPageobject. - Set up a
fixtures.tsfile to injecthomePage. - Write a single test that starts at Home, searches, adds to cart, and asserts the cart total—using fluent style (
const cart = await home.searchFor('Shoes').addToCart();).
📝 Quiz
Test your understanding. Click an option to check your answer.
page.import()test.extend() (Custom Fixtures)globalSetuptest.describe()expect) be placed inside Page Object methods?login() method return?voidbooleanDashboardPage instanceLoginPage instancepage object entirelynew LoginPage(page) in every testclick() or fill() is called.class CartPage extends BasePage) in modern Playwright?page object❓ FAQ
Q: Can I use POM for API testing?
A: Yes, the pattern is similar. Instead of UI locators, you create an ApiClient class that wraps request.post() and request.get() calls.
Q: Should I put waitForResponse inside a POM action?
A: Yes, if the action inherently triggers a critical network request. For example, login() might wait for the /api/auth response before returning. This makes the POM robust.
Q: How do I handle components that appear on every page, like a Header or Navbar?
A: Create a Header class. You can either inject it as its own fixture, or instantiate it inside the constructors of your main pages (e.g., this.header = new Header(page)).
📦 Summary
🎯 Key Takeaways
- POM separates test logic from UI locators. When the UI changes, you update one class, not 100 tests.
- Define locators in the class constructor.
- Use
test.extend(Custom Fixtures) to inject Page Objects into tests natively. - Keep assertions out of Page Objects. POMs should only perform actions.
- Return new Page Objects when an action navigates to a new page.
