The Page Object Model (POM) is a design pattern that treats each web page or a significant component of a web page as a class.
Let’s see it in action. Imagine we have a simple login page:
<!-- login.html -->
<html>
<body>
<input id="username" type="text" placeholder="Username">
<input id="password" type="password" placeholder="Password">
<button id="login-button">Login</button>
<div id="error-message" style="display: none; color: red;">Invalid credentials</div>
</body>
</html>
And we want to test the login functionality using Cypress. Without POM, our test might look like this:
// cypress/integration/login.spec.js (without POM)
describe('Login Functionality', () => {
it('should allow a user to log in with valid credentials', () => {
cy.visit('/login.html'); // Assuming this page is served locally
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('#login-button').click();
// Assertion would go here, e.g., checking for a welcome message
cy.url().should('include', '/dashboard');
});
it('should display an error message with invalid credentials', () => {
cy.visit('/login.html');
cy.get('#username').type('wronguser');
cy.get('#password').type('wrongpassword');
cy.get('#login-button').click();
cy.get('#error-message').should('be.visible');
});
});
This works, but it tightly couples our test logic to the specific selectors of the page. If the id of the username input changes from username to user-input-field, we have to update it in every test that interacts with the username field.
With POM, we encapsulate these page elements and their interactions into separate "page objects."
First, let’s create a LoginPage object:
// cypress/page-objects/LoginPage.js
class LoginPage {
visit() {
cy.visit('/login.html');
}
// Getters for elements
getUsernameInput() {
return cy.get('#username');
}
getPasswordInput() {
return cy.get('#password');
}
getLoginButton() {
return cy.get('#login-button');
}
getErrorMessage() {
return cy.get('#error-message');
}
// Actions that can be performed on the page
login(username, password) {
this.getUsernameInput().type(username);
this.getPasswordInput().type(password);
this.getLoginButton().click();
}
isErrorMessageVisible() {
return this.getErrorMessage().should('be.visible');
}
}
export default LoginPage;
Now, our test file becomes much cleaner and more readable:
// cypress/integration/login.spec.js (with POM)
import LoginPage from '../page-objects/LoginPage';
describe('Login Functionality with POM', () => {
const loginPage = new LoginPage();
it('should allow a user to log in with valid credentials', () => {
loginPage.visit();
loginPage.login('testuser', 'password123');
// Assertion would go here, e.g., checking for a welcome message
cy.url().should('include', '/dashboard');
});
it('should display an error message with invalid credentials', () => {
loginPage.visit();
loginPage.login('wronguser', 'wrongpassword');
loginPage.isErrorMessageVisible();
});
});
See how the test logic is now focused on what it’s trying to achieve (logging in, checking for an error) rather than how it interacts with the page (typing into #username, clicking #login-button). The "how" is abstracted away in LoginPage.js.
This pattern solves the problem of brittle tests. If the id of the username input changes, you only need to update it in one place: LoginPage.js. All your tests that use LoginPage will automatically pick up the change. It also improves test readability and maintainability.
The core idea is that each page object represents a page or a component, exposing methods that represent user actions or ways to retrieve state from that page/component. You can also have page objects that represent common components that appear across multiple pages, like headers or footers.
One common pitfall is making page objects too granular or too monolithic. A good page object usually represents a distinct functional area or a significant chunk of a page. For very complex pages, you might break them down into multiple page objects, each representing a specific section or widget on that page. For instance, a dashboard page might have a DashboardPage object, which itself uses a SalesChartComponent object and a RecentActivityFeed object.
The next step in structuring your tests might involve creating a BasePage object to hold common methods used across all page objects, such as waitForPageLoad or getURL.