DEV Community

Murat K Ozcan
Murat K Ozcan

Posted on • Edited on

Functional Programming Test Patterns with Cypress

The debate on Page Object vs module pattern is really just Inheritance vs Composition. Inheritance (PO) is great for describing what something is; the page has x, y, z on it. Composition (module pattern) is great for describing what something does. Which one do you think best fits component based architecture, where components are the building blocks and get reused on pages? How about user flows?

No more Page Objects

Page Object design pattern has two benefits

  1. They keep all page element selectors in one place and thus separation of Test code from Locators of the system.
  2. They standardize how tests interact with the page and thus avoid duplication of code and ease code maintenance.

OO in JS is a little awkward. Introduction on Class in ES6 helps but Classes, specifically this keyword, can still surprise people used to Java because they work differently than. Here is a great blog from Kent C. Dodds which highlights this point

Enter Page Modules

In Java land it's pretty common to find Page Objects which inherit from the Base Page. e.g. of this in JS will be:

import { HomePage } from './BasePage'

class HomePage extends BasePage  {
  constructor() {
    super();
    this.mainElement = 'body > .banner';
  }

//... More code

export const mainPage = new MainPage();

}
Enter fullscreen mode Exit fullscreen mode

With the move to FP we are going to lose not only Inheritance but the Class itself. Therefore we need to use Modules to arrange our code. Each module exports public functions that can be imported into other modules and used.

// HomePage Module  - HomePage.js
export function login(email, password){
  //...
}

export function logout(){
  //...
}

export function search(criterion){
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This module could be then imported into your tests or other modules and used as below.

// HomePage Test - HomePageTest.js
import * as homePage from './HomePage.js'

describe('Home Page', ()=> {
  it('User can login', ()=> {
      cy.visit('/')
      homePage.login('abc','123456')
  })
})

Enter fullscreen mode Exit fullscreen mode

Or We could selectively import individual functions from a module.

import {login} from './HomePage.js'
describe('Home Page', ()=> {
  it('User can login', ()=> {
    cy.visit('/')
    login('abc','123456')
  })
})

Enter fullscreen mode Exit fullscreen mode

What about Inheritance

public class HomePage extends BasePage {

}

Enter fullscreen mode Exit fullscreen mode

A lot of times we come around Test suites where Page Objects extend a BasePage or every test file extends a BaseTest class. The intention behind doing this is often code reuse. Most often the BaseTest class has methods related to login, logout and logging etc. This is an anti-pattern. Bundling unrelated functionality into a parent class for the purpose of reuse is an abuse of Inheritance.

Common functionality that is to be made available to multiple specs (my recommendation is 3+) could be added as Cypress Custom commands. Custom Commands are available to be used globally with the cy. prefix. e.g. we can add a method called login as a custom command as below.

Cypress.Commands.add('login', (username, password) => {
    cy.get('#username').type(username)
    //...
})
Enter fullscreen mode Exit fullscreen mode

The Cypress.Commands.add takes the name of the custom command as the first argument and a closure as the second argument. Cypress custom commands are chainable and automatically returned, even there is no explicit return.

Now we could use the custom command in any spec.

describe('Login Page', ()=>{
  it('User can login', ()=>{
    cy.login('abc','123456')
    // ...
  })
})

Enter fullscreen mode Exit fullscreen mode

Functionality that is shared between a few specs (my recommendation is 2/3 or less) should be added to utility modules.

Favour composition over Inheritance

Why? Watch this video.

Consider the below code which uses Inheritance

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
  getInfo(greetStr) {
    return `${greetStr}, I am ${this.firstName} ${this.lastName}.`;
  }
}

class Employee extends Person {
  constructor(firstName, lastName, employeeId) {
    super(firstName, lastName);
    this.employeeId = employeeId;
  }

  getId(greetStr) {
    return `${greetStr}, my employee id is ${this.employeeId}.`;
  }
}
const employee = new Employee('John', 'Doe', 123);
console.log(employee.getInfo('Hi')); // Hi, I am John Doe.
console.log(employee.getId('Hello')); // Hello, my employee id is 123.
Enter fullscreen mode Exit fullscreen mode

