.agents/skills/playwright-best-practices/core/page-object-model.md
Page Object Model encapsulates page structure and interactions, providing:
// pages/login.page.ts
import {Page, Locator, expect} from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', {name: 'Sign in'})
this.errorMessage = page.getByRole('alert')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
}
// tests/login.spec.ts
import {test, expect} from '@playwright/test'
import {LoginPage} from '../pages/login.page'
test.describe('Login', () => {
test('successful login redirects to dashboard', async ({page}) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', '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('[email protected]', 'wrong')
await loginPage.expectError('Invalid credentials')
})
})
For reusable UI components:
// components/navbar.component.ts
import {Page, Locator} from '@playwright/test'
export class NavbarComponent {
readonly container: Locator
readonly logo: Locator
readonly searchInput: Locator
readonly userMenu: Locator
constructor(page: Page) {
this.container = page.getByRole('navigation')
this.logo = this.container.getByRole('link', {name: 'Home'})
this.searchInput = this.container.getByRole('searchbox')
this.userMenu = this.container.getByRole('button', {name: /user menu/i})
}
async search(query: string) {
await this.searchInput.fill(query)
await this.searchInput.press('Enter')
}
async openUserMenu() {
await this.userMenu.click()
}
}
// components/modal.component.ts
import {Locator, expect} from '@playwright/test'
export class ModalComponent {
readonly container: Locator
readonly title: Locator
readonly closeButton: Locator
readonly confirmButton: Locator
constructor(container: Locator) {
this.container = container
this.title = container.getByRole('heading')
this.closeButton = container.getByRole('button', {name: 'Close'})
this.confirmButton = container.getByRole('button', {name: 'Confirm'})
}
async expectTitle(title: string) {
await expect(this.title).toHaveText(title)
}
async close() {
await this.closeButton.click()
}
async confirm() {
await this.confirmButton.click()
}
}
// pages/dashboard.page.ts
import {Page, Locator} from '@playwright/test'
import {NavbarComponent} from '../components/navbar.component'
import {ModalComponent} from '../components/modal.component'
export class DashboardPage {
readonly page: Page
readonly navbar: NavbarComponent
readonly newProjectButton: Locator
constructor(page: Page) {
this.page = page
this.navbar = new NavbarComponent(page)
this.newProjectButton = page.getByRole('button', {name: 'New Project'})
}
async goto() {
await this.page.goto('/dashboard')
}
async createProject() {
await this.newProjectButton.click()
return new ModalComponent(this.page.getByRole('dialog'))
}
}
// pages/base.page.ts
import {Page} from '@playwright/test'
export abstract class BasePage {
constructor(readonly page: Page) {}
abstract goto(): Promise<void>
async getTitle(): Promise<string> {
return this.page.title()
}
}
// Return new page object on navigation
export class LoginPage extends BasePage {
async login(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
return new DashboardPage(this.page)
}
}
// Usage
const loginPage = new LoginPage(page)
await loginPage.goto()
const dashboardPage = await loginPage.login('[email protected]', 'pass')
await dashboardPage.expectWelcomeMessage()
Alternative to classes for simpler pages:
// pages/login.page.ts
import {Page} from '@playwright/test'
export function createLoginPage(page: Page) {
const emailInput = page.getByLabel('Email')
const passwordInput = page.getByLabel('Password')
const submitButton = page.getByRole('button', {name: 'Sign in'})
return {
goto: () => page.goto('/login'),
login: async (email: string, password: string) => {
await emailInput.fill(email)
await passwordInput.fill(password)
await submitButton.click()
},
emailInput,
passwordInput,
submitButton,
}
}
// Usage
const loginPage = createLoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', 'password')
submitOrder() not clickButton()tests/
├── pages/
│ ├── base.page.ts
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── settings.page.ts
├── components/
│ ├── navbar.component.ts
│ ├── modal.component.ts
│ └── table.component.ts
├── fixtures/
│ └── pages.fixture.ts
└── specs/
├── login.spec.ts
└── dashboard.spec.ts
// fixtures/pages.fixture.ts
import {test as base} from '@playwright/test'
import {LoginPage} from '../pages/login.page'
import {DashboardPage} from '../pages/dashboard.page'
type Pages = {
loginPage: LoginPage
dashboardPage: DashboardPage
}
export const test = base.extend<Pages>({
loginPage: async ({page}, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({page}, use) => {
await use(new DashboardPage(page))
},
})
// Usage in tests
test('can login', async ({loginPage}) => {
await loginPage.goto()
await loginPage.login('[email protected]', 'password')
})