📖 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

Test File (Spec)
Contains test logic, assertions, and data. No locators here.
⬇️ Calls methods
Page Object (Class)
Contains locators and high-level actions (e.g., loginUser()).
⬇️ Interacts via
Playwright (Page/Locator)
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.

typescript · pages/LoginPage.ts
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.

typescript · fixtures.ts
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.

typescript · tests/auth.spec.ts
// 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

typescript · ❌ Anti-pattern
// ❌ 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

  1. Use Custom Fixtures. It's the native Playwright way. It handles instantiation perfectly.
  2. Keep locators in the constructor. Centralize them so a UI change requires updating only one block of code.
  3. Return page objects from actions. If login() navigates to the Dashboard, have it return new DashboardPage(this.page).
  4. Never put test data in POMs. Usernames and passwords should be passed as arguments, not hardcoded in the class.
  5. 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

Beginner
What is the Page Object Model?
Show Answer
POM is a design pattern where web pages are represented as classes. The class holds the locators for the page and methods to interact with it. This separates the test logic from the UI implementation, making maintenance easier.
Intermediate
How do Playwright custom fixtures improve the POM pattern?
Show Answer
Instead of manually instantiating page objects in every test (new LoginPage(page)), custom fixtures (test.extend) inject these objects automatically. This reduces boilerplate, ensures consistent lifecycle management, and leverages TypeScript autocompletion seamlessly.
Advanced
Should assertions be included inside Page Object methods?
Show Answer
Generally, no. Page Objects should focus on "how to interact" (locators and actions). Tests should focus on "what to verify" (assertions). Mixing them makes Page Objects rigid. However, basic "wait until loaded" assertions can sometimes be acceptable inside a POM for convenience.
Scenario
Your team has 500 tests. The UI changes entirely. How does your architecture save you time?
Show Answer
Because we used POM with fixtures, the test files contain zero locators. I would only need to update the locators inside the specific Page Object classes affected by the UI change. The 500 test files themselves wouldn't need to be touched at all.

🏋️ Practical Exercise

🎯 Hands-On Practice Hard

Refactor to POM

  1. Find an existing Playwright test that has 5+ lines of page.click() and page.fill().
  2. Create a pages/ directory and a class for that page.
  3. Move the locators into the constructor and wrap the actions in a method.
  4. Create a fixtures.ts file and extend the base test.
  5. 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

  1. Create 3 Page Objects: HomePage.ts, ProductPage.ts, CartPage.ts.
  2. In HomePage, add a method searchFor(item) that returns a ProductPage object.
  3. In ProductPage, add addToCart() that returns a CartPage object.
  4. Set up a fixtures.ts file to inject homePage.
  5. 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.

1. What is the primary purpose of the Page Object Model?
A) To make tests run faster
B) To separate test logic from UI locators
C) To replace Playwright's built-in assertions
D) To handle parallel execution
Answer: B — POM creates a layer of separation so UI changes don't require rewriting test logic.
2. Where should locators ideally be defined in a Page Object class?
A) Inside the test methods
B> In a separate JSON file
C) In the class constructor
D) Hardcoded in the action methods
Answer: C — Defining them in the constructor centralizes them and makes them easy to update.
3. What Playwright feature is best for injecting Page Objects into tests?
A) page.import()
B) test.extend() (Custom Fixtures)
C) globalSetup
D) test.describe()
Answer: B — Custom fixtures allow you to inject Page Objects cleanly via the test function arguments.
4. Should assertions (e.g., expect) be placed inside Page Object methods?
A) Yes, always
B) No, assertions belong in the test files
C) Only if they are network assertions
D) Only if using TypeScript
Answer: B — Mixing assertions into POMs makes them rigid and harder to reuse across different tests.
5. What is an anti-pattern when designing Page Objects?
A) Having small, focused classes
B> Creating a "God Object" that represents the entire application
C) Returning another Page Object after an action
D) Passing arguments to methods
Answer: B — "God Objects" are massive, unmaintainable classes. Break them down into logical pages or components.
6. If a login action redirects to a Dashboard, what should the login() method return?
A) void
B) boolean
C) A new DashboardPage instance
D) The original LoginPage instance
Answer: C — Returning the next page object allows for fluent, readable test flows.
7. How does using custom fixtures clean up test code?
A) It removes the need for the page object entirely
B) It eliminates the need to write new LoginPage(page) in every test
C) It automatically mocks network requests
D) It adds TypeScript support
Answer: B — Fixtures handle the instantiation behind the scenes, injecting the ready-to-use object.
8. Where should test data (like usernames/passwords) live?
A) Hardcoded in the Page Object constructor
B) In the Page Object methods
C) Passed as arguments to the POM methods or stored in env variables
D) In the Playwright config file
Answer: C — POMs should be reusable. Test data should be injected so the same POM can test different user roles.
9. What happens if you define a locator in the constructor but the element isn't on the page yet?
A> The constructor throws an error
B) Playwright's locators are lazy; it doesn't query the DOM until an action is called
C) The test fails immediately
D) It waits 5 seconds automatically
Answer: B — Locators are just descriptions. The actual DOM query happens when click() or fill() is called.
10. Why is composition often preferred over inheritance (e.g., class CartPage extends BasePage) in modern Playwright?
A) Inheritance is not supported in TypeScript
B) Inheritance can lead to tight coupling and bloated base classes; fixtures/composition are more flexible
C) Composition runs faster
D) Base pages cannot access the page object
Answer: B — Deep inheritance trees become brittle. Using small, independent POMs injected via fixtures is more maintainable.

❓ 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.