Test data is a core element of any automated test. It forms the foundation for various scenarios, inputs, and conditions encountered during test execution. Effective test data management empowers testers to verify software functionality with confidence and consistency.
Fortunately, Cypress offers a straightforward and easy-to-implement pattern for fully controlling test data programmatically.
In this blog we’ll explore:
- How to cleanup and create data using cy.request
- How to create Custom API Cypress Commands
- Why controlling data matters
To do this, let’s pretend we are testing a user table and the goal is to validate three basic test cases:
- Create a new user.
- Edit an existing user.
- Delete an existing user.
Let’s begin by examining what an initial attempt at these three tests might look like, starting with creating a new user.
Cleanup Data:
// e2e/userTable.cy.js
beforeEach(() => {
cy.intercept("POST", "/users").as("postUsers");
// Visit the user table
cy.visit('/users')
})
it("should add a new user", () => {
// Populate user table
cy.get(".first-name").type("New");
cy.get(".last-name").type("User");
// Submit new user
cy.get(".submit-button").click();
cy.wait("@postUsers");
// Validate user table
cy.get(".user-table").should("contain", "New User");
});
The test is simple.
We populate the user’s name in a form, submit it, and validate its existence in the table. So, what’s wrong with it?
The issue occurs when the test is reran. If the POST /users
request checks for duplicate users, the endpoint will fail with a 400
response as soon as this test runs again. Additionally, even if our endpoint doesn’t check for duplication, the test will continue dumping identical users into the same table, rendering the creation validation invalid. Due to the test not controlling the state of it’s environment, until the test user is manually removed from the table, the test will fail. So, how can our test guarantee it always creates the same user repeatedly without issue?
Let’s dive into the pattern.
To begin, let’s create an api folder under the default cypress/support
folder. Within that, create a users.js
file. In the users
file, let's create a cleanup helper that checks if our test user exists and deletes it if it does. It should look something like this:
// Support/api/users.js
const getUsers = () =>
cy.request({
method: 'GET',
url: '/users',
})
const deleteUser = ({ userId } = {}) =>
cy.request({
method: 'GET',
url: `/users/${userId}`,
})
const cleanupUserIfExist = () => {
// Fetch all users
getUsers().then(({ response }) => {
const userBody = JSON.parse(response.body)
// Check if our user exists in the environment
const matchedUser = userBody.find((u) => u.full_name === 'New User')
// Remove if found
if (matchedUser) {
deleteUser({ userId: matchedUser.puid })
}
})
}
export default cleanupUserIfExist
Let’s breakdown what’s happening here.
First we fetch all users from the environment using getUsers
, then we check to see if a user exists within the API response that matches our test user. If the user exists, we delete the user. Cypress allows us to make these real API calls using the cy.request command, which is also much faster than deleting the user through the UI and removes any unnecessary front-end dependencies from the test. Finally, the cleanup helper is exported at the bottom of the file and can therefore be imported into the commands.js
file like this:
// suppport/commands.js
import cleanupUserIfExist from './api/users'
/** Cleanup a test user if it exists */
Cypress.Commands.add('cleanupUserIfExist', () => {
Cypress.log({ name: 'cleanupUserIfExist' })
return cleanupUserIfExist()
})
By doing this, a new custom command is created that can be executed within any test spec file by appending cy
to the helper. In our case, this would be cy.cleanupUserIfExists()
. Let's modify the initial test and add the cleanup command now.
// e2e/userTable.cy.js
beforeEach(() => {
// Set the state of the test before visiting the application
cy.cleanupUserIfExist()
cy.intercept("POST", "/users").as("postUsers");
// Visit the user table
cy.visit('/users')
})
it("should add a new user", () => {
// Populate user table
cy.get(".first-name").type("New");
cy.get(".last-name").type("User");
// Submit new user
cy.get(".submit-button").click();
cy.wait("@postUsers");
// Validate user table
cy.get(".user-table").should("contain", "New User");
});
All we’ve done is add one line to the beforeEach
, but that line makes all the difference. The custom command ensures that we are always in a clean state whenever the test executes, allowing us to rerun this test repeatedly. Notice the cleanup also occurs before the test executes, not after. This is a best practice, and more information can be found here.
Creating Data:
We’ve looked at cleaning up data; now let’s examine how data can be programmatically created for the edit and delete users tests. Again, let’s break down what an initial edit user test might look like.
// e2e/userTable.cy.js
beforeEach(() => {
cy.intercept('PATCH', '/users').as('patchUsers')
// Visit the user table
cy.visit('/users')
})
it('should edit an existing user', () => {
// Grab the user and edit
cy.get('.user-table')
.contains('Edit User')
.parent()
.find('.edit-button')
.click()
// Edit the user
cy.get('.first-name').clear().type('Edit')
cy.get('.last-name').clear().type('Person')
// Submit new user
cy.get('.submit-button').click()
cy.wait('@patchUsers')
// Validate user table
cy.get('.user-table').should('contain', 'Edit Person')
})
The biggest issue with this test is that it is entirely dependent on a specific user existing in the environment when it’s executed. If the test environment doesn’t contain Edit User
, this test will fail. It’s unnecessarily data-dependent. Furthermore, this test faces a similar issue in which it is unable to be rerun due to the underlying, update data manipulation.
Fortunately, let’s create a createUser
method following a similar approach from before in our support/api/users.js
file.
// Support/api/users.js
const createUser = () =>
cy.request({
method: 'POST',
url: '/users',
body: {
first_name: 'Edit',
last_name: 'User'
}
})
// Rest of file ...
export { cleanupUserIfExist, createUser }
Similarly to before, cy.request
allows us to create a real user that the test can edit through the application's API. This is a decent start, but what if another test uses this same hard-coded user, or better yet, how can we guarantee that our test data will always be unique? An easy way to accomplish this is by taking advantage of random, faker data and persisting our user response in a Cypress.env
variable.
// Support/api/users.js
import { faker } from '@faker-js/faker'
const createUser = () =>
cy.request({
method: 'POST',
url: '/users',
body: {
first_name: faker.person.firstName(),
last_name: faker.person.lastName()
}
}).then(({ body }) => Cypress.env('currentUserBody', body))
// Rest of file ...
export { cleanupUserIfExist, createUser }
By replacing the static name variables with random data, it guarantees our user will always be unique each run, avoiding any possibility of conflict with other tests. Also, by storing the real, user response in a Cypress.env
variable the dynamic user data can now be accessed within the test file. Let’s wrap the createUser
method in a custom command again and refactor the edit user test.
// suppport/commands.js
import { cleanupUserIfExist, createUser } from './api/users'
/** Create a test user */
Cypress.Commands.add('createUser', () => {
Cypress.log({ name: 'createUser' })
return createUser()
})
// Rest of file ...
// e2e/userTable.cy.js
import { faker } from '@faker-js/faker'
beforeEach(() => {
cy.intercept('PATCH', '/users').as('patchUsers')
// Ensure a user exists
cy.createUser()
// Visit the user table
cy.visit('/users')
})
it('should edit an existing user', () => {
const { first_name, last_name } = Cypress.env('currentUserBody')
const newFirst = faker.person.firstName()
const newLast = faker.person.lastName()
// Grab the user and edit
cy.get('.user-table')
.contains(`${first_name} ${last_name}`)
.parent()
.find('.edit-button')
.click()
// Edit the user
cy.get('.first-name').clear().type(newFirst)
cy.get('.last-name').clear().type(newLast)
// Submit new user
cy.get('.submit-button').click()
cy.wait('@patchUsers')
// Validate user table
cy.get('.user-table').should('contain', `${newFirst} ${newLast}`)
})
Since the cy.createUser()
custom command is called in the beforeEach
, when the it hook begins, the Cypress.env('currentUserBody')
contains the real, dynamically created user. Then by destructuring the response object, our test is always editing a new, randomly created user each time. Finally, faker can also be used in the update data to keep the new user random too.
This same pattern can also be used in the delete user test.
// e2e/userTable.cy.js
beforeEach(() => {
cy.intercept('DELETE', '/users').as('deleteUsers')
// Ensure a user exists
cy.createUser()
// Visit the user table
cy.visit('/users')
})
it('should delete an existing user', () => {
const { first_name, last_name } = Cypress.env('currentUserBody')
// Grab the user and delete
cy.get('.user-table')
.contains(`${first_name} ${last_name}`)
.parent()
.find('.delete-button')
.click()
// Confirm the deletion
cy.get('.confirm-button').click()
cy.wait('@deleteUsers')
// Validate user no longer exists
cy.get('.user-table').should('not.contain', `${first_name} ${last_name}`)
})
The result looks similar. A user is created programmatically, the data is persisted in an environment variable which then drives what user will be deleted in the test. To reiterate, the data setup occurs in our setup hook, beforeEach
, guaranteeing the the environment will always be in the correct state before the test begins.
Now that these three tests are refactored, the data in our test is no longer a variable determining if the test will succeed or not.
Summary:
To recap, the benefits of programmatically controlling test data are:
- Guarantees consistent results every time
- Ensure tests are able to run (and rerun) against any environment
- Ensure tests are able to run in parallel without conflict
- Setup state fast and remove unnecessary UI dependencies
By leveraging Cypress’s capabilities, we can overcome the limitations of manual test data management and environmental, data-dependencies. Ensuring resilience in our test suites is critical and using this pattern enables reaching full confidence in our results. Thank you for reading.
Happy Testing!
Top comments (0)