Skip to main content

How to Implement the Page Object Model in Playwright

5 min read

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:

Project Structure
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:

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

  1. Use readonly for locators: This prevents accidental reassignment during test execution.
  2. Choose the right locator strategy: Playwright recommends getByRole, getByLabel, and getByText for better resilience. In Katalon, the Spy tool chose the strategy for you.
  3. Store the page instance: Every page object needs access to the Playwright page object, so we pass it in the constructor.
  4. Encapsulate actions: Methods like login() hide the implementation details from tests.

Using Page Objects in Tests

Here's how a test looks with POM:

tests/specs/login.spec.ts
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:

tests/fixtures.ts
// 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:

tests/specs/login-with-fixtures.spec.ts
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):

Terminal
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

tests/pages/LoginPage.ts
async loginAndNavigate(username: string, password: string): Promise<DashboardPage> {
await this.login(username, password);
return new DashboardPage(this.page);
}

Waiting for Elements

tests/pages/DashboardPage.ts
async waitForDashboard() {
await this.welcomeMessage.waitFor({ state: 'visible' });
}

Handling Dynamic Content

tests/pages/ProductPage.ts
getProductByName(name: string): Locator {
return this.page.locator(`[data-product-name="${name}"]`);
}

Conclusion

tip

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 (CSS with 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!