| name | playwright-page-object-model |
| user-invocable | false |
| description | Use when creating page objects or refactoring Playwright tests for better maintainability with Page Object Model patterns. |
| allowed-tools | ["Bash","Read","Write","Edit"] |
Playwright Page Object Model
Master the Page Object Model (POM) pattern to create maintainable, reusable,
and scalable test automation code. This skill covers modern Playwright
patterns including component-based architecture, locator strategies, and
app actions.
Core POM Principles
Single Responsibility
Each page object should represent one page or component with a single,
well-defined responsibility.
Encapsulation
Hide implementation details and expose only meaningful actions and
assertions.
Reusability
Create reusable components that can be composed into larger page objects.
Maintainability
When UI changes, update page objects in one place rather than across
multiple tests.
Basic Page Object Pattern
Simple Page Object
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
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.loginButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
}
Using Page Object in Tests
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
test.describe('Login', () => {
test('should login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('should show error on invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrongpassword');
const error = await loginPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
});
Locator Strategies
Recommended Locator Priority
- User-visible locators (getByRole, getByText, getByLabel)
- Test IDs (getByTestId)
- CSS/XPath (only as last resort)
User-Visible Locators
export class HomePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get searchButton() {
return this.page.getByRole('button', { name: 'Search' });
}
get searchInput() {
return this.page.getByLabel('Search products');
}
get welcomeMessage() {
return this.page.getByText('Welcome back');
}
get emailInput() {
return this.page.getByPlaceholder('Enter your email');
}
get logo() {
return this..();
}
() {
..();
}
}
Test ID Locators
export class FormPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get submitButton() {
return this.page.getByTestId('submit-button');
}
get formContainer() {
return this.page.getByTestId('form-container');
}
}
Locator Chaining
export class ProductPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get priceInCart() {
return this.page
.getByTestId('shopping-cart')
.getByRole('cell', { name: 'Price' });
}
getProductByName(name: string) {
return this.page
.getByRole('listitem')
.filter({ hasText: name });
}
get firstProduct() {
return this.page.getByRole('article').nth(0);
}
}
Component-Based Architecture
Reusable Component Objects
export class Navigation {
readonly page: Page;
readonly homeLink: Locator;
readonly productsLink: Locator;
readonly cartLink: Locator;
readonly profileMenu: Locator;
constructor(page: Page) {
this.page = page;
this.homeLink = page.getByRole('link', { name: 'Home' });
this.productsLink = page.getByRole('link', { name: 'Products' });
this.cartLink = page.getByRole('link', { name: 'Cart' });
this.profileMenu = page.getByRole('button', { name: 'Profile' });
}
async navigateToHome() {
await ..();
}
() {
..();
}
() {
..();
}
() {
..();
}
}
Composing Page Objects with Components
import { Page } from '@playwright/test';
import { Navigation } from '../components/navigation';
import { Footer } from '../components/footer';
export class BasePage {
readonly page: Page;
readonly navigation: Navigation;
readonly footer: Footer;
constructor(page: Page) {
this.page = page;
this.navigation = new Navigation(page);
this.footer = new Footer(page);
}
}
import { BasePage } from './base-page';
import { Page } from '@playwright/test';
export class ProductPage extends BasePage {
readonly addToCartButton: Locator;
readonly productTitle: Locator;
readonly productPrice: Locator;
constructor(page: Page) {
super(page);
this.addToCartButton = page.getByRole('button', { name: 'Add to Cart' });
this.productTitle = page.getByRole('heading', { level: 1 });
this.productPrice = page.getByTestId('product-price');
}
async goto(productId: string) {
await this.page.();
}
() {
..();
..(
response.().()
);
}
() {
..();
}
() {
text = ..();
(text?.(, ) || );
}
}
Modal and Dialog Components
export class Modal {
readonly page: Page;
readonly container: Locator;
readonly closeButton: Locator;
readonly title: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.getByRole('dialog');
this.closeButton = this.container.getByRole('button', { name: 'Close' });
this.title = this.container.getByRole('heading');
}
async isVisible() {
return await this.container.isVisible();
}
async getTitle() {
return await ..();
}
() {
..();
..({ : });
}
}
import { Modal } from './modal';
import { Page } from '@playwright/test';
export class ConfirmationModal extends Modal {
readonly confirmButton: Locator;
readonly cancelButton: Locator;
readonly message: Locator;
constructor(page: Page) {
super(page);
this.confirmButton = this.container.getByRole('button', {
name: 'Confirm',
});
this.cancelButton = this.container.getByRole('button', {
name: 'Cancel',
});
this.message = this.container.getByTestId('modal-message');
}
async () {
..();
..({ : });
}
() {
..();
..({ : });
}
() {
..();
}
}
App Actions Pattern
High-Level Actions
import { Page } from '@playwright/test';
import { LoginPage } from './login-page';
import { ProductPage } from './product-page';
export class AppActions {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async login(email: string, password: string) {
const loginPage = new LoginPage(this.page);
await loginPage.goto();
await loginPage.login(email, password);
await this.page.waitForURL('/dashboard');
}
async addProductToCart(productId: string) {
const productPage = new (.);
productPage.(productId);
productPage.();
}
() {
..();
.(paymentDetails.);
.(paymentDetails.);
..(, { : }).();
..();
}
() {
..().(shipping.);
..().(shipping.);
..().(shipping.);
..().(shipping.);
}
() {
..().(payment.);
..().(payment.);
..().(payment.);
}
}
{
: ;
: ;
}
{
: ;
: ;
: ;
: ;
}
{
: ;
: ;
: ;
}
Using App Actions in Tests
import { test, expect } from '@playwright/test';
import { AppActions } from '../pages/app-actions';
test('should complete checkout flow', async ({ page }) => {
const app = new AppActions(page);
await app.login('user@example.com', 'password123');
await app.addProductToCart('product-123');
await app.completeCheckout({
shipping: {
name: 'John Doe',
address: '123 Main St',
city: 'New York',
postalCode: '10001',
},
payment: {
cardNumber: '4111111111111111',
expiry: '12/25',
cvv: '123',
},
});
await expect(page.getByText('Order confirmed')).toBeVisible();
});
Advanced Patterns
Generic Table Component
export class Table {
readonly page: Page;
readonly container: Locator;
constructor(page: Page, testId?: string) {
this.page = page;
this.container = testId
? page.getByTestId(testId)
: page.getByRole('table');
}
async getHeaders() {
const headers = await this.container
.getByRole('columnheader')
.allTextContents();
return headers;
}
async getRowCount() {
return await this.container.getByRole('row').count() - 1;
}
async getRow(index: number) {
return ..().(index + );
}
() {
rowLocator = .(row);
cell = rowLocator.().(column);
cell.();
}
() {
headers = .();
columnIndex = headers.(columnName);
(columnIndex === -) {
();
}
.(row, columnIndex);
}
() {
headers = .();
columnIndex = headers.(columnName);
rowCount = .();
( i = ; i < rowCount; i++) {
cellValue = .(i, columnIndex);
(cellValue === value) {
.(i);
}
}
;
}
}
Form Component with Validation
export class Form {
readonly page: Page;
readonly container: Locator;
readonly submitButton: Locator;
constructor(page: Page, formTestId: string) {
this.page = page;
this.container = page.getByTestId(formTestId);
this.submitButton = this.container.getByRole('button', {
name: /submit|save|create/i,
});
}
async fillField(label: string, value: string) {
await this.container.getByLabel(label).fill(value);
}
async selectOption(label: string, value: string) {
await this..(label).(value);
}
() {
..(label).();
}
() {
..(label).();
}
() {
..();
}
() {
field = ..(label);
fieldId = field.();
error = ..();
error.();
}
() {
error = .(label);
error !== && error.() !== ;
}
() {
errors = .
.()
.();
errors.( e.() !== );
}
}
Waiting Strategies in Page Objects
export class DashboardPage {
readonly page: Page;
readonly loadingSpinner: Locator;
readonly dataTable: Locator;
constructor(page: Page) {
this.page = page;
this.loadingSpinner = page.getByTestId('loading-spinner');
this.dataTable = page.getByRole('table');
}
async goto() {
await this.page.goto('/dashboard');
await this.waitForPageLoad();
}
async waitForPageLoad() {
await this.loadingSpinner.waitFor({ state: 'hidden' });
await this.dataTable.({ : });
..();
}
() {
refreshButton = ..(, { : });
refreshButton.();
..(
response.().() && response.() ===
);
.();
}
}
Handling Dynamic Content
Lists and Collections
export class ProductListPage {
readonly page: Page;
readonly productCards: Locator;
constructor(page: Page) {
this.page = page;
this.productCards = page.getByTestId('product-card');
}
async goto() {
await this.page.goto('/products');
}
async getProductCount() {
return await this.productCards.count();
}
async getProductCard(index: number) {
return this.productCards.nth(index);
}
async getProductCardByName(name: string) {
return this.productCards.filter({ : name }).();
}
() {
names = .
.()
.();
names;
}
() {
card = .(name);
card.();
}
() {
card = .(name);
card.(, { : }).();
}
}
Search and Filter
export class SearchPage {
readonly page: Page;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly results: Locator;
readonly filters: Locator;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByRole('searchbox');
this.searchButton = page.getByRole('button', { name: 'Search' });
this.results = page.getByTestId('search-results');
this.filters = page.getByTestId('filters');
}
async search(query: string) {
await this.searchInput.fill(query);
await ..();
.();
}
() {
.
.(, { : filterName })
.();
.
.(, { : value })
.();
.();
}
() {
countText = .
.()
.();
(countText?.()?.[] || );
}
() {
..(
response.().()
);
..().().();
}
}
Type-Safe Page Objects
Using TypeScript Interfaces
export interface User {
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export interface Product {
id: string;
name: string;
price: number;
description?: string;
}
import { User } from '../types/user';
export class RegistrationPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/register');
}
async register(user: User) {
await this.page.getByLabel('Email').fill(user.email);
await this.page.getByLabel('Password').fill(user.password);
if (user.firstName) {
await this.page.getByLabel('First Name').fill(user.firstName);
}
(user.) {
..().(user.);
}
..(, { : }).();
}
}
Builder Pattern for Test Data
import { User } from '../types/user';
export class UserBuilder {
private user: Partial<User> = {};
withEmail(email: string): this {
this.user.email = email;
return this;
}
withPassword(password: string): this {
this.user.password = password;
return this;
}
withName(firstName: string, lastName: string): this {
this.user.firstName = firstName;
this.user.lastName = lastName;
return this;
}
build(): User {
if (!this.user.email || !this..) {
();
}
. ;
}
}
import { UserBuilder } from '../builders/user-builder';
import { RegistrationPage } from '../pages/registration-page';
test('should register new user', async ({ page }) => {
const user = new UserBuilder()
.withEmail('newuser@example.com')
.withPassword('SecurePass123!')
.withName('John', 'Doe')
.build();
const registrationPage = new RegistrationPage(page);
await registrationPage.goto();
await registrationPage.register(user);
await expect(page).toHaveURL('/welcome');
});
When to Use This Skill
- Creating new page objects for test automation
- Refactoring existing tests to use Page Object Model
- Building reusable component libraries for tests
- Implementing app actions for complex user flows
- Standardizing locator strategies across a test suite
- Creating type-safe page objects with TypeScript
- Designing maintainable test architecture
- Handling dynamic content and complex UI interactions
- Building form and table abstractions
- Establishing page object patterns for a team
Resources