But How Much Abstraction is Still Okay in Cypress? To POM or Not To POM

sebastianclavijo

Sebastian Clavijo Suero

Posted on October 14, 2024

But How Much Abstraction is Still Okay in Cypress? To POM or Not To POM

Dive into the debate on the merits and pitfalls of employing Page Object Models in your Cypress testing suite. Discover some scenarios where this abstraction tool may enhance or hinder your automation strategy.

(Cover image from pexels.com by Steve Johnson)


ACT 1: EXPOSITION

As you all know, in the Cypress ecosystem, there's a general opinion that the Page Object Model (POM) doesn’t really belong here. Many believe it was imported by testers transitioning from Selenium. Even seasoned Cypress professionals and official Cypress blogs often oppose using the POM approach when setting up test suites.

There are some great reads on how to use POM in Cypress—check out BrowserStack's "Understanding Page Object Model in Cypress" and LambdaTest's "How to Implement Cypress Page Object Model (POM)". If you're a POM fan and want to use it in Cypress, I recommend these posts.

However, there's a different vibe in part of the Cypress community: utilizing Cypress commands and assertions directly in your test code and opting for Application Actions instead of POMs. Interested in diving into the details of Application Actions? Two articles I suggest are Gleb Bahmutov's "Application Actions: Use Them Instead of Page Objects" and Filip Hric's "Page objects vs. App actions in Cypress" on Cypress's official blog and Applitools’ blog, respectively.

I believe Application Actions are fantastic for setting up your tests' initial state, which speeds up test execution. But there’s a catch: you need to know your application’s events well, which can be challenging during black-box testing. It's a solid tool for specific cases, but remember—using Application Actions doesn’t have to exclude a POM approach in some of your test suites.

But hey, Application Actions aren't the star of this show, so I’ll pivot back to the main question of this piece:

Should I dare to use the Page Object Model in my Cypress test, or am I losing my marbles? 🤯

Alright, here’s the deal: stick with me and hear me out for the entire article. I’m not here to dictate whether you should or shouldn’t use POM in your Cypress test suites. Instead, I want to share some scenarios where I've found POM to be unnecessary, and others where a 'kind of' a POM approach has been incredibly useful. So, dive in, and let’s explore the balance of when To POM and when Not To POM! 🤝


ACT 2: CONFRONTATION

Let's explore how we can apply a rational decision making process regarding the suitability of using the POM design pattern.

For this exercise, we will use the LambdaTest Playground page as a practical example, along with a modified version of the example described in the article "How to Implement Cypress Page Object Model (POM)".

Our exercise will consist of two test suites, and the cypress.config.js will have set up baseUrl as:

baseUrl: "https://ecommerce-playground.lambdatest.io/",
Enter fullscreen mode Exit fullscreen mode

 

FIRST TEST SUITE

The first test file will check for the Home page:

  • The page title
  • The footer copyright
  • The search feature

Image description

 

If we implement these tests using a typical by the book Page Object Model approach, it might look something like this:

// `Home.js` (POM file)

class Home {
    visit() {
        cy.visit('/')
    }

    getPageTitle() {
        return cy.title()
    }

    getCopyright() {
        return cy.get(
            '.footer p'
        )
    }

    searchInput(text) {
        return cy.get(
            'input[name="search"]'
        ).first().type(text)
    }

    getSearchButton() {
        return cy.get(
            '#search > div.search-button > button'
        ).first()
    }
}
module.exports = Home


// `test-home-page-pom.cy.js` (Test file to verify the Home page)

import Home from '../support/pages/Home'

const home = new Home()

describe('testing home page', () => {
    beforeEach(() => {
        home.visit()
    })

    it('should visit home page and validate footer', () => {
        home.getPageTitle().should('eq', 'Your Store')
        home.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should search for a product', () => {
        home.searchInput('iphone')
        home.getSearchButton().click()
    })
})
Enter fullscreen mode Exit fullscreen mode

Beautiful, elegant, and neat, isn't it?

