How to Implement the Page Object Model in Playwright
Coming from Katalon Studio, I was used to a structured approach where the Page Object Model (POM) was practically built in. The Object Repository handled everything, I'd capture elements visually, organize them in folders, and the framework enforced clean separation between test logic and UI elements.
Now working with Playwright, I discovered that while POM is absolutely possible (and actually pretty easy), it gives you the freedom to structure your tests however you want, but that also means you need to build the architecture yourself.
Why POM matters in Playwright
Before diving into implementation, let's be clear about why you want POM in Playwright:
- Maintainability: When a button's selector changes, you fix it in one place
- Reusability: Common actions (login, navigation) are written once, used everywhere
- Readability: Tests read like business logic, not technical implementation
The Basic Structure
In Katalon, the tool created the structure for you. In Playwright, you create it manually, which will look something like this:
tests/
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
└── specs/
├── login.spec.ts
└── checkout.spec.ts
Creating Your First Page Object
Let's build a LoginPage. Here's the pattern I follow:
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByText('Invalid credentials');
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async expectError() {
await expect(this.errorMessage).toBeVisible();
}
}
Explanation
- Use
readonlyfor locators: This prevents accidental reassignment during test execution. - Choose the right locator strategy: Playwright recommends
getByRole,getByLabel, andgetByTextfor better resilience. In Katalon, the Spy tool chose the strategy for you. - Store the page instance: Every page object needs access to the Playwright
pageobject, so we pass it in the constructor. - Encapsulate actions: Methods like
login()hide the implementation details from tests.
Using Page Objects in Tests
Here's how a test looks with POM:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('user can login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('testuser', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('shows error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('baduser', 'wrongpass');
await loginPage.expectError();
});
Notice how clean the tests are.
Making It Even Easier with Fixtures
One thing I missed from Katalon was having objects "just available" without constantly instantiating them. Playwright's fixtures solve this:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';
Now your tests become even simpler:
import { test, expect } from './fixtures';
test('user can login', async ({ page, loginPage }) => {
await loginPage.goto();
await loginPage.login('testuser', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Finding Selectors
In Katalon, the Object Spy did this for you. In Playwright, you have two main options (which you may have known already, but i put this here anw):
1. Codegen (Playwright's recorder):
npx playwright codegen https://your-app.com
This generates code as you click around. However, it produces linear scripts, not page objects. You'll need to:
- Copy the generated locator code
- Paste it into your page object class
- Give it a meaningful name (might be the hardest part lmaoo)
2. Browser DevTools:
- Right-click an element → Inspect
- Use the Playwright inspector in DevTools to test selectors
- Copy the working selector into your page object
Common Patterns
Navigation Between Pages
async loginAndNavigate(username: string, password: string): Promise<DashboardPage> {
await this.login(username, password);
return new DashboardPage(this.page);
}
Waiting for Elements
async waitForDashboard() {
await this.welcomeMessage.waitFor({ state: 'visible' });
}
Handling Dynamic Content
getProductByName(name: string): Locator {
return this.page.locator(`[data-product-name="${name}"]`);
}
Conclusion
Katalon enforced POM through its UI structure. Playwright trusts you to build it yourself. This means more upfront work but also more flexibility:
- Locator: In Katalon, the Spy captured multiple selectors and could self-heal. In Playwright, you choose one strategy. Pick resilient ones (
getByRole,getByLabel) over fragile ones (CSSwith classes that might change). - Flexibility: What took clicks in Katalon requires typing in Playwright. But it can do the exact same thing, same-same but different (lol).
After the initial setup, maintaining tests is just as easy as Katalon. Update a selector in one page object file, and all tests using it are fixed.
Happy testing!