What is the Page Object Model?
The Page Object Model (POM) is a design pattern that creates an object repository for web UI elements. Instead of scattering selectors and actions throughout your tests, POM encapsulates them in dedicated classes.
Benefits of POM
Maintainability UI changes only require updates in one place - the page object class.
Reusability Multiple tests can use the same page methods without code duplication.
Readability Tests read like business workflows rather than technical implementations.
Type Safety TypeScript ensures you call the right methods with correct parameters.
Architecture Overview
This framework implements a three-layer POM architecture:
┌─────────────────────────────────┐
│ Test Specifications │ ← High-level test logic
│ (LoginPageSpec.cy.ts) │
└────────────┬────────────────────┘
│ uses
↓
┌─────────────────────────────────┐
│ Page Objects │ ← Page-specific methods
│ (LoginPage.ts, HomePage.ts) │
└────────────┬────────────────────┘
│ extends
↓
┌─────────────────────────────────┐
│ Base Page │ ← Shared functionality
│ (BasePage.ts) │
└─────────────────────────────────┘
Base Page Class
The BasePage class provides common functionality inherited by all page objects:
export default class BasePage {
public inventoryItem : string
constructor () {
this . inventoryItem = '[data-test="inventory-item"]'
}
protected goToUrl ( url : string ) : void {
cy . visit ( url )
}
}
Why Use a Base Page?
Shared selectors : Common elements like inventoryItem available to all pages
Utility methods : Navigation, waiting, and other common operations
Consistency : All page objects follow the same structure
DRY principle : Don’t repeat yourself across multiple page objects
Use protected for methods that should only be called by page objects themselves, and public for methods that tests will call directly.
Page Object Implementation
Let’s examine the LoginPage class to understand the pattern:
import BasePage from "./BasePage" ;
export default class LoginPage extends BasePage {
// Private selectors - encapsulated from tests
private usernameField : string
private passwordField : string
private loginBtnField : string
private errorMsgAlert : string
private closeErrorBtn : string
private loginMainPage : string
constructor () {
super () // Call BasePage constructor
this . usernameField = '#user-name'
this . passwordField = '#password'
this . loginBtnField = '#login-button'
this . errorMsgAlert = '[data-test="error"]'
this . closeErrorBtn = '[data-test="error-button"]'
this . loginMainPage = '.login_container'
}
// Public action methods
public typeUsername ( username : string ) {
cy . get ( this . usernameField ). type ( username )
}
public typePassword ( password : string ) {
cy . get ( this . passwordField ). type ( password )
}
public clickOnLoginButton () {
cy . get ( this . loginBtnField ). click ({ force: true })
}
// Public verification methods
public verifyLoginMainPage () {
cy . get ( this . loginMainPage ). should ( 'be.visible' )
}
public verifyErrorMessage ( message : string ) {
cy . get ( this . errorMsgAlert ). should ( 'have.text' , message )
}
// Composite action - combines multiple steps
public userLogin ( username : string , password : string ) {
this . typeUsername ( username )
this . typePassword ( password )
this . clickOnLoginButton ()
}
}
Key Components
Selectors as Private Properties
Selectors are stored as private properties, hiding implementation details from tests. If the UI changes, you only update the selector here.
Public Action Methods
Methods like typeUsername() perform actions on the page. They provide a clean interface for tests.
Public Verification Methods
Methods like verifyLoginMainPage() assert expected states. They keep assertions readable and maintainable.
Composite Methods
Methods like userLogin() combine multiple actions for common workflows, reducing test code.
Using Page Objects in Tests
Here’s how page objects are used in actual test files:
import LoginPage from "../../support/pages/LoginPage" ;
import HomePage from "../../support/pages/HomePage" ;
let loginPage : LoginPage
let homePage : HomePage
describe ( 'User Authentication' , () => {
beforeEach (() => {
cy . fixture ( 'users' ). as ( 'userData' )
// Initialize page objects
loginPage = new LoginPage ()
homePage = new HomePage ()
cy . visit ( '/' )
});
it ( 'Should log in successfully with valid credentials' , () => {
cy . get ( '@userData' ). then (( user : any ) => {
// Test reads like plain English
loginPage . verifyLoginMainPage ()
loginPage . typeUsername ( user . standard )
loginPage . typePassword ( user . password )
loginPage . clickOnLoginButton ()
homePage . verifyHomePageIsVisible ()
})
});
it ( 'Should not log in with a locked user credentials' , () => {
cy . get ( '@userData' ). then (( user : any ) => {
cy . get ( '@loginData' ). then (( login : any ) => {
loginPage . verifyLoginMainPage ()
loginPage . typeUsername ( user . locked )
loginPage . typePassword ( user . password )
loginPage . clickOnLoginButton ()
loginPage . verifyErrorMessage ( login . errorMsg )
loginPage . closeErrorMessage ()
loginPage . verifyErrorMessageIsHidden ()
})
})
});
});
Compare: With vs Without POM
loginPage . typeUsername ( user . standard )
loginPage . typePassword ( user . password )
loginPage . clickOnLoginButton ()
homePage . verifyHomePageIsVisible ()
Notice how the POM version reads like a user story, while the non-POM version exposes technical details. If the username field selector changes, you’d need to update every test without POM.
Advanced Page Object Pattern
The HomePage class demonstrates more advanced patterns:
import BasePage from "./BasePage" ;
export default class HomePage extends BasePage {
private inventoryContainer : string
private prodSortContainer : string
constructor () {
super ()
this . inventoryContainer = '#inventory_container'
this . prodSortContainer = '[data-test="product-sort-container"]'
}
// Action method
public selectFilterAtoZ () {
cy . get ( this . prodSortContainer ). select ( 'Name (A to Z)' )
}
// Verification with complex logic
public verifyFilterAtoZ () {
cy . get ( this . inventoryItem ). then (( $els ) => {
const names = [ ... $els ]. map ( el => el . innerText . trim ())
const sorted = [ ... names ]. sort (( a , b ) => a . localeCompare ( b ))
expect ( names ). to . deep . equal ( sorted )
})
}
// Dynamic action method
public addProductToCart ( productIndex : number ) {
cy . get ( this . inventoryItem ). eq ( productIndex )
. find ( 'button' ). click ({ force: true })
}
// Batch operation
public addAllProductsToCart () {
cy . get ( this . inventoryItem ). each (( $el ) => {
cy . wrap ( $el ). find ( 'button' ). click ({ force: true })
})
}
}
Advanced Patterns
Complex verifications : verifyFilterAtoZ() performs array sorting and deep equality checks
Parameterized actions : addProductToCart(index) accepts parameters for flexibility
Batch operations : addAllProductsToCart() iterates over elements
Inherited selectors : Uses inventoryItem from BasePage
Component Objects
For reusable UI components, create component objects:
export default class Navbar {
private menuButton : string
private logoutLink : string
constructor () {
this . menuButton = '#react-burger-menu-btn'
this . logoutLink = '#logout_sidebar_link'
}
public userLogout () {
cy . get ( this . menuButton ). click ()
cy . get ( this . logoutLink ). click ()
}
}
Components can be used across multiple pages:
import Navbar from "../../support/components/Navbar" ;
let navbar = new Navbar ()
navbar . userLogout ()
Best Practices
Selectors should never be accessed directly from tests. Always use public methods. // ✅ Good
loginPage . typeUsername ( 'user' )
// ❌ Bad
cy . get ( loginPage . usernameField ). type ( 'user' )
Use descriptive method names
Method names should describe what they do, not how they do it. // ✅ Good
public verifyLoginMainPage ()
// ❌ Bad
public checkLoginContainer ()
Keep methods focused on a single responsibility. // ✅ Good
public typeUsername ( username : string )
public clickOnLoginButton ()
// ❌ Bad
public loginAndVerify ( username : string , password : string )
Exception: Composite methods for common workflows are acceptable.
Return 'this' for method chaining
Enable fluent interfaces by returning this: public typeUsername ( username : string ): this {
cy . get ( this . usernameField ). type ( username )
return this
}
// Usage
loginPage
. typeUsername ( 'user' )
. typePassword ( 'pass' )
. clickOnLoginButton ()
When NOT to Use POM
POM is designed for UI testing. For API tests, use direct cy.request() calls instead: // API tests don't need page objects
it ( 'Should receive a 200 status' , () => {
cy . request ({
method: 'GET' ,
url: '/booking' ,
}). its ( 'status' ). should ( 'eq' , 200 )
})
Next Steps
Creating Page Objects Step-by-step guide to building page objects
Writing UI Tests Apply POM in your UI tests
Test Organization Structure your test suite effectively
TypeScript Usage Leverage TypeScript in page objects