The same functionality could be achieved using Composition like so

// We first define all the functions that the classes would have
// e.g. getInfo() and getId()

function getInfo(firstName, lastName, greetStr){
  return `${greetStr}, I am ${firstName} ${lastName}.`
}

function getId(emplyeeId, greetStr){
  return `${greetStr}, my employee id is ${emplyeeId}.`;
}

// Instead of a Person Class we create a function 
// which returns an Object which represents the person. 
// This object is "Composed" of the bindings and functions available to us

function CreatePerson(firstName, lastName){
  return {
    firstName: firstName,
    lastName: lastName,
    getInfo: (greetStr) => getInfo(firstName, lastName, greetStr) 
  }
}

function CreateEmployee(firstName, lastName, employeeId){
  return {
    employeeId: employeeId,
    getId : (greetStr) => getId(employeeId, greetStr),
    getInfo: (greetStr) => getInfo(firstName, lastName, greetStr) 
  };
}

// Notice that the objects returned by CreatePerson 
// and CreateEmployee functions are independent of each other
// (i.e. not bounded by a relation aka inheritance)
// Both the returned objects have a property on them
// whose value happens to be the same function.

let person = CreatePerson('Bla', 'Bla')
let employee = CreateEmployee('John', 'Doe', 123)
console.log( employee.getInfo('Hi')) // Hi, I am John Doe.
console.log( employee.getId('Hello')) // Hello, my employee id is 123.
Enter fullscreen mode Exit fullscreen mode

Functions which return objects are called Factory functions. Watch this video for a better convincing argument on using Factory Functions over classes.

Readability

The main benefit of using Page Object is that it encapsulates the complexity of the UI and locators and thus helping with reusability and making the tests more readable.

For these examples I am using the Cypress TodoMVC Example Repo and refactoring few tests.

Compare the below tests

describe('Todo Application', ()=> {

  it('Can add a new Todo', ()=>{
     cy.get('.new-todo')
      .type('First Todo')
      .type('{enter}') 

     cy.get('.todo-list li')
      .eq(0)
      .find('label')
      .should('contain', 'First Todo')  
  })
})

Enter fullscreen mode Exit fullscreen mode

VS

import {addTodo, getTodoName} from './TodoUtil'

describe('Todo Application', ()=> {

  it('Can add a new Todo', ()=>{
     addTodo('First Todo')
     .then(getTodoName)
     .should('equal', 'First Todo')
  })
})

Enter fullscreen mode Exit fullscreen mode

The second test requires less cognitive load to understand because it's declarative and don't makes us read through the steps of how to add new todo or get it's name.

The addTodo and getTodoName come from the TodoUtil module

// TodoUtil.js
export const addTodo = (name) => {
  cy.get('.new-todo')
    .type(name)
    .type('{enter}') 

  return cy
    .get('.todo-list')
    .eq(0)
}

