DEV Community

Anton Gulin
Anton Gulin

Posted on Originally published at anton.qa

Page Object Model in Playwright with TypeScript: The Complete Guide

What is the Page Object Model?

The Page Object Model (POM) is a design pattern that creates an abstraction layer between your tests and your web pages. Instead of writing locators directly in tests, you encapsulate page elements and actions inside reusable classes.

Without POM (hard to maintain):

test('user can login', async ({ page }) => {
  await page.goto('/login');
  await page.locator('#email').fill('user@example.com');
  await page.locator('#password').fill('password123');
  await page.locator('button[type="submit"]').click();
  await expect(page.locator('.dashboard-welcome')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

With POM (scalable):

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(loginPage.welcomeMessage).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

When the login form changes, you update one file, not 50 tests.

Why Use Page Objects?

1. Maintainability

Locators live in one place. When UI changes, update once.

2. Readability

Tests read like user stories: loginPage.login(), dashboard.navigateToSettings().

3. Reusability

Login logic written once, used in 100 tests.

4. Separation of concerns

Tests focus on behavior. Page objects handle implementation.

5. Team scalability

New team members understand tests faster.

Project Structure

Here's the structure I use for all my Playwright projects:

tests/
├── pages/
   ├── BasePage.ts
   ├── LoginPage.ts
   ├── DashboardPage.ts
   └── CheckoutPage.ts
├── components/
   ├── Header.ts
   ├── Modal.ts
   └── Sidebar.ts
├── fixtures/
   └── fixtures.ts
├── specs/
   ├── login.spec.ts
   ├── dashboard.spec.ts
   └── checkout.spec.ts
└── playwright.config.ts
Enter fullscreen mode Exit fullscreen mode

Creating a Base Page Class

Start with a base class that all pages extend:

// tests/pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
  protected page: Page;
  constructor(page: Page) {
    this.page = page;
  }
  async goto(): Promise<void> {
    await this.page.goto(this.url);
  }
  abstract get url(): string;
  // Common elements across all pages
  get header(): Locator {
    return this.page.getByRole('banner');
  }
  get footer(): Locator {
    return this.page.getByRole('contentinfo');
  }
  // Common actions
  async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState('networkidle');
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a Login Page Object

Now create a specific page class:

// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }
  get url(): string {
    return '/login';
  }
  // Locators (use getters for lazy evaluation)
  get emailInput(): Locator {
    return this.page.getByLabel('Email address');
  }
  get passwordInput(): Locator {
    return this.page.getByLabel('Password');
  }
  get submitButton(): Locator {
    return this.page.getByRole('button', { name: 'Sign in' });
  }
  get errorMessage(): Locator {
    return this.page.getByRole('alert');
  }
  get forgotPasswordLink(): Locator {
    return this.page.getByRole('link', { name: 'Forgot password?' });
  }
  // Actions
  async login(email: string, password: string): Promise<void> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
  async loginAndExpectDashboard(email: string, password: string): Promise<void> {
    await this.login(email, password);
    await expect(this.page).toHaveURL(/.*dashboard/);
  }
  async loginWithInvalidCredentials(email: string, password: string): Promise<void> {
    await this.login(email, password);
    await expect(this.errorMessage).toBeVisible();
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a Dashboard Page Object

// tests/pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }
  get url(): string {
    return '/dashboard';
  }
  // Locators
  get welcomeMessage(): Locator {
    return this.page.getByRole('heading', { name: /Welcome/ });
  }
  get userMenu(): Locator {
    return this.page.getByRole('button', { name: 'User menu' });
  }
  get logoutButton(): Locator {
    return this.page.getByRole('menuitem', { name: 'Logout' });
  }
  get settingsLink(): Locator {
    return this.page.getByRole('link', { name: 'Settings' });
  }
  // Actions
  async logout(): Promise<void> {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
  async navigateToSettings(): Promise<void> {
    await this.settingsLink.click();
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Page Objects in Tests

Now your tests are clean and readable:

// tests/specs/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test.describe('Login functionality', () => {
  test('user can login with valid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const dashboardPage = new DashboardPage(page);
    await loginPage.goto();
    await loginPage.login('valid@example.com', 'password123');
    await expect(dashboardPage.welcomeMessage).toBeVisible();
  });
  test('user sees error with invalid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.loginWithInvalidCredentials('invalid@example.com', 'wrong');
    await expect(loginPage.errorMessage).toContainText('Invalid credentials');
  });
  test('user can navigate to forgot password', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.forgotPasswordLink.click();
    await expect(page).toHaveURL(/.*forgot-password/);
  });
});
Enter fullscreen mode Exit fullscreen mode

Advanced: Component Objects

For reusable UI components (modals, headers, sidebars), create component classes:

// tests/components/Modal.ts
import { Page, Locator } from '@playwright/test';
export class Modal {
  private page: Page;
  private container: Locator;
  constructor(page: Page) {
    this.page = page;
    this.container = page.getByRole('dialog');
  }
  get title(): Locator {
    return this.container.getByRole('heading');
  }
  get closeButton(): Locator {
    return this.container.getByRole('button', { name: 'Close' });
  }
  get confirmButton(): Locator {
    return this.container.getByRole('button', { name: 'Confirm' });
  }
  get cancelButton(): Locator {
    return this.container.getByRole('button', { name: 'Cancel' });
  }
  async close(): Promise<void> {
    await this.closeButton.click();
  }
  async confirm(): Promise<void> {
    await this.confirmButton.click();
  }
}
Enter fullscreen mode Exit fullscreen mode

Use it in page objects:

// In DashboardPage.ts
async openDeleteModal(): Promise<Modal> {
  await this.deleteButton.click();
  return new Modal(this.page);
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Using Playwright Fixtures

For even cleaner tests, integrate page objects with Playwright fixtures:

// tests/fixtures/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';
Enter fullscreen mode Exit fullscreen mode

Now tests are even cleaner:

// tests/specs/login.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('user can login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Use getters for locators

Getters are lazily evaluated. The locator is resolved when accessed, not when the class is instantiated.

// Good
get submitButton(): Locator {
  return this.page.getByRole('button', { name: 'Submit' });
}
// Avoid (evaluated too early)
submitButton = this.page.getByRole('button', { name: 'Submit' });
Enter fullscreen mode Exit fullscreen mode

2. Keep actions atomic

Each method should do one logical thing:

// Good
async login(email: string, password: string) { ... }
async fillEmail(email: string) { ... }
// Avoid combining unrelated actions
async loginAndCheckSettings() { ... }
Enter fullscreen mode Exit fullscreen mode

3. Don't add assertions in page objects (usually)

Assertions belong in tests. Page objects encapsulate interactions.

Exception: Verification methods like loginAndExpectDashboard() can include assertions for convenience.

4. Use role-based locators

More resilient than CSS selectors:

// Good
page.getByRole('button', { name: 'Submit' })
// Fragile
page.locator('#submit-btn-primary')
Enter fullscreen mode Exit fullscreen mode

Common Mistakes to Avoid

1. God page objects

If your page object has 50+ methods, break it into smaller components.

2. Exposing raw locators everywhere

Encapsulate interactions: loginPage.login() instead of loginPage.submitButton.click().

3. Not waiting for navigation

After clicks that navigate, wait for the URL or element:

await this.submitButton.click();

await this.page.waitForURL(/.*dashboard/);

4. Sharing state between tests

Each test should be independent. Don't rely on previous test state.

Folder Structure for Large Projects

For 100+ tests, organize by feature:

tests/
├── pages/
├── components/
├── fixtures/
├── auth/
   ├── login.spec.ts
   ├── register.spec.ts
   └── password-reset.spec.ts
├── checkout/
   ├── cart.spec.ts
   ├── payment.spec.ts
   └── confirmation.spec.ts
└── admin/
    ├── users.spec.ts
    └── settings.spec.ts
Enter fullscreen mode Exit fullscreen mode

Need Architecture Help?

If you're building a test automation framework and want it done right from the start, I can help design the architecture, set up page objects, and train your team on best practices.

Get in touch


Anton Gulin is the AI QA Architect, the first person to claim this title on LinkedIn. He builds AI-powered test automation systems where AI agents and human engineers collaborate on quality. Former Apple SDET (Apple.com / Apple Card pre-release testing). Find him at anton.qa or on LinkedIn.

Top comments (0)