Test Automation Best Practices in Action (Part 1)

Core Framework & Test Design – Page Object Model

If you’ve spent any time writing automated tests, you already know the pain of maintaining them. You write a beautiful suite of end-to-end tests, they pass perfectly, and you feel like a coding rockstar. Then, a front-end developer decides to rename a single CSS class from .btn-blue to .btn-primary, and suddenly 50 of your tests turn bright red in the CI/CD pipeline.

Ouch.

To stop this from happening, we need to talk about the absolute foundation of any scalable test automation framework: The Page Object Model (POM) the most famous one.
In this post, we’re going to look at what POM is, why experts consider it non-negotiable, and how we implement it natively and elegantly in Playwright using the architecture from our Test Automation Best Practices repository. Let’s dive in!


What is the Page Object Model (POM)?

At its core, the Page Object Model is an object-oriented design pattern that creates an abstraction layer between your test scripts and the UI of your application.
Instead of scattering raw locators (like page.locator('#submit-button')) across dozens of different test files, you create a dedicated class for each page (or reusable component) in your app. This class acts as a single source of truth for all the locators and actions specific to that page.

The Golden Rule: If the UI changes, you only update the code in one place (the Page Object), and all your tests magically keep working.

Leveling Up: POM in Playwright

Let’s look at how this is done in our repository.

1. Building the Page Class

Take a look at how we structure the HomePage class. Notice how clean and strict the encapsulation is:

import { Page, Locator } from '@playwright/test'

export class HomePage {
  readonly page: Page
  readonly header: Locator
  readonly currentColorText: Locator
  // ... other locators

  constructor(page: Page) {
    this.page = page
    this.header = page.locator('header')
    this.currentColorText = page.getByText('Current color:')
    this.turquoiseBtn = page.getByRole('button', { name: 'Turquoise' })
    // ...
  }

  async goto() {
    await this.page.goto('/')
  }

  async clickColorButton(colorName: string) {
    await this.page.getByRole('button', { name: colorName }).click()
  }
}

Expert Takeaways from this code:

  • readonly Locators: By declaring our locators as readonly, we prevent tests from accidentally mutating the page definition.
  • Accessibility-First Locators: Instead of brittle CSS or XPath selectors, we lean heavily on page.getByRole and page.getByText in the constructor and methods. This makes your tests resilient and ensures you are interacting with elements the same way a real user (or screen reader) would.
  • Encapsulated Actions: We don’t just store locators; we store behaviors. The clickColorButton(colorName: string) method hides the complexity of finding the specific button and clicking it.

2. The Secret Weapon: Playwright Fixtures

Here is where we take our framework from “good” to “enterprise-grade.”

Traditionally, to use a Page Object, you have to instantiate it in every single test using something like const homePage = new HomePage(page);. It’s repetitive and clutters your test files.

Instead, we extend Playwright’s base test functionality to automatically inject our Page Objects as fixtures.

// e2e/baseFixtures.ts
import { test as baseTest } from '@playwright/test'
import { HomePage } from './pages/HomePage'

export const test = baseTest.extend<{ homePage: HomePage; allureBddMapper: void }>({
  // Automatically instantiate Page Objects
  homePage: async ({ page }, use) => {
    await use(new HomePage(page))
  },
  // ... other global setups
})

export const expect = test.expect

By passing new HomePage(page) to the use() callback, Playwright now knows exactly how to build a HomePage object whenever a test asks for one.

3. The Final Result: Beautifully Clean Tests

Now, let’s look at how easy it is to write a test. By using our custom fixture, we can just destructure { homePage } directly into our test definitions.

// e2e/tests/pom-refactored.spec.ts
import { test, expect } from '../baseFixtures'

test.describe('POM Refactored: Background color tests', () => {
  test.beforeEach(async ({ homePage }) => {
    await homePage.goto()
  })

  test(`verify Red ( #e74c3c ) is applied as the background color`, async ({ homePage }) => {
    await homePage.clickColorButton('Red')
    await expect(homePage.currentColorText).toContainText('e74c3c')
  })
})

Notice how there is absolutely no setup code in the test itself? The test.beforeEach block directly calls await homePage.goto() using the injected fixture. The test reads exactly like a user story: Go to the home page, click the red button, and expect the color text to update. ###

Summary
Adopting the Page Object Model combined with Playwright’s custom fixtures gives you:

  1. DRY Code: Define locators once, use them everywhere.
  2. Readability: Tests describe the what (business logic), while the POM handles the how (browser interactions).
  3. Zero Boilerplate: By treating your pages as fixtures, your test files remain incredibly clean.

Happy testing! 🛠️✅

Do you want to do an exploration on this example together?

You can book some time with me to discuss your current situation and do exploratory testing of the sample of this post or any other kind of issue you met regarding Software Testing & Quality Engineering.