export const getTodoName = (todo) => {
  return todo.find('label)
}
Enter fullscreen mode Exit fullscreen mode

While the first approach is fine for small and simple scenarios but as the scenarios become more complex or longer a more declarative approach can be a lifesaver.

// The scenario to test that we are able to update the newly created Todo will look like
import {addTodo, getTodoName, updateTodo} from './TodoUtil'

describe('Todo Application', ()=> {

  const TODO_NAME = 'First todo'

it('Can update a newly created todo', ()=>{
    addTodo(TODO_NAME)
    .then(updateTodo(TODO_NAME + ' new'))
    .then(getTodoName)
    .should('equal', TODO_NAME + ' new')

  })
})

Enter fullscreen mode Exit fullscreen mode

The new method updateTodo from TodoUtils.js looks like

export const updateTodo = (name) => ($todo) => {
  cy.wrap($todo).within(() => {
    cy.get('label').dblclick()
    cy.get('.edit').clear().type(`${name}{enter}`)
  })

  return cy.wrap($todo)
Enter fullscreen mode Exit fullscreen mode

But I still love my Page Objects.

Most common arguments against Page Objects are

  1. Page Objects introduce additional state in addition to the system state which makes tests hard to understand.
  2. Using Page object means that all our tests are going to go through the Application's GUI.
  3. Page objects try to fit multiple cases into a uniform interface, falling back to conditional logic.

While the above arguments are all true in my experience, but biggest problem with Page Objects arise due to Selenium's recommendation that "methods should return Page Objects".

Read all recommendations here

Let's try to look at some common situations we find ourselves in when using Page Objects and how to solve them.

Single Responsibility Principle is not met by Page Objects

PO bind unrelated functionality together in one class. e.g. in the below code searchProduct() functionality is not related to login or logout actions.

public class HomePage {
    private final WebDriver webDriver;

    public HomePage(WebDriver webDriver) {
        this.webDriver = webDriver;
    }

    public SignUpPage signUp() {
        webDriver.findElement(By.linkText("Sign up")).click();
        return new SignUpPage(webDriver);
    }

    public void logOut() {
        webDriver.findElement(By.linkText("Log out")).click();
    }

    public LoginPage logIn() {
        webDriver.findElement(By.linkText("Log in")).click();
        return new LoginPage(webDriver);
    }

    public ProductPage searchProduct(String product){
        webDriver.findElement(By.linkText(product)).click();
        return new ProductPage(webDriver);
    }

}

Enter fullscreen mode Exit fullscreen mode

This means that or class does not follow Single Responsiility Princial (SPR).

The Single Responsibility Principal (SRP) states that every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class.

Breaking Page Objects like above into multiple smaller Page Objects does not help with SRP either.

e.g. We could take the Login action outside the HomePage and create a new LoginPage object and use it like below.

LoginPage loginPage = new HomePage().navigateToLoginPage();
loginPage.login("username", "password");
Enter fullscreen mode Exit fullscreen mode

As the actions belong to 2 different pages, this code will repeat in all test cases that use login. The responsibility is not entirely encapsulated.

One way of fixing this is be to define our Class/Module not by the page but by the intent.

// login.js

export const loginAsCustomer = (name, password) => {
}

Enter fullscreen mode Exit fullscreen mode

The loginAsCustomer method can then work through both the Home and Login screens of the application to complete login as a single user action.

📝 If possible, prefer basing your modules on user intent rather than basing them strictly by Page.

Page Order != User Flows

Another place where PO may complicate things is when User flows are not be same as the page order.

Consider the example of a shopping website. Here the user can add an item to the cart either using the Product Page or using the search functionality on the Search page.

From the Cart page the user maybe taken to either the Home page or the Search page on clicking "continue to shop", depending on if the last item was added using the Product Page or the Search Page.

The code for the CartPage class might now look something like this

public class CartPage{       
    Page continueShopping(){
     if(state) {  // determines using which page the last item was added
       return new SearchPage();
     }
     else {
       return new HomePage();
     }    
}

Enter fullscreen mode Exit fullscreen mode

Why is this a problem?

Not only is this code more complex to understand and we have to maintain additional state, it also makes it harder to modify the CartPage if in future another user flow was introduced. This violates the Open/Closed principle (OCP).

The open/closed principle(OCP) states “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”

One way to remove the state logic from our Cart Module is by changing the continueShopping method to not return references to other Classes/Modules and limit it to only clicking the continue shopping link.

// cart.js

export const continueShopping(){
  cy.get('#continue').click();
}

// Test.js

it('user can add item to cart from home page post selecting "continue to shop"', (){
    //.... code to add product to the cart from Product Page
    cartPage.continueShopping();
    homePage.navigateToProductPage();
    productPage.addItemToCart('item2');
})

it('user can add item to cart from search page post selecting "continue to shop"', (){
    //.... code to add product to the cart using Search
    cartPage.continueShopping();
    searchPage.addItemToCart('item');
})
Enter fullscreen mode Exit fullscreen mode

In this example our Test builds user flows just by choosing the right order of calling loosely coupled steps. This means our individual modules did not have to maintain state and state based was removed.

Loosely coupled steps

Need another example of how loosely coupled steps reduce complexity? Consider the below typical LoginPage class.

Business requirement is that on successful login user is taken to "Home Page" and on unsuccessful login use stays on the "Login" page.

class LoginPage {
    HomePage validLogin(String userName,String password){...}
    LoginPage invalidLogin(String userName,String password){...}
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's introduce roles in the mix. An "Admin" on login is taken to the "Admin Dashboard" instead of the Home Page. So we now need to add another method to the LoginPage class and return an instance of the "Admin Dashboard" Page.

class LoginPage {
    HomePage validLogin(String userName,String password){...}
    LoginPage invalidLogin(String userName,String password){...}

    AdminDashboardPage adminValidLogin(String userName,String password){...}
  }
}
Enter fullscreen mode Exit fullscreen mode

More roles will mean even more methods because there is a tight coupling between the pages and the return type.

Let's fix this by not returning references to different pages by the login action.

// login.js
export default const login = (username, password) => {
  cy.get('.username').type(username)
  cy.get('.password').type(password)
  cy.click('.loginButton')
}
Enter fullscreen mode Exit fullscreen mode

Our test will now look like

// Test.js
it('User is taken to Home Page on valid login', ()=> {
   login('abc', '12345')
   cy.title().should('equal', 'Home Page');
})

it('Admin is taken to Admin Dashboard on valid login', ()=> {
   login('admin', '12345')
   cy.title().should('equal', 'Admin Dashboard');
})

Enter fullscreen mode Exit fullscreen mode

Hopefully you can see that preferring Loosely Coupled steps leads to us writing less lines of code with reduced complexity.

If this did not convince you, We recommend this pattern for using page objects in Cypress.

Addendum (2023)

Since this article years ago, I have come to some other realizations.

An abstraction has a cost in readability, sometimes abstraction does more harm than good; selectors growing and being abstracted may be such a case.

Using data attributes (data-cy + a custom selector command) or testing lib, the need for selectors is less and less; we at work never even worry about them at all.

Some of those helpers are only used in 1 test file, in that sense they do not need to be imported from another file, or be a command, they don't even need to be abstracted unless the code is being repeated within the spec file.

Optimizing imports, commands etc. are critical for test startup speed https://www.youtube.com/watch?v=9d7zDR3eyB8 . This means you do not want to overuse commands unless they are being used in many, many places. For that, you might prefer helpers if the code has to be repeated in 2-3 places. The screenshot shows an example of how that might play out in a large app with 1000+ tests.

Image description

Top comments (5)

Collapse
 
pakhilov profile image
Dmitry

I think that good to have this two links here:
eldadu1985.medium.com/8-reasons-pa...
eldadu1985.medium.com/a-case-again...

Collapse
 
bahunov profile image
bahunov

Great post!
I think there's a typo in:
export const addTodo = (name) => {
cy.get('.new-todo')
.type('First Todo') //should be .type(name)?
.type('{enter}')

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

Right! Thanks for the scrutiny. Fixed

Collapse
 
bahunov profile image
bahunov • Edited

Why do you use cy.wrap on updateTodo but not on getTodoName?

Collapse
 
muratkeremozcan profile image
Murat K Ozcan • Edited

for updateTodo, cy.wrap is only needed because of using within:
cy.wrap($todo).within( .... Usually you can just do cy.get(..).within(..) , but we are relying on the jQuery yielded by addTodo, that's why we wrap.

For getTodo, it's relying on the already wrapped jQuery/$todo, so no need to rewrap it.

It can also be explained like this, basically keeping consistent signature (cy.get vs cy.wrap(jQuery) )
:

addTodo(TODO_NAME) -> jQuery is yielded, needs wrapping
.then(updateTodo(TODO_NAME + ' new')) -> we wrap the jQuery and use within, we return a wrapped jQuery
.then(getTodoName) -> takes already wrapped jQuery and returns it
.should('equal', TODO_NAME + ' new') -> here we are still shoulding on wrapped jQuery