However, let's take a moment to examine the code more carefully.

In the beforeEach() hook of test-home-page-pom.cy.js, we are calling home.visit() to navigate to the home page.

    beforeEach(() => {
        home.visit()
    })
Enter fullscreen mode Exit fullscreen mode

And that visit() function was created in Home.js POM file

class Home {
    visit() {
        cy.visit('/')
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

But Cypress already has a command cy.visit() that does precisely that, and it is the only thing that is actually called within the visit() function of the Home.js POM.

So why do we really need that function?

Let's continue with this line of thought.

In the first test of test-home-page-pom.cy.js, we retrieve the title and the footer copyright by calling the Home.js POM functions home.getPageTitle() and home.getCopyright(), respectively, in order to check them.

    it('should visit home page and validate footer', () => {
        home.getPageTitle().should('eq', 'Your Store')
        home.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')
    })
Enter fullscreen mode Exit fullscreen mode

And those functions were created in the Home.js POM file in the following manner:

    // ...
    getPageTitle() {
        return cy.title()
    }

    getCopyright() {
        return cy.get(
            '.footer p'
        )
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

Wait a minute, I see a recurring pattern here... Cypress already has a cy.title() command to get the page title, and a cy.get() command to retrieve a DOM element that matches a selector.

Let me ask again, why do we really need to define those functions in the Home.js POM, and have to create an instance of the class to invoke those functions?

And I'm sure the immediate answer that comes to your mind would be something like: 'Man! To keep the abstraction while testing the page. If you don't believe me, check out the second test in that suite!'

Fair enough! Let's check the second test in the test-home-page-pom.cy.js file.

    it('should search for a product', () => {
        home.searchInput('iphone')
        home.getSearchButton().click()
    })
Enter fullscreen mode Exit fullscreen mode

This test enters the text 'iphone' as the product to search, and then clicks the search button.

But the Home.js POM functions being called are a bit more complicated this time:

    // ...
    searchInput(text) {
        return cy.get(
            'input[name="search"]'
        ).first().type(text)
    }

    getSearchButton() {
        return cy.get(
            '#search > div.search-button > button'
        ).first()
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

It seems that you also need to call the Cypress command cy.first() because there are several DOM elements on the same page that match the selectors.

That is true, but perhaps they are not really needed. What if we find a much better selector and you do not need them at all? Remember, in real estate, everything is about location, location, location. Testing is no different: everything is about selector, selector, selector (after all, some people also refer to them as locators)! 🏡 🙂

By digging a little deeper into the design of the page, you can figure out that instead of input[name="search"] you can use #main-header input[name="search"], and instead of #search > div.search-button > button you can use #main-header #search > div.search-button > button. In both cases, it returns a single element instead of multiple!

Our Home.js POM functions could be simplified to something like this:

    // ...
    searchInput(text) {
        return cy.get(
            '#main-header input[name="search"]'
        ).type(text)
    }

    getSearchButton() {
        return cy.get(
            '#main-header #search > div.search-button > button'
        )
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

Well... Now our POM function searchInput(text) simply performs a cy.get() followed by a cy.type(), and the function getSearchButton() does a cy.get(), essentially replicating existing Cypress commands.

We could easily move the cy.type() command out of the Home.js POM and into the test file test-home-page-pom.cy.js. After all, we are performing UI actions like .click() in the test file, so for consistency, it makes sense, and it wouldn't really make the test code much bigger, right?

    it('should search for a product', () => {
        home.getSearchInput().type('iphone')
        home.getSearchButton().click()
    })
Enter fullscreen mode Exit fullscreen mode

I believe putting the cy.type() in the test along with the cy.click() will help the tester understand exactly what the test does

If, after a few weeks or months, a tester revisits the code and sees something like home.searchInput('iphone'), they might wonder what exactly it does or feel the need to double-check. This could lead to spending a few minutes piecing everything together, especially if they are not very familiar with the application or the Page Object Models implemented for the page.

If the Home.js POM file is not needed, as it merely wraps existing Cypress custom commands, why keep it at all? The test could simply be written in a single file test-home-page-pom.cy.js like this:

describe('testing home page', () => {
    beforeEach(() => {
        cy.visit('/')
    })

    it('should visit home page', () => {
        cy.title().should('eq', 'Your Store')
        cy.get('.footer p').should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should search for a product', () => {
        cy.get('#main-header input[name="search"]').type('iphone')
        cy.get('#main-header #search > div.search-button > button').click()
    })
})
Enter fullscreen mode Exit fullscreen mode

Easy enough, isn't it?

And this is when you interject: 'Whoa, whoa, whoa, man! Remember? Abstraction! What if the selectors change during the life of the application? Wouldn't it be better to have all the selectors in one place, and you won't have to go on a "bear hunt" through all your tests to change the selectors???" 🐻

You are right, and I am so glad you asked! So, why don't we use the best of both worlds?

What if we create a Home.js file that resembles a POM file but contains ONLY the selectors (let's refer to it as a POM-ish file from now on)? I know it might sound unconventional, but please bear with me. You must admit, it does sound somewhat amusing. 😄

That means:

  • All the selectors for a page are in one single place (not spread across the test files).
  • We do not have to maintain unnecessary functions that duplicate existing Cypress commands.
  • When we review the tests in the future, we don't need to jump through all these Page Object Model files to figure out what each of those functions does, saving our very precious time.

In that case our code could look like something like this instead:

// `Home.js` (POM-ish file)

class Home {
    static Copyright = '.footer p'
    static SearchInput = '#main-header input[name="search"]'
    static SearchButton = '#main-header #search > div.search-button > button'
}
export default Home


// `test-home-page-pomish.cy.js`

import Home from '../support/pages/Home'

describe('testing home page', () => {
    beforeEach(() => {
        cy.visit('/')
    })

    it('should visit home page', () => {
        cy.title().should('eq', 'Your Store')
        cy.get(Home.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should search for a product', () => {
        cy.get(Home.SearchInput).type('iphone')
        cy.get(Home.SearchButton).click()
    })
})
Enter fullscreen mode Exit fullscreen mode

We've got all the selectors snugly packed in one file, representing the page under test. It's like a cheat sheet linking selector names to DOM elements and their CSS selectors. No fuss, no muss!

This test file is still pretty neat and easy to read—you know exactly what it does at a glance (no surprises). Plus, it maintains that level of abstraction we might need for testing our always evolving application.

 

SECOND TEST SUITE

The second test file will check for the Blog page:

  • The page title
  • The footer copyright, which must match that of the homepage
  • The available categories

Image description

 

In the classic POM approach the code might look something like:

// `Blog.js` (Page object file)

class Blog {
    constructor() {
        this.url = '/index.php?route=extension/maza/blog/home'
    }

    visit() {
        cy.visit(this.url)
    }

    getPageTitle() {
        return cy.title()
    }

    getCopyright() {
        return cy.get(
            '.footer p'
        )
    }

    getFirstCategoryButton() {
        return cy.get('#entry_210963 > div > a:nth-child(1)')
    }

    getSecondCategoryButton() {
        return cy.get('#entry_210963 > div > a:nth-child(2)')
    }

    getThirdCategoryButton() {
        return cy.get('#entry_210963 > div > a:nth-child(3)')
    }

    getFourthCategoryButton() {
        return cy.get('#entry_210963 > div > a:nth-child(4)')
    }
}
module.exports = Blog


// `test-blog-page-pom.cy.js` (Test file to verify the Blog page)
import Blog from '../support/pages/Blog'

const blog = new Blog()

describe('testing blog page', () => {
    beforeEach(() => {
        blog.visit()
    })

    it('should visit the blog page and validate footer', () => {
        blog.getPageTitle().should('eq', 'Blog - Poco theme')
        blog.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should have correct category names', () => {
        blog.getFirstCategoryButton().should('contain.text', 'Business')
        blog.getSecondCategoryButton().should('contain.text', 'Electronics')
        blog.getThirdCategoryButton().should('contain.text', 'Technology')
        blog.getFourthCategoryButton().should('contain.text', 'Fashion')
    })
})
Enter fullscreen mode Exit fullscreen mode

I must admit, I'm not a fan of storing URL paths to the application in a Page Object Model file. Instead, I would prefer placing the Blog page URL '/index.php?route=extension/maza/blog/home' in the cypress.config.js file. This way, I can access it later using Cypress.config() anywhere in my test project.

  urlBlogPage: '/index.php?route=extension/maza/blog/home',
Enter fullscreen mode Exit fullscreen mode

If we apply the same criteria we used for the First Test Suite to this Second Test Suite (what we are calling the POM-ish approach), our code would look something like this:

// `Blog.js`

class Blog {
    static Copyright = '.footer p'
    static FirstCategoryButton = '#entry_210963 > div > a:nth-child(1)'
    static SecondCategoryButton = '#entry_210963 > div > a:nth-child(2)'
    static ThirdCategoryButton = '#entry_210963 > div > a:nth-child(3)'
    static FourthCategoryButton = '#entry_210963 > div > a:nth-child(4)'
}
export default Blog


// `test-blog-page-pomish.cy.js`

import Blog from "../support/pages/Blog"

describe("testing blog page", () => {
    beforeEach(() => {
        cy.visit(Cypress.config('urlBlogPage'))
    })

    it('should visit the blog page and validate footer', () => {
        cy.title().should('eq', 'Blog - Poco theme')
        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should have correct category names', () => {
        cy.get(Blog.FirstCategoryButton).should('contain.text', 'Business')
        cy.get(Blog.SecondCategoryButton).should('contain.text', 'Electronics')
        cy.get(Blog.ThirdCategoryButton).should('contain.text', 'Technology')
        cy.get(Blog.FourthCategoryButton).should('contain.text', 'Fashion')
    })
})
Enter fullscreen mode Exit fullscreen mode

 

But I have to make another confession... I have lied to you. 🩳🔥

I mentioned earlier about including only selectors in the POM-ish file, not functions.

Don't all these locators follow a painfully obvious common pattern?
static FirstCategoryButton = '#entry_210963 > div > a:nth-child(1)'
static SecondCategoryButton = '#entry_210963 > div > a:nth-child(2)'
and so on.

This process can be greatly simplified by using an arrow function in the Blog.js file to dynamically generate the selectors for the category buttons. And although this is a function, it still maintains the intuitive and easy to understand mapping pattern:

// `Home.js`

class Blog {
    static Copyright = '.footer p'
    static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`
}
export default Blog


// `test-blog-page-pomish.cy.js`

import Blog from "../support/pages/Blog"

describe("testing blog page", () => {
    beforeEach(() => {
        cy.visit(Cypress.config('urlBlogPage'))
    })

    it('should visit the blog page and validate footer', () => {
        cy.title().should('eq', 'Blog - Poco theme')
        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should have correct category names', () => {
        cy.get(Blog.CategoryButton(1)).should('contain.text', 'Business')
        cy.get(Blog.CategoryButton(2)).should('contain.text', 'Electronics')
        cy.get(Blog.CategoryButton(3)).should('contain.text', 'Technology')
        cy.get(Blog.CategoryButton(4)).should('contain.text', 'Fashion')
    })
})
Enter fullscreen mode Exit fullscreen mode

This looks even neater. You have to give me that! 😄

You could make the code even more compact yet still clear by wrapping the assertions of the second tests in a loop. However, that's a topic of a different blog post: Dynamic Tests in Cypress: To Loop or Not To Loop. 😉

 

REFACTOR THE POM-ish FILES BY UI COMPONENT INSTEAD OF BY PAGE

I'm sure you have noticed that in both the first and second test suites, the initial test checks that the Home and Blog pages have the same copyright information in their footers.

Even more, it is likely that the footer is a common UI component shared by most, if not all, pages in our application. However, we still need to verify on each page that the copyright information is present and consistent.

Since the selectors to check for each page are in the POM-ish file of each page, we were forced to include the selector for the Copyright DOM element in both files:

// `Home.js`
class Home {
    static Copyright = '.footer p'
    // ...
}


// `Blog.js`
class Blog {
    static Copyright = '.footer p'
    // ...
}
Enter fullscreen mode Exit fullscreen mode

What if... we refactor our POM-ish files to correspond to a UI component instead of a full page?

These could be our UI components for our example:

  • Search
  • Footer
  • Categories

After the refactor our two test suites will be something like:

// `Search.js`

class Search{
    static SearchInput = '#main-header input[name="search"]'
    static SearchButton = '#main-header #search > div.search-button > button'
}
export default Search


// `Footer.js`

class Footer{
    static Copyright = '.footer p'
}
export default Footer


// `Categories.js`

class Categories{
     static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`
}
export default Categories


// `test-home-page-pomish.cy.js`

import Search from '../support/pages/Search'
import Footer from '../support/pages/Footer'

describe('testing home page', () => {
    beforeEach(() => {
        cy.visit('/')
    })

    it('should visit home page', () => {
        cy.title().should('eq', 'Your Store')
        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should search for a product', () => {
        cy.get(Search.SearchInput).type('iphone')
        cy.get(Search.SearchButton).click()
    })
})


// `test-blog-page-pomish.cy.js`

import Categories from "../support/pages/Categories"
import Footer from "../support/pages/Footer"

describe("testing blog page", () => {
    beforeEach(() => {
        cy.visit(Cypress.config('urlBlogPage'))
    })

    it('should visit the blog page and validate footer', () => {
        cy.title().should('eq', 'Blog - Poco theme')
        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should have correct category names', () => {
        cy.get(Categories.CategoryButton(1)).should('contain.text', 'Business')
        cy.get(Categories.CategoryButton(2)).should('contain.text', 'Electronics')
        cy.get(Categories.CategoryButton(3)).should('contain.text', 'Technology')
        cy.get(Categories.CategoryButton(4)).should('contain.text', 'Fashion')
    })
})
Enter fullscreen mode Exit fullscreen mode

If you ask me, I honestly believe that the resulting code is easier to understand and definitely easier to maintain. But hey, don't forget, it's just my opinion!

 

BONUS: TAKE IT A STEP FURTHER—USE CUSTOM COMMANDS OR UTILITY FUNCTIONS WHEN THEY CAN HELP

I believe we can still turn the screw a bit more and refine these suites one last time.

In this scenario, where multiple test suites need to verify the page title and footer copyright, we can create a custom command in the commands.js file to include these assertions. Custom commands are particularly useful for bundling common actions that are shared across different test suites. Keep it DRY when you can, specially if it is helpful.

// `support/commands.js`

import Blog from './pages/Blog'

Cypress.Commands.add(
    'checkTitleAndFooter',
    (title) => {
        cy.title().should('eq', title)
        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    }
);
Enter fullscreen mode Exit fullscreen mode

Now I must admit that I lied to you once more, but I promise this will be the last time. 🩳🔥

I believe we should create a JavaScript function within our Second Test Suite to assert all categories using a loop. This function will take an array with the list of categories to check in sequence. Since this check is conducted solely on the Blog page, it's more efficient and clean to keep the code within the test suite rather than creating a custom command in the commands.js file.

Our new utility function would be:

    const checkCategoryButtons = (categories) => {
        categories.forEach((category, i) => {
            cy.get(Blog.CategoryButton(i + 1)).should('contain.text', category)
        });
    }
Enter fullscreen mode Exit fullscreen mode

 

✔️ So, our FINAL POM-ish files and test suites will be:

// `support/commands.js`

import Footer from './pages/Footer'

Cypress.Commands.add(
    'checkTitleAndFooter',
    (title) => {
        cy.title().should('eq', title)
        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')
    }
);


// `Search.js`

class Search {
    static SearchInput = '#main-header input[name="search"]'
    static SearchButton = '#main-header #search > div.search-button > button'
}
export default Search


// `Footer.js`

class Footer {
    static Copyright = '.footer p'
}
export default Footer


// `Categories.js`

class Categories {
     static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`
}
export default Categories


// `test-home-page-pomish.cy.js`

import Search from '../support/pages/Search'

describe('testing home page', () => {
    beforeEach(() => {
        cy.visit('/')
    })

    it('should visit home page', () => {
        cy.checkTitleAndFooter('Your Store')
    })

    it('should search for a product', () => {
        cy.get(Search.SearchInput).type('iphone')
        cy.get(Search.SearchButton).click()
    })
})


// `test-blog-page-pomish.cy.js`

import Categories from "../support/pages/Categories"

describe("testing blog page", () => {
    const checkCategoryButtons = (categories) => {
        categories.forEach((category, i) => {
            cy.get(Categories.CategoryButton(i + 1)).should('contain.text', category)
        });
    }

    beforeEach(() => {
        cy.visit(Cypress.config('urlBlogPage'))
    })

    it('should visit the blog page and validate footer', () => {
        cy.checkTitleAndFooter('Blog - Poco theme')
    })

    it('should have correct category names', () => {
        checkCategoryButtons(['Business', 'Electronics', 'Technology', 'Fashion'])
    })
})
Enter fullscreen mode Exit fullscreen mode

And now we are completely done with the refactor! 🏆


ACT3: RESOLUTION

I understand that fans of the POM approach might view using the term POM-ish as an atrocity or blasphemy. However, I've taken the liberty to use this term to differentiate a proper POM from this 'pseudo POM' pattern.

If you are one of those devoted POM fans and you continued reading this blog post even after I introduced the term POM-ish, I sincerely appreciate your effort and will be forever grateful!

Here are my two cents or takeaways about the use of Page Object Model in Cypress framework based on my personal experience, and insights gained from reading great articles by people smarter than me:

  1. An orthodox by the books POM approach in Cypress is likely overkill. It can easily become cumbersome, hard to maintain, and may turn into the very beast it was meant to tame.

  2. Group related selectors in one place, not by web page to test but by UI functionality or UI components (our POM-ish files). These UI components are likely to be reused across many pages of your application.

  3. In these POM-ish files, only include the selectors and avoid functions, especially those adding no real advantage in the Cypress environment (particularly functions that merely replicate existing Cypress commands).

    If a selector needs to be changed, you only have to update it in one place within your entire test framework. This is especially useful in applications under development, where the structure of UI components and selectors may change during the design and development phases.

    Keep it simple—the POM-ish file for that UI component can simply be a class that you export, with selectors defined as static constants. s. If needed, you could also include some static functions to create indexed selectors (remember the 🩳🔥).

  4. If you aim to implement DRY (Don't Repeat Yourself) code:

    • For code used only in a single test file, create just JavaScript utility functions within that test file.
    • For code used across multiple test files or frequently, consider creating a Cypress custom command, custom query, utility function in your Cypress support folder, or even an external Cypress plugin if it's shared among projects.

    When choosing an approach, consider whether it should run synchronously or asynchronously. For more on which tool might suit each case, check out my article "And the nominees for “Best Cypress Helper” are: Utility Function, Custom Command, Custom Query, Task, and External Plugin".

    If you want to learn how to create your own Cypress external plugin from scratch, right from inception to publishing it on npm, check out my article "The Quirky Guide to Crafting and Publishing Your Cypress npm Plugin".

  5. If after reading the full article you still don't share (even just a little) my perspective on whether To POM or Not To POM, that's okay! I still believe you’re a great QA Engineer, and I’m confident you’ll make the right choice for your projects! 🙂

Don't forget to follow me, leave a comment, or give a thumbs up if you found this post useful or insightful.

💖 💪 🙅 🚩
sebastianclavijo
Sebastian Clavijo Suero

Posted on October 14, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related