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);
});
});
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');
});
});
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;
};
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);
});
});
};
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()
})
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)
})
})
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'])
})
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')
})
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()
})
Top comments (0)