In Part 1 of this series, we set up Cypress and created our first automated test for YouTube search functionality. While our test works, there's an issue we didn't address: maintainability.
Imagine YouTube changes its search button selector from #search-icon-legacy
to something else. We'd need to update this selector in multiple places across our test suite. As our test suite grows, this approach quickly becomes unmanageable.
That's where the Page Object Model (POM) pattern comes in. In this article, we'll refactor our YouTube search tests using POM to create a more maintainable and scalable test architecture.
What is the Page Object Model?
The Page Object Model is a design pattern that creates an object repository for web UI elements. Each web page in your application is represented by a corresponding page class that contains:
- Selectors for elements on that page
- Methods that perform actions on those elements
- Methods that extract information from the page
The key benefits of using POM include:
- Reduced code duplication: Element selectors are defined once
- Better maintainability: When the UI changes, you only need to update one place
- Improved readability: Test logic is separated from implementation details
- Higher reusability: Page objects can be used across multiple tests
Let's see how we can apply this pattern to our YouTube search tests.
Project Structure for Page Objects
First, let's create an organized folder structure for our page objects:
mkdir -p cypress/support/page-objects
Your directory structure should now look like this:
cypress/
├── e2e/
│ └── youtube-search.cy.js
├── fixtures/
├── support/
│ ├── commands.js
│ ├── e2e.js
│ └── page-objects/
│ ├── BasePage.js
│ ├── HomePage.js
│ └── SearchResultsPage.js
└── ...
Creating Base Page Object
Let's start by creating a base page object that other page objects will extend. This will contain common methods used across multiple pages:
// cypress/support/page-objects/BasePage.js
class BasePage {
/**
* Navigate to a specific URL
* @param {string} url - The URL to navigate to
*/
navigate(url) {
cy.visit(url);
}
/**
* Get an element using a CSS selector
* @param {string} selector - CSS selector
* @returns Cypress chain for further commands
*/
getElement(selector) {
return cy.get(selector);
}
/**
* Click an element
* @param {string} selector - CSS selector
*/
click(selector) {
this.getElement(selector).click();
}
/**
* Type text into an input field
* @param {string} selector - CSS selector
* @param {string} text - Text to type
*/
type(selector, text) {
this.getElement(selector).type(text);
}
/**
* Check if an element is visible
* @param {string} selector - CSS selector
*/
shouldBeVisible(selector) {
this.getElement(selector).should('be.visible');
}
/**
* Check if URL includes specific text
* @param {string} urlText - Text the URL should include
*/
urlShouldInclude(urlText) {
cy.url().should('include', urlText);
}
/**
* Wait for a specific time (use sparingly!)
* @param {number} ms - Time to wait in milliseconds
*/
wait(ms) {
cy.wait(ms);
}
/**
* Accept cookies if the dialog appears
* @param {string} acceptButtonSelector - Selector for the accept button
*/
acceptCookiesIfPresent(acceptButtonSelector) {
cy.get('body').then($body => {
if ($body.find(acceptButtonSelector).length > 0) {
this.click(acceptButtonSelector);
}
});
}
}
export default BasePage;
Creating YouTube Home Page Object
Now, let's create a page object for the YouTube home page:
// cypress/support/page-objects/HomePage.js
import BasePage from './BasePage';
class HomePage extends BasePage {
// Selectors
selectors = {
searchInput: 'input#search',
searchButton: '#search-icon-legacy',
cookieAcceptButton: '[aria-label="Accept the use of cookies and other data for the purposes described"]',
suggestionsList: 'ytd-unified-search-suggestion-renderer',
};
/**
* Navigate to the YouTube home page
*/
visitHomePage() {
this.navigate('/');
this.acceptCookiesIfPresent(this.selectors.cookieAcceptButton);
}
/**
* Search for a specific term
* @param {string} searchTerm - Term to search for
*/
search(searchTerm) {
this.type(this.selectors.searchInput, searchTerm);
this.click(this.selectors.searchButton);
}
/**
* Get search suggestions as they appear
* @param {string} partialSearchTerm - Partial term to trigger suggestions
*/
getSearchSuggestions(partialSearchTerm) {
this.type(this.selectors.searchInput, partialSearchTerm);
// Wait for suggestions to appear
return cy.get(this.selectors.suggestionsList);
}
/**
* Select a specific search suggestion by its text
* @param {string} partialSearchTerm - Partial term to trigger suggestions
* @param {string} suggestionText - Text of suggestion to select
*/
selectSearchSuggestion(partialSearchTerm, suggestionText) {
this.getSearchSuggestions(partialSearchTerm);
cy.contains(this.selectors.suggestionsList, suggestionText).click();
}
/**
* Check if search suggestions contain specific text
* @param {string} partialSearchTerm - Partial term to trigger suggestions
* @param {string} expectedText - Text that should appear in suggestions
*/
searchSuggestionsShouldContain(partialSearchTerm, expectedText) {
this.getSearchSuggestions(partialSearchTerm);
cy.contains(this.selectors.suggestionsList, expectedText).should('be.visible');
}
}
export default new HomePage();
Creating YouTube Search Results Page Object
Next, let's create a page object for the search results page:
// cypress/support/page-objects/SearchResultsPage.js
import BasePage from './BasePage';
class SearchResultsPage extends BasePage {
// Selectors
selectors = {
searchResults: '#contents ytd-video-renderer',
videoTitle: '#video-title',
filterButton: '[aria-label="Search filters"]',
filterOptionUploadDate: {
section: ':contains("Upload date")',
thisMonth: ':contains("This month")',
},
videoThumbnail: '#thumbnail',
};
/**
* Verify search results loaded successfully
* @param {string} searchTerm - The search term used
*/
verifySearchResults(searchTerm) {
// Check the URL contains the search term
const encodedSearchTerm = encodeURIComponent(searchTerm).replace(/%20/g, '+');
this.urlShouldInclude(`/results?search_query=${encodedSearchTerm}`);
// Check that results are visible
this.shouldBeVisible(this.selectors.searchResults);
}
/**
* Get the count of search results
* @returns Cypress chain with the number of results
*/
getResultsCount() {
return cy.get(this.selectors.searchResults).its('length');
}
/**
* Verify minimum number of search results
* @param {number} minCount - Minimum number of expected results
*/
verifyMinimumResultsCount(minCount) {
cy.get(this.selectors.searchResults).should('have.length.at.least', minCount);
}
/**
* Verify search results contain specific text
* @param {string} text - Text to look for in results
*/
verifyResultsContainText(text) {
cy.get(this.selectors.searchResults)
.first()
.find(this.selectors.videoTitle)
.should('contain.text', text);
}
/**
* Click on a specific search result by index (0-based)
* @param {number} index - Index of the result to click
*/
clickResult(index) {
cy.get(this.selectors.searchResults)
.eq(index)
.find(this.selectors.videoThumbnail)
.click();
}
/**
* Filter search results by upload date (this month)
*/
filterByThisMonth() {
this.click(this.selectors.filterButton);
// Click "Upload date" section if not already expanded
cy.get(this.selectors.filterOptionUploadDate.section).then($el => {
// Check if the section is collapsed
if ($el.find(this.selectors.filterOptionUploadDate.thisMonth).length === 0) {
cy.wrap($el).click();
}
});
// Click "This month" option
cy.get(this.selectors.filterOptionUploadDate.section)
.parent()
.contains('This month')
.click();
// Verify filter was applied
this.urlShouldInclude('sp=');
}
/**
* Get text of a specific search result by index
* @param {number} index - Index of the result
* @returns Cypress chain with the text content
*/
getResultText(index) {
return cy.get(this.selectors.searchResults)
.eq(index)
.find(this.selectors.videoTitle)
.invoke('text');
}
}
export default new SearchResultsPage();
Creating a Video Page Object
Let's also create a page object for the video playback page, to use when we click on a search result:
// cypress/support/page-objects/VideoPage.js
import BasePage from './BasePage';
class VideoPage extends BasePage {
// Selectors
selectors = {
videoPlayer: '.html5-video-player',
videoTitle: '.ytd-video-primary-info-renderer h1.ytd-watch-metadata',
channelName: '#channel-name',
subscribeButton: '#subscribe-button',
videoDescription: '#description-inline-expander',
commentSection: '#comments',
};
/**
* Verify video page loaded successfully
*/
verifyVideoPageLoaded() {
this.shouldBeVisible(this.selectors.videoPlayer);
this.shouldBeVisible(this.selectors.videoTitle);
}
/**
* Get the video title
* @returns Cypress chain with the video title text
*/
getVideoTitle() {
return cy.get(this.selectors.videoTitle).invoke('text');
}
/**
* Verify video title contains specific text
* @param {string} text - Text to check for in the title
*/
verifyVideoTitleContains(text) {
cy.get(this.selectors.videoTitle).should('contain.text', text);
}
/**
* Get channel name
* @returns Cypress chain with the channel name text
*/
getChannelName() {
return cy.get(this.selectors.channelName).invoke('text');
}
/**
* Check if comments section is loaded
*/
verifyCommentsLoaded() {
this.shouldBeVisible(this.selectors.commentSection);
}
/**
* Expand video description
*/
expandDescription() {
cy.get(this.selectors.videoDescription).click();
}
}
export default new VideoPage();
Refactoring Our Tests Using Page Objects
Now, let's refactor our original YouTube search test to use these page objects:
// cypress/e2e/youtube-search.cy.js
import HomePage from '../support/page-objects/HomePage';
import SearchResultsPage from '../support/page-objects/SearchResultsPage';
import VideoPage from '../support/page-objects/VideoPage';
describe('YouTube Search Functionality with Page Objects', () => {
beforeEach(() => {
// Visit YouTube homepage and handle cookie consent
HomePage.visitHomePage();
});
it('should search for a specific term and verify results', () => {
const searchTerm = 'Cypress test automation';
// Perform search
HomePage.search(searchTerm);
// Verify search results
SearchResultsPage.verifySearchResults(searchTerm);
SearchResultsPage.verifyMinimumResultsCount(5);
SearchResultsPage.verifyResultsContainText('Cypress');
});
it('should filter search results by upload date', () => {
const searchTerm = 'Cypress test automation';
// Perform search
HomePage.search(searchTerm);
// Apply filter and verify
SearchResultsPage.filterByThisMonth();
SearchResultsPage.shouldBeVisible(SearchResultsPage.selectors.searchResults);
});
it('should click on a search result and verify video page loads', () => {
const searchTerm = 'Cypress test automation';
// Perform search
HomePage.search(searchTerm);
// Save the title of the first result
let firstResultTitle;
SearchResultsPage.getResultText(0).then(text => {
firstResultTitle = text.trim();
// Click the first result
SearchResultsPage.clickResult(0);
// Verify video page loaded
VideoPage.verifyVideoPageLoaded();
// Verify the video title matches (or contains) the search result title
VideoPage.getVideoTitle().then(videoTitle => {
expect(videoTitle.trim()).to.include(firstResultTitle);
});
});
});
it('should show search suggestions when typing', () => {
const partialSearchTerm = 'Cypress';
// Type partial search term and verify suggestions
HomePage.searchSuggestionsShouldContain(partialSearchTerm, 'cypress test');
});
});
Advanced Page Object Techniques
Now that we have our basic page objects working, let's explore some advanced techniques that will make our test framework even more maintainable.
1. Centralizing Selectors
For very large applications, you might want to separate selectors from page objects:
// cypress/support/selectors/youtube-selectors.js
export const homePageSelectors = {
searchInput: 'input#search',
searchButton: '#search-icon-legacy',
// more selectors...
};
export const searchResultsSelectors = {
searchResults: '#contents ytd-video-renderer',
videoTitle: '#video-title',
// more selectors...
};
// Then import in page objects:
// import { homePageSelectors } from '../selectors/youtube-selectors';
2. Composition with Components
For complex UI parts that appear on multiple pages (e.g., a header with search functionality), we can create component objects:
// cypress/support/page-objects/components/Header.js
class Header {
selectors = {
logo: 'ytd-topbar-logo-renderer',
searchInput: 'input#search',
searchButton: '#search-icon-legacy',
// more selectors...
};
search(term) {
cy.get(this.selectors.searchInput).type(term);
cy.get(this.selectors.searchButton).click();
}
// more methods...
}
export default new Header();
// Then use in page objects:
// import Header from './components/Header';
// HomePage.visitHomePage();
// Header.search('cypress');
3. Adding Custom Commands
For frequently used actions, we can extend Cypress with custom commands:
// cypress/support/commands.js
// Custom command to perform YouTube search from any page
Cypress.Commands.add('youtubeSearch', (searchTerm) => {
cy.get('input#search').clear().type(searchTerm);
cy.get('#search-icon-legacy').click();
});
// Usage in tests:
// cy.youtubeSearch('Cypress test automation');
4. Data-Driven Testing with Page Objects
We can combine page objects with data-driven testing using fixtures:
// cypress/fixtures/search-terms.json
{
"terms": [
{
"query": "Cypress test automation",
"expectedResult": "Cypress"
},
{
"query": "JavaScript testing",
"expectedResult": "JavaScript"
}
]
}
// cypress/e2e/data-driven-search.cy.js
import HomePage from '../support/page-objects/HomePage';
import SearchResultsPage from '../support/page-objects/SearchResultsPage';
describe('Data-Driven YouTube Search Tests', () => {
beforeEach(() => {
HomePage.visitHomePage();
});
it('should search for multiple terms and verify results', () => {
cy.fixture('search-terms.json').then(data => {
data.terms.forEach(term => {
// Perform search
HomePage.search(term.query);
// Verify results
SearchResultsPage.verifySearchResults(term.query);
SearchResultsPage.verifyResultsContainText(term.expectedResult);
// Go back to homepage for next search
HomePage.visitHomePage();
});
});
});
});
5. API Integrations with Page Objects
For tests that combine UI and API interactions:
// cypress/support/page-objects/SearchResultsPage.js
class SearchResultsPage extends BasePage {
// Existing methods...
/**
* Intercept search API requests and validate response
* @param {string} searchTerm - Search term
*/
interceptSearchRequests(searchTerm) {
// Intercept the search API request
cy.intercept('GET', '**/search*').as('searchRequest');
// Perform search via UI
HomePage.search(searchTerm);
// Wait for the request and validate
cy.wait('@searchRequest').then(interception => {
expect(interception.response.statusCode).to.eq(200);
// More assertions on the response...
});
}
}
Advanced Testing Patterns with Page Objects
1. The Observer Pattern for State Verification
Sometimes you need to observe and verify complex state changes. Here's a pattern to handle that:
// cypress/support/page-objects/SearchResultsPage.js
class SearchResultsPage extends BasePage {
// Existing code...
/**
* Observe number of results over time (useful for infinite scrolling)
* @param {number} initialCount - Initial count to compare against
* @param {number} scrollTimes - Number of times to scroll
* @returns {Cypress.Chainable} - Promise with the new count
*/
observeResultsAfterScrolling(initialCount, scrollTimes = 1) {
let count = initialCount;
// Create a function to scroll and check count
const scrollAndCheck = (times) => {
if (times <= 0) return cy.wrap(count);
// Scroll to bottom
cy.scrollTo('bottom');
// Wait for new results to load
cy.wait(1000); // Consider using a better wait strategy
// Get new count
return this.getResultsCount().then(newCount => {
expect(newCount).to.be.gt(count);
count = newCount;
return scrollAndCheck(times - 1);
});
};
return scrollAndCheck(scrollTimes);
}
}
2. The Chain of Responsibility Pattern
For complex test flows where different objects need to handle different parts:
// Example of chaining page objects for an end-to-end test
it('should search, select a result, and interact with the video page', () => {
const searchTerm = 'Cypress test automation';
// Chain of responsibility
HomePage.visitHomePage()
.then(() => {
HomePage.search(searchTerm);
return SearchResultsPage; // Return the next handler
})
.then(searchPage => {
searchPage.verifySearchResults(searchTerm);
searchPage.clickResult(0);
return VideoPage; // Return the next handler
})
.then(videoPage => {
videoPage.verifyVideoPageLoaded();
videoPage.expandDescription();
// More video page interactions...
});
});
Best Practices for Page Object Model in Cypress
-
Keep Page Objects Focused: Each page object should represent a single page or component.
-
Make Methods Atomic: Methods should do one thing and do it well.
-
Return 'this' or Next Page Object: For method chaining and fluent interfaces.
search(term) { this.type(this.selectors.searchInput, term); this.click(this.selectors.searchButton); return new SearchResultsPage(); // Return the next page object }
-
Separate Selectors from Actions: Makes maintenance easier when the UI changes.
-
Maintain State Minimally: Page objects should be mostly stateless.
-
Document Each Method and Selector: Good documentation makes the framework more usable.
-
Consider Role-Based Page Objects: For applications with different user roles and permissions.
Common Pitfalls to Avoid
-
Overly Complex Page Objects: If your page object becomes too large, break it down into components.
-
Testing Logic in Page Objects: Page objects should contain UI interaction logic only, not test assertions.
-
Tight Coupling Between Page Objects: Page objects should be modular and independent.
-
Ignoring the Single Responsibility Principle: Keep each page object focused on a specific part of the UI.
-
Using Cypress Commands That Require Chaining Inside Page Objects: This can lead to complex synchronization issues.
Conclusion
The Page Object Model brings significant benefits to Cypress test maintenance. By encapsulating selectors and UI interactions in dedicated classes, we've created a more robust and maintainable test suite.
In this article, we've:
- Structured a scalable project for page objects
- Created reusable page objects for YouTube testing
- Refactored our tests to use the page object pattern
- Explored advanced POM techniques and patterns
- Covered best practices and common pitfalls
This approach will pay dividends as your test suite grows. When YouTube's UI changes (which it inevitably will), you'll only need to update selectors in one place rather than across dozens of tests.
In the final article of this series, we'll dive into comprehensive reporting and error handling for Cypress tests, including how to capture videos, screenshots, network logs, and console logs in a Mochawesome report.
Stay tuned, and in the meantime, try refactoring your existing Cypress tests using the page object pattern!
Part 3 of this series, "Comprehensive Logging and Reporting in Cypress," will be published on April 7, 2025.