DEV Community

Cover image for Cypress: (Help)ers Wanted — When and Where to Use Them
David Ingraham
David Ingraham

Posted on

Cypress: (Help)ers Wanted — When and Where to Use Them

When organizing your Cypress helpers, should they go in a utility file, become a custom command or remain inline? Do I even need a helper, or can I repeat the functionality in multiple specs? These questions might seem small, but they’re crucial as your test suite grows.

As your automation test suite grows, organizing helpers becomes critical. Without proper structure, you risk creating inconsistent implementations, duplicated code, and increased maintenance overhead. These challenges lead to slower development cycles and decrease confidence in the test suite’s reliability. A well-structured and predictable framework enables multiple engineers to work collaboratively, ensuring consistency across tests. But how do you know where to define a test helper?

Before we get to the where, let’s discuss the why. Helpers in automation testing service two primary purposes:

  • Encapsulating common or repeated operations to reduce duplicated code.
  • Improving readability by abstracting complex or lengthy logic.

As a result of this, all helpers you write should address one or both of these benefits. Reducing duplication — following the Don’t Repeat Yourself(DRY) principle — is a common software development practice that should carry over into automation testing. While DRY can be taken to extremes (where maintaining the abstraction becomes more work than its value), automation testers should focus on removing duplicated code to improve maintainability, scalability, and predictability.

Additionally, some helpers might just be useful for readability. For example, instead of populating a form with ten (or more) fields directly in a test, you might prefer a single call to populateNewUserForm(). This abstraction makes the test cleaner and easier to understand at a glance. In the example below the test is easy to read at a high-level due to the helper but still provides the user easy access to it if needed.

const populateNewUserForm = () => {
  cy.get('first-name-input').type('David');
  cy.get('last-name-input').type('Ingraham');
  cy.get('gender-select').select('male');
  cy.get('email-input').type('test@gmail.com');
  cy.get('password-input').type('happy testing');
  cy.get('address-input').type('123 Testing Street');
  cy.get('address-2-input').type('Unit 1');
  cy.get('city-input').type('Denver');
  cy.get('state-select').select('Colorado');
  cy.get('zip-input').type('12345');
};

describe('Create User', () => {
  it('should create a user', () => {
    cy.intercept('user', 'POST').as('postUser');
    cy.visit('users');

    // Populate new user form
    cy.get('create-user-button').click();
    populateNewUserForm();

    // Submit User
    cy.get('submit-button').click();
    cy.wait('@postUser').then(({ response }) => {
      expect(response?.statusCode).to.eq(201);
    });
  });
Enter fullscreen mode Exit fullscreen mode

Once you’ve identified the use for a helper, the next step is deciding where to define it. In Cypress, there are three primary options, each suited for different scenarios.


Single Spec

Helpers that are only relevant to one test or spec file should be defined within that file. This keeps the scope limited and avoids unnecessary abstraction.

An example of this might be a populate helper unique to a single spec or a re-used piece of logic unique to that file. We can expand our example above and add another single, spec helper called waitForUserTableToLoad since it’s used multiple times only for this one test.

const populateNewUserForm = () => {
  cy.get('first-name-input').type('David');
  cy.get('last-name-input').type('Ingraham');
  cy.get('gender-select').select('male');
  cy.get('email-input').type('test@gmail.com');
  cy.get('password-input').type('happy testing');

  cy.get('address-input').type('123 Testing Street');
  cy.get('address-2-input').type('Unit 1');
  cy.get('city-input').type('Denver');
  cy.get('state-select').select('Colorado');
  cy.get('zip-input').type('12345');
};

const waitForUserTableToLoad = () => {
  cy.wait('getUsers');
  cy.get('user-table-loading-spinner').should('not.exist');
  cy.get('user-table').should('be.visible');
};

describe('Create User', () => {
  it('should add a user', () => {
    cy.intercept('user', 'GET').as('getUsers');
    cy.intercept('user', 'POST').as('postUser');

    cy.visit('users');
    waitForUserTableToLoad();

    // Populate new user form
    cy.get('create-user-button').click();
    populateNewUserForm();

    // Submit User
    cy.get('submit-button').click();
    cy.wait('@postUser').then(({ response }) => {
      expect(response?.statusCode).to.eq(201);

      waitForUserTableToLoad();
      cy.get('user-table').contains('David Ingraham');

      // Validate user persists after reload
      cy.reload();
      waitForUserTableToLoad();
      cy.get('user-table').contains('David Ingraham');
    });
  });
Enter fullscreen mode Exit fullscreen mode

The helper reduces duplicated code while keeping the test readability, two traits that are once again crucial for maintaining a test suite as it grows. If either one of these helpers are needed across multiple specs, then you might want to consider moving it to an external helper file instead.


Helper File

If a helper is used across multiple spec files, it should be defined or moved to a shared helpers or utils folder within your Cypress project. This centralizes logic, making it easier to maintain and update. An example of a shared helper could include anything ranging from re-used setup code, validations or calculations.

// Helper.ts

// Shared Navigation Helper
export const navigateToUserPage = () => {
  cy.intercept('user', 'GET').as('getUsers');

  cy.login() // Login - Custom Command
  cy.visit('users');
  cy.wait('@getUsers');
};

// Shared Validation Helper
export const validateUserTable = (user: User) => {
  const { firstName, lastName } = user;

  cy.get('user-table').contains(`${firstName} ${lastName}`);
};

// Shared Calculation Helper
export const calculateTotal = (price: number) => {
  const tax = 0.12;
  const shipping = 5.99;
  const priceTax = tax * price;
  return price + shipping + priceTax;
};
Enter fullscreen mode Exit fullscreen mode

All three of these examples are now available to be imported into multiple specs across our test suite. If any issues or updates occur, updating them is a breeze as we only have to make changes in a single place without worrying about touching multiple test files.


Custom Commands

Finally, Cypress is unique because it provides a built-in helper called a Custom Command. They are ideal for helpers that represent common actions, validations or setup that’s shared across a majority or all specs and tests. Cypress Commands offer several advantages over plain javascript helper files including:

  • Global Availability: No need to import them into every spec file.
  • Chaining Support: Ability to chain off other Cypress Commands.
  • Return Values: Able to return a value using .then

If I have the following login function and defined it as both a Cypress Command and a Javascript helper, it might look like this.

// Custom Command 
Cypress.Commands.add('login', (user) => {
  const { username, password } = user;
  cy.session(username, () => {
    cy.request({
      method: 'POST',
      url: '/login',
      body: { username, password },
    }).then(({ body }) => {
      window.localStorage.setItem('authToken', body.token);
    });
  });
});

// JS Helper
export const login = (user) => {
  cy.session(username, () => {
    cy.request({
      method: 'POST',
      url: '/login',
      body: { username, password },
    }).then(({ body }) => {
      window.localStorage.setItem('authToken', body.token);
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

While the code is nearly identical, utilizing this code in a spec is a bit different. For a Cypress Custom Command, I have access to my login command by calling cy.login as it’s now globally available to all my files versus having to import my login JS helper across all specs. Further, if my login helper returns user information, I can easily access it by doing:

it('some test', () => {
  // You CAN do this
  cy.login((userInfo) => {
      // If I need to use `userInfo` in my tests, I can do so
  })

  // You CANT do this
  const userInfo = login() 
})
Enter fullscreen mode Exit fullscreen mode

Accessing a returned value isn’t possible with a regular helper due to the asynchronous nature of how Cypress works.

Note: Cypress also has the option to create Custom Queries. These are type of command that follow a slightly different set of rules than Custom Commands. These won’t be discussed specifically, but they are available as needed.

The Custom Commands I define fall into one of the following three categories API, Validation and Action.


API Custom Commands

These are helpers that simply wrap API calls which are needed for test setup. Programmatically controlling test data using cy.request is a best practice for maintaining test data reliably. Defining this setup in a custom command allows any tests that utilize it to access any created API data as needed. For example, let’s define a Custom Command that programmatically creates a user for our test and allows us to access the created user response for further validation and use.

// Create User POST Custom Command
Cypress.Commands.add('createUser', () => {
    Cypress.log({ message: 'createUser' })

    return cy.request({
      method: 'POST',
      url: '/user',
      body: { username, password },
    }).then(({ response }) => response.body
});

// Test Example
it('should edit an existing user', () => {
  // Programatically create the user and fetch the response 
  cy.createUser().then((createdUser) => {
    const { firstName } = createdUser

     // Use the real response to validate application data
     cy.get('edit-user-button').click()
     cy.get('first-name-input').should('have.value', firstName)
  })
})
Enter fullscreen mode Exit fullscreen mode

For more information on this pattern, check out my article here.


Validation Custom Commands

These type of custom commands are shared assertions reused across all test files. The benefit of a custom command is that these might be validations that can be chained off a previous cypress selector command.

An example of this could be validateMenuTabLists which is a helper that validates a particular page has the correct tab list and text. The implementation might look something like this:

// Validation Custom Command 
Cypress.Commands.add('validateMenuListTabs', { prevSubject: 'element'}, (subject, tabs) => {
  // Validate list tab length
  cy.wrap(subject).find('[role="tab"']).should('have.length.of', tabs.length)
  // Validate individual tab texts
  cy.wrap(subject)
    .find('[role="tab"]')
    .each((tab, index) => {
      cy.wrap(tab).should('contain', tabs[index])
    })
})

// Test Example
it('should validate user page', () => {
  cy.get('user-page-menu-list').validateMenuListTabs(['My Info', 'Billing', 'Settings'])
})
Enter fullscreen mode Exit fullscreen mode

This is extremely valuable as validating any tab list in my application is now simple and accessible. Defining common validations is powerful because you additionally gain confidence that all your elements are verified the same.


Action Custom Commands

Finally, action command are those that do something throughout the test.

A popular example might be defining a custom, cy.get command such as getByTestId. In this example, the command forces the user to grab an element using a custom, data-test attribute instead of other methods. Another example could be defining a custom dropdown, select helper for a particular framework such as selectPrimeVueOption for the PrimeVue component library. Defining custom commands that slightly tweak the default Cypress command behavior to work with your preferred framework and patterns provides a huge benefit across all of your tests.

// Action Custom Command - Customized Get
Cypress.Commands.add('getByDataTest', (dataTest, options) => {
  return cy.get(`[data-test=${dataTest}]`, options)
})

// Action Custom Command - Customized Dropdown Select
Cypress.Commands.add('selectPrimeVueOption', { prevSubject: 'element'}, (subject, option) => {
  cy.wrap(subject).scrollIntoView()
  cy.wrap(subject).find('[data-pc-section="dropdown"]').as('dropdownButton')
  cy.get('@dropdownButton').click() // expand dropdown

  cy.get('.p-select-list').within(() => {
      cy.contains(option).click() // Select dropdown option
  })
})

// Test Example
it('should select user data', () => {
  cy.getByDataTest('user-age-select').selectPrimeVueOption('30')
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Wherever you put your helper, it should always enhance your test suite’s maintainability, scaleability and readability. Good helpers provide these benefits while poorly structured helpers add unnecessary complexity. Ultimately though, there’s not one right answer, just suggested organization. Use your best judgement based on the scope of impact and level of effort to maintain the helper if something changes. Having this awareness and purpose for all your Single Spec helpers, Helper Files, or Custom Commands, creates a robust and organized Cypress test suite that grows gracefully with your application and sets your team up for long-term success.

const closingMessage = () => {
  console.log('Thanks for Reading, Happy Testing.')
}

it('connect with me on LinkedIn and say hi', () => {
   closingMessage()
})
Enter fullscreen mode Exit fullscreen mode

Top comments (0)