| name | page-object-model |
| description | Complete POM design patterns for Playwright test automation. Use when building maintainable test architecture, creating page objects, base pages, component objects, page factories, or flow objects for multi-page journeys.
|
Page Object Model (POM) Skill
A comprehensive guide to implementing the Page Object Model pattern in Playwright for maintainable, scalable test automation.
What is Page Object Model?
Page Object Model (POM) is a design pattern that creates an object repository for web UI elements. It reduces code duplication and improves test maintenance by separating test logic from page interactions.
Benefits of POM
| Benefit | Description |
|---|
| Maintainability | Change locators in one place when UI changes |
| Reusability | Share page objects across multiple tests |
| Readability | Tests read like user stories |
| Abstraction | Hide complex interactions behind simple methods |
| Scalability | Easy to add new tests and pages |
Core Patterns
1. Basic Page Object
The foundation of POM - encapsulates page elements and actions.
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email address');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password?' });
}
async navigate(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage(): Promise<string> {
return (await this.errorMessage.textContent()) ?? '';
}
async expectErrorMessage(message: string): Promise<void> {
await expect(this.errorMessage).toHaveText(message);
}
}
2. Base Page Object (Inheritance Pattern)
Create a base class for shared functionality across all pages.
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
readonly header: Locator;
readonly footer: Locator;
readonly loadingSpinner: Locator;
constructor(page: Page) {
this.page = page;
this.header = page.getByRole('banner');
this.footer = page.getByRole('contentinfo');
this.loadingSpinner = page.getByTestId('loading-spinner');
}
abstract navigate(): Promise<void>;
async waitForPageLoad(): Promise<void> {
await ..();
}
(): <> {
..({ : });
}
(): <> {
..();
}
(: ): <> {
..({ : });
}
(): <> {
..(, { : }).();
}
(): <> {
..(, { : }).();
}
}
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class ProductPage extends BasePage {
readonly productTitle: Locator;
readonly productPrice: Locator;
readonly productDescription: Locator;
readonly addToCartButton: Locator;
readonly quantityInput: Locator;
readonly reviewsSection: Locator;
constructor(page: Page) {
super(page);
this.productTitle = page.getByRole('heading', { level: 1 });
this.productPrice = page.getByTestId('product-price');
this.productDescription = page.getByTestId();
. = page.(, { : });
. = page.();
. = page.(, { : });
}
(?: ): <> {
path = productId ? : ;
..(path);
.();
}
(: = ): <> {
(quantity > ) {
..(quantity.());
}
..();
.();
}
(): <> {
priceText = ..();
(priceText?.(, ) ?? );
}
(: ): <> {
(.).(name);
}
}
3. Component Objects Pattern
Break down complex pages into reusable component objects.
import { Page, Locator } from '@playwright/test';
export class HeaderComponent {
readonly page: Page;
readonly root: Locator;
readonly logo: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly cartIcon: Locator;
readonly cartCount: Locator;
readonly accountMenu: Locator;
constructor(page: Page) {
this.page = page;
this.root = page.getByRole('banner');
this.logo = this.root.getByRole('link', { name: 'Home' });
this.searchInput = ..();
. = ..(, { : });
. = ..(, { : });
. = ..();
. = ..(, { : });
}
(: ): <> {
..(query);
..();
}
(): <> {
count = ..();
(count ?? , );
}
(): <> {
..();
}
(): <> {
..();
}
}
import { Locator } from '@playwright/test';
export class ProductCardComponent {
readonly root: Locator;
readonly image: Locator;
readonly title: Locator;
readonly price: Locator;
readonly addToCartButton: Locator;
readonly wishlistButton: Locator;
readonly rating: Locator;
constructor(root: Locator) {
this.root = root;
this.image = root.getByRole('img');
this.title = root.getByRole('heading');
this.price = root.getByTestId('price');
this.addToCartButton = root.getByRole('button', { name: });
. = root.(, { : });
. = root.(, { : });
}
(): <> {
..();
}
(): <> {
..();
}
(): <> {
..();
}
(): <> {
( ..()) ?? ;
}
(): <> {
priceText = ..();
(priceText?.(, ) ?? );
}
}
import { Page, Locator } from '@playwright/test';
export class NavigationComponent {
readonly page: Page;
readonly root: Locator;
constructor(page: Page) {
this.page = page;
this.root = page.getByRole('navigation', { name: 'Main' });
}
getCategory(name: string): Locator {
return this.root.getByRole('link', { name });
}
async navigateToCategory(category: string): Promise<void> {
await this.getCategory(category).click();
}
async expandSubmenu(menuName: string): Promise<void> {
..(, { : menuName }).();
}
(): <[]> {
links = ..().();
: [] = [];
( link links) {
text = link.();
(text) categories.(text.());
}
categories;
}
}
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { HeaderComponent } from './components/HeaderComponent';
import { NavigationComponent } from './components/NavigationComponent';
import { ProductCardComponent } from './components/ProductCardComponent';
export class ProductListPage extends BasePage {
readonly header: HeaderComponent;
readonly navigation: NavigationComponent;
readonly productGrid: Locator;
readonly sortDropdown: Locator;
readonly filterPanel: Locator;
readonly resultsCount: Locator;
constructor(page: Page) {
super(page);
. = (page);
. = (page);
. = page.(, { : });
. = page.();
. = page.(, { : });
. = page.();
}
(): <> {
..();
}
(): <[]> {
cards = ..().();
cards.( (card));
}
(: ): {
card = .
.()
.({ : productName });
(card);
}
(: ): <> {
..(option);
.();
}
(: ): <> {
..(category).();
.();
}
(: , : ): <> {
..().(min.());
..().(max.());
..(, { : }).();
.();
}
(): <> {
text = ..();
match = text?.();
match ? (match[], ) : ;
}
}
4. Page Factory Pattern
Create pages on demand with proper type safety.
import { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { ProductListPage } from './ProductListPage';
import { ProductPage } from './ProductPage';
import { CartPage } from './CartPage';
import { CheckoutPage } from './CheckoutPage';
import { AccountPage } from './AccountPage';
export type PageType =
| 'login'
| 'productList'
| 'product'
| 'cart'
| 'checkout'
| 'account';
export class PageFactory {
private page: Page;
private pageInstances: Map<PageType, unknown> = new Map();
constructor(: ) {
. = page;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
(!..()) {
..(, (.));
}
..() ;
}
(): {
..();
}
}
import { test as base } from '@playwright/test';
import { PageFactory } from '../page-objects/PageFactory';
type PageFixtures = {
pages: PageFactory;
};
export const test = base.extend<PageFixtures>({
pages: async ({ page }, use) => {
const factory = new PageFactory(page);
await use(factory);
factory.clearCache();
},
});
export { expect } from '@playwright/test';
import { test, expect } from '../fixtures/pages.fixture';
test('complete purchase flow', async ({ pages }) => {
const loginPage = pages.getLoginPage();
const productList = pages.getProductListPage();
const cart = pages.getCartPage();
const checkout = pages.getCheckoutPage();
await loginPage.navigate();
await loginPage.login('user@example.com', 'password');
await productList.navigate();
const product = productList.getProductCard('Wireless Headphones');
await product.addToCart();
await cart.navigate();
await expect(cart.itemCount).toHaveText('1');
await cart.proceedToCheckout();
await checkout.completeOrder();
});
5. Fluent Interface Pattern
Chain methods for more readable test code.
import { Page, Locator, expect } from '@playwright/test';
export class FluentCheckoutPage {
readonly page: Page;
readonly firstNameInput: Locator;
readonly lastNameInput: Locator;
readonly emailInput: Locator;
readonly addressInput: Locator;
readonly cityInput: Locator;
readonly zipInput: Locator;
readonly cardNumberInput: Locator;
readonly cardExpiryInput: Locator;
readonly cardCvcInput: Locator;
readonly placeOrderButton: Locator;
constructor(page: Page) {
this.page = page;
this.firstNameInput = page.getByLabel('First name');
. = page.();
. = page.();
. = page.();
. = page.();
. = page.();
. = page.();
. = page.();
. = page.();
. = page.(, { : });
}
(): <> {
..();
;
}
(: ): <> {
..(name);
;
}
(: ): <> {
..(name);
;
}
(: ): <> {
..(email);
;
}
(: ): <> {
..(address);
;
}
(: ): <> {
..(city);
;
}
(: ): <> {
..(zip);
;
}
(: ): <> {
..();
;
}
(: ): <> {
..(expiry);
;
}
(: ): <> {
..(cvc);
;
}
(): <> {
..();
(..(, { : })).();
}
}
test('checkout with fluent interface', async ({ page }) => {
const checkout = new FluentCheckoutPage(page);
await checkout
.navigate()
.then(c => c.withFirstName('John'))
.then(c => c.withLastName('Doe'))
.then(c => c.withEmail('john@example.com'))
.then(c => c.withAddress('123 Main St'))
.then(c => c.withCity('Seattle'))
.then(c => c.withZipCode('98101'))
.then(c => c.withCardNumber('4111111111111111'))
.then(c => c.withCardExpiry('12/25'))
.then(c => c.())
.( c.());
});
(, ({ page }) => {
checkout = (page).();
( ( ( ( ( checkout
.())
.())
.())
.())
.())
.();
checkout.();
});
6. Builder Pattern for Test Data
Combine with page objects for clean test data setup.
export interface User {
firstName: string;
lastName: string;
email: string;
password: string;
phone?: string;
address?: {
street: string;
city: string;
state: string;
zip: string;
};
}
export class UserBuilder {
private user: Partial<User> = {};
static aUser(): UserBuilder {
return new UserBuilder();
}
static aDefaultUser(): User {
return UserBuilder.aUser()
.withFirstName('Test')
.withLastName('User')
.withEmail(`test-${Date.now()}@example.com`)
.withPassword('SecurePass123!')
.();
}
(: ): {
.. = firstName;
;
}
(: ): {
.. = lastName;
;
}
(: ): {
.. = email;
;
}
(: ): {
.. = password;
;
}
(: ): {
.. = phone;
;
}
(: , : , : , : ): {
.. = { street, city, state, zip };
;
}
(): {
(!.. || !.. || !.. || !..) {
();
}
. ;
}
}
import { UserBuilder } from '../test-data/builders/UserBuilder';
test('register new user', async ({ page }) => {
const registrationPage = new RegistrationPage(page);
const user = UserBuilder.aUser()
.withFirstName('Jane')
.withLastName('Smith')
.withEmail('jane.smith@example.com')
.withPassword('SecurePassword123!')
.withPhone('555-123-4567')
.withAddress('456 Oak Ave', 'Portland', 'OR', '97201')
.build();
await registrationPage.navigate();
await registrationPage.registerUser(user);
await expect(page.getByText('Registration successful')).toBeVisible();
});
Advanced Patterns
7. Page Object with State Management
Track page state for complex interactions.
import { Page, Locator, expect } from '@playwright/test';
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
export class StatefulCartPage {
readonly page: Page;
private items: CartItem[] = [];
readonly cartContainer: Locator;
readonly emptyCartMessage: Locator;
readonly checkoutButton: Locator;
readonly totalPrice: Locator;
constructor(page: Page) {
this.page = page;
this.cartContainer = page.getByRole('region', { name: 'Shopping Cart' });
this.emptyCartMessage = page.getByText();
. = page.(, { : });
. = page.();
}
(): <> {
..();
.();
}
(): <> {
. = [];
itemElements = ..().();
( element itemElements) {
id = element.() ?? ;
name = element.().() ?? ;
quantityText = element.().();
priceText = element.().() ?? ;
..({
id,
name,
: (quantityText, ),
: (priceText.(, )),
});
}
}
(): [] {
[....];
}
(): <[]> {
.();
.();
}
(): <> {
.();
..( sum + item., );
}
(): <> {
.();
..( sum + item. * item., );
}
(: , : ): <> {
item = ..();
item.().(quantity.());
item.(, { : }).();
.();
}
(: ): <> {
item = ..();
item.(, { : }).();
.();
}
(): <> {
..();
}
(: ): <> {
(.).();
}
}
8. Multi-Page Flow Objects
Handle complex flows spanning multiple pages.
import { Page, expect } from '@playwright/test';
import { CartPage } from '../CartPage';
import { ShippingPage } from '../ShippingPage';
import { PaymentPage } from '../PaymentPage';
import { ConfirmationPage } from '../ConfirmationPage';
export interface ShippingInfo {
firstName: string;
lastName: string;
address: string;
city: string;
state: string;
zip: string;
}
export interface PaymentInfo {
cardNumber: string;
expiry: string;
cvc: string;
nameOnCard: string;
}
export class CheckoutFlow {
readonly page: Page;
: ;
: ;
: ;
: ;
() {
. = page;
. = (page);
. = (page);
. = (page);
. = (page);
}
(): <> {
..();
;
}
(): <> {
..();
(.).();
;
}
(: ): <> {
..(info);
;
}
(): <> {
..();
(.).();
;
}
(: ): <> {
..(info);
;
}
(): <> {
..();
(.).();
..();
}
(: , : ): <> {
.();
.();
.(shipping);
.();
.(payment);
.();
}
}
test('complete checkout flow', async ({ page }) => {
const checkoutFlow = new CheckoutFlow(page);
const orderId = await checkoutFlow.completeCheckout(
{
firstName: 'John',
lastName: 'Doe',
address: '123 Main St',
city: 'Seattle',
state: 'WA',
zip: '98101',
},
{
cardNumber: '4111111111111111',
expiry: '12/25',
cvc: '123',
nameOnCard: 'John Doe',
}
);
expect(orderId).toMatch(/^ORD-\d+$/);
});
9. Modal and Dialog Handling
Handle modals as separate components.
import { Page, Locator } from '@playwright/test';
export class ConfirmationModal {
readonly page: Page;
readonly root: Locator;
readonly title: Locator;
readonly message: Locator;
readonly confirmButton: Locator;
readonly cancelButton: Locator;
readonly closeButton: Locator;
constructor(page: Page) {
this.page = page;
this.root = page.getByRole('dialog');
this.title = this.root.getByRole('heading');
this.message = this.root.getByTestId('modal-message');
. = ..(, { : });
. = ..(, { : });
. = ..(, { : });
}
(): <> {
..({ : });
}
(): <> {
..({ : });
}
(): <> {
..();
}
(): <> {
..();
.();
}
(): <> {
..();
.();
}
(): <> {
..();
.();
}
(): <> {
( ..()) ?? ;
}
}
import { ConfirmationModal } from './components/ConfirmationModal';
export class CartPage {
readonly page: Page;
readonly confirmationModal: ConfirmationModal;
constructor(page: Page) {
this.page = page;
this.confirmationModal = new ConfirmationModal(page);
}
async removeItem(productId: string): Promise<void> {
await this.getRemoveButton(productId).click();
await this.confirmationModal.waitForOpen();
await this.confirmationModal.confirm();
}
async removeItemWithCancel(productId: string): Promise<void> {
await this.(productId).();
..();
..();
}
}
Folder Structure for POM
your-project/
├── page-objects/
│ ├── BasePage.ts # Abstract base class
│ ├── PageFactory.ts # Page factory
│ ├── index.ts # Barrel exports
│ │
│ ├── components/ # Reusable components
│ │ ├── HeaderComponent.ts
│ │ ├── FooterComponent.ts
│ │ ├── NavigationComponent.ts
│ │ ├── ProductCardComponent.ts
│ │ ├── ConfirmationModal.ts
│ │ └── index.ts
│ │
│ ├── flows/ # Multi-page flows
│ │ ├── CheckoutFlow.ts
│ │ ├── RegistrationFlow.ts
│ │ └── index.ts
│ │
│ ├── auth/ # Auth pages
│ │ ├── LoginPage.ts
│ │ ├── RegisterPage.ts
│ │ ├── ForgotPasswordPage.ts
│ │ └── index.ts
│ │
│ ├── products/ # Product pages
│ │ ├── ProductListPage.ts
│ │ ├── ProductPage.ts
│ │ ├── SearchResultsPage.ts
│ │ └── index.ts
│ │
│ ├── checkout/ # Checkout pages
│ │ ├── CartPage.ts
│ │ ├── ShippingPage.ts
│ │ ├── PaymentPage.ts
│ │ ├── ConfirmationPage.ts
│ │ └── index.ts
│ │
│ └── account/ # Account pages
│ ├── ProfilePage.ts
│ ├── OrderHistoryPage.ts
│ ├── AddressBookPage.ts
│ └── index.ts
│
├── test-data/
│ ├── builders/ # Test data builders
│ │ ├── UserBuilder.ts
│ │ ├── ProductBuilder.ts
│ │ └── OrderBuilder.ts
│ └── fixtures/
│ ├── users.json
│ └── products.json
│
├── fixtures/ # Playwright fixtures
│ ├── pages.fixture.ts
│ ├── auth.fixture.ts
│ └── index.ts
│
└── tests/
├── auth/
├── products/
├── checkout/
└── account/
Best Practices Checklist
Do's
Don'ts
Anti-Patterns to Avoid
1. God Page Object
class AllInOnePage {
async login() {}
async searchProduct() {}
async addToCart() {}
async checkout() {}
async updateProfile() {}
}
2. Assertions in Page Objects
class ProductPage {
async verifyProductVisible() {
expect(this.productTitle).toBeVisible();
}
}
class ProductPage {
get productTitle(): Locator {
return this.page.getByRole('heading', { level: 1 });
}
}
await expect(productPage.productTitle).toBeVisible();
3. Exposing Implementation Details
class LoginPage {
readonly emailSelector = '#email-input';
readonly passwordSelector = '[data-testid="password"]';
}
class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
constructor(page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
}
}
Related Resources