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:
readonlyLocators: By declaring our locators asreadonly, we prevent tests from accidentally mutating the page definition.- Accessibility-First Locators: Instead of brittle CSS or XPath selectors, we lean heavily on
page.getByRoleandpage.getByTextin 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:
- DRY Code: Define locators once, use them everywhere.
- Readability: Tests describe the what (business logic), while the POM handles the how (browser interactions).
- Zero Boilerplate: By treating your pages as fixtures, your test files remain incredibly clean.
Happy testing! 🛠️✅
