August 11th, 2021

Building Cypress End to End Tests in TypeScript




Cypress is an end to end (e2e) testing framework written in JavaScript for front-end applications. Cypress tests run in a Chrome web browser or a headless browser, navigating through and interacting with web pages. It's reasonable to compare Cypress to other test automation frameworks such as Selenium or Puppeteer; however, unlike those frameworks, Cypress was created specifically for writing end to end tests. Because of its test first design, Cypress provides lots of features that make writing end to end tests easy. It is currently my preferred end to end testing tool.

While Cypress tests can be written in JavaScript, as is the case with my application, Cypress also has TypeScript support. Nowadays, I write most of my front-end applications in TypeScript due to its type safety. In my experience, TypeScript helps reduce the number of bugs and type mismatch mistakes in JavaScript code. For applications written in TypeScript, it is great to be able to write Cypress tests in TypeScript as well. This helps keep the programming language uniform across the front-end application code and test code.

In this article, I discuss the basics of Cypress and how I use it to test my front-end applications. All the Cypress code I show is written in TypeScript. The Cypress test code comes from two of my application repositories: apollo-client-server-prototype and saints-xctf-web.

End to End Testing

End to end (E2E) testing is the practice of writing tests for an application from one end to another, from beginning to end. In essence, these tests cover the entire execution flow of an application in a production-like environment. In the case of a front-end application, this means testing the application by interacting with the UI as an actual user would. Because these tests are executed in a production-like environment, all the API calls used by the front-end code are invoked as part of the tests. This results in the application being executed from end to end - from the front-end to the back-end and database.

With Cypress, end to end tests are run in a web browser. Tests navigate to a web page and make assertions about the content shown. Then, they can interact with the page and make further assertions about what changed in the UI, check what network calls were made, and more.

Cypress is installed using its cypress npm package. Cypress is pretty easy to configure within an application. For example, my application uses Cypress and is configured to use it with TypeScript. For starters, its repository saints-xctf-web is written as an npm package, with cypress defined as a dev dependency in its package.json file. Next, a cypress.json file exists in the root directory of the npm package. This is the main configuration file for Cypress, with the contents of my configuration file shown below.

{ "baseUrl": "http://localhost:8090", "ignoreTestFiles": ["*.md"], "env": { ... } }

Although the fields of the configuration file are optional, they do help simplify tests and configure the settings of Cypress to fit your needs1. In my configuration file, baseUrl configures the default base URL to use for all my tests, so that I don't need to copy-paste it across all my test cases. ignoreTestFiles configures the Cypress test runner to ignore any Markdown files in the test directory, in my case which are used for documentation purposes. Finally, env sets environment variables which are accessible to the Cypress test code. Cypress has many additional configuration options listed in their documentation.

The actual Cypress test code is written in a cypress directory located in the root of the npm package. The cypress folder and its subdirectories are created as a scaffold automatically when Cypress first runs in the project, so there is no need to create these manually. Inside cypress are four subdirectories - fixtures, integration, plugins, and support - along with a tsconfig.json file to configure TypeScript.

The integration directory holds the Cypress end to end tests. In my case, these tests are TypeScript files with .e2e.test.ts file extensions. For example, the Cypress tests for the home page of my website are located in a Home.e2e.test.ts file.

The fixtures directory contains static assets used by the tests. The majority of the files in my fixtures directory are JSON files representing mocked API responses. In certain end to end tests, it's preferable to use fixed API responses instead of ones from an actual live API. This is often due to the unpredictability of API data, and a need to test specific repeatable scenarios. It's also due to the requirement of testing failure scenarios on the website, such as when an API returns an unexpected error code. These scenarios are easily achieved by mocking the API responses with the JSON files found in the fixtures directory. The other static assets found in my fixtures directory are pictures, which are used for testing profile picture and group picture uploading on my website.

The plugins directory contains a single index.js JavaScript file that runs in Node.js before the Cypress tests begin2. This file is used to configure first-party and third-party tooling which extends the functionality of Cypress. For example, my plugins file configures a code coverage plugin to generate code coverage reports for my end to end tests3. Code coverage reports require an additional @cypress/code-coverage dev dependency in the package.json file.

The support directory contains custom commands used throughout Cypress tests. Custom commands contain reusable code, making the Cypress tests more concise and DRY. The index.ts file in the support directory is run automatically before every Cypress test file, so that the custom commands don't need to be manually imported into all the test files4. There is a whole section of this article dedicated to writing custom commands in Cypress.

The tsconfig.json file in the cypress folder creates and configures a TypeScript project for the Cypress tests, separate from the TypeScript project for the source code. The most important property of the JSON configuration is compilerOptions.types, specifying that only type definitions specific to Cypress should be used in the test code5.

{ "compilerOptions": { "strict": true, "baseUrl": "../node_modules", "target": "ES5", "lib": ["ES5", "DOM"], "types": ["cypress", "cypress-file-upload"] }, "include": ["**/*.ts"] }

There are two main ways to run Cypress tests: in a web browser and in a headless browser. Inside my package.json file, I specify multiple custom commands for running Cypress tests. The first command, cypress open (aliased as cy:open), opens the Cypress test runner and allows test files to be run in a web browser. The test runner is shown below.

The test runner lists all the end to end test files, referred to as specs, which exist in the integration directory. Clicking any of the file names opens a web browser and begins running the tests.

In the screenshot above, all three tests passed. While the tests run, the Cypress commands that execute are displayed on the left hand side of the browser. On the right hand side, the current state of the website in the test is shown. This is great for seeing tests execute on the UI, and allows for easy debugging if tests are failing.

The other way to run tests is with a headless browser. Headless browsers provide an execution environment similar to a web browser, except they run from a command line6. Executing Cypress tests in headless browsers is great for CI/CD pipelines which run tests in an automated fashion. The cypress run --headless command (aliased as cy:headless) runs all the Cypress tests in a headless browser.

In the remainder of this article, I walk you through Cypress test cases and reusable Cypress functions. All these examples are used in the end to end tests for my website.

Let's start with some basic examples which test the SaintsXCTF home page.

Cypress test code is built on top of the Mocha testing framework and the Chai assertion library. Therefore, engineers familiar with these libraries will recognize the coding syntax right away. Cypress test specs often have a similar layout to the following specification for my website's home page.

describe('Home E2E Tests', () => { beforeEach(() => { cy.visit('/'); }); it('loads the home page as expected', () => { cy.get('.sxctf-home').contains('Cross Country and Track & Field Team Exercise Logs').should('exist'); }); ... });

The home page's end to end test spec is located in a Home.e2e.test.ts file. The describe() function gives a name to the test suite with its first argument and defines all the test cases along with pre-test and post-test hook methods with its second argument. In my example, the test suite is named "Home E2E Tests". beforeEach() is a pre-test hook which executes before every test case. beforeEach() executes one built-in Cypress command, cy.visit('/');, which navigates the browser to the home page of my website before each test.

The final piece of code in the snippet above is an it() function, which defines a test case. The test is given the name "loads the home page as expected" and executes a few Cypress commands. Most Cypress tests utilize function chaining, and this basic example is no different. First, the call to cy.get('.sxctf-home') gets any HTML elements with the class sxctf-home. The result could be zero, one, or many HTML elements. Next, the result of this function is chained with .contains('Cross Country and Track & Field Team Exercise Logs'). The contains() function narrows down the HTML elements retrieved with the cy.get() call to only those elements that contain the text "Cross Country and Track & Field Team Exercise Logs". Finally, the result of this function is chained with .should('exist'). This ensures that at least one HTML element exists with a sxctf-home class and the text "Cross Country and Track & Field Team Exercise Logs". If no elements match these two requirements, the Cypress test fails.

Let's look at a few more basic tests used on my application's home page.

it("'about' header button navigates down to the 'about' section", () => { cy.get('.aboutButton').click(); cy.url().should('include', '/#about'); });

This test takes an action, clicking on an HTML element with an aboutButton class. The test then asserts that the URL of the webpage has changed to /#about, signaling that the user was navigated down the page to the "About" section.

The website under test is mobile-responsive, and I have a separate file for mobile end to end tests called

describe('Home Mobile E2E Tests', () => { beforeEach(() => { cy.viewport(400, 800); cy.visit('/'); }); it("'About' navbar dropdown link navigates down to the 'About' section", () => { cy.url().should('equal', `${Cypress.config('baseUrl')}/`); cy.get('.sxctf-nav-hamburger').click(); cy.get('.aj-nav-list-item').contains('About').click(); cy.url().should('equal', `${Cypress.config('baseUrl')}/#about`); }); ... });

The mobile home page test suite has a beforeEach() hook function similar to the one in Home.e2e.test.ts, except this time it also changes the viewport to a mobile phone screen size. This is achieved with the Cypress command cy.viewport(400, 800) which changes the viewport width to 400px and the viewport height to 800px.

The test shown in this code snippet is logically equivalent to the "About" section test discussed prior. This time, a mobile dropdown is clicked to navigate to the "About" section instead of a button in the website header.

Now let's look at some of the advanced Cypress tests written in my end to end test suite. Don't worry too much about the tiny code details; instead, focus on the big picture of what Cypress is capable of.

Let's look at a test that creates a new exercise log. This test exists in a NewLog.e2e.test.ts file.

it('able to create a new running exercise log', () => { cy.route('POST', '/api/v2/logs/').as('createLog'); const formattedDate = moment().format('YYYY-MM-DD'); const finalFormattedDate = moment().format('MMM. Do, YYYY'); cy.visit('/log/new'); cy.get('.sxctf-image-input input[name="name"]').type('Test Run'); cy.get('.sxctf-image-input input[name="location"]').type('New York, NY'); cy.get('.sxctf-image-input input[name="date"]').type(formattedDate); cy.get('.sxctf-image-input input[name="distance"]').type('5'); cy.get('.sxctf-image-input input[name="time"]').type('3625'); cy.get('textarea').type('Running Log Generated from E2E Tests'); cy.get('button').contains('Create').click(); cy.wait('@createLog'); cy.getDataCy('alert').should('exist'); cy.getDataCy('alert').should('contain.text', 'Exercise log created!'); // The success message should disappear after 4 seconds. cy.wait(4000); cy.getDataCy('alert').should('not.exist'); cy.get('.dashboardButton').click(); cy.url().should('equal', `${Cypress.config('baseUrl')}/dashboard`); cy.get('#logFeed .exerciseLog').should('have.length', 10); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogUser').should('contain.text', 'Andy Jarombek'); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogTitle').should('contain.text', 'Test Run'); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogDate').should('contain.text', finalFormattedDate); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogType').should('contain.text', 'RUN'); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogLocation').should('contain.text', 'New York, NY'); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogDistance').should('contain.text', '5 miles'); cy.get('#logFeed .exerciseLog').eq(0).findDataCy('exerciseLogTimePace').should('contain.text', '36:25 (7:17/mi)'); cy.get('#logFeed .exerciseLog').eq(0).should('have.class', 'average'); cy.get('#logFeed .exerciseLog') .eq(0) .findDataCy('exerciseLogDescription') .should('contain.text', 'Running Log Generated from E2E Tests'); });

The test starts by filling out an exercise log creation form with the help of the Cypress type() commands. This code snippet also demonstrates how other JavaScript libraries, such as the moment.js date/time library, can be used in Cypress tests.

cy.get('button').contains('Create').click() clicks a button which makes an API call to create a new exercise log. The Cypress test waits for this API call to complete with the cy.wait('@createLog') command. @createLog is an alias for a HTTP route. It is defined at the beginning of the test with the cy.route('POST', '/api/v2/logs/').as('createLog') command. After the exercise log is created, the Cypress test navigates back to the dashboard page of the website and makes sure the new exercise log appears.

Another advanced Cypress test is one for a monthly calendar tab on a user's profile page. This test exists in a NewLog.e2e.test.ts file.

it('has a tab with a calendar of monthly exercise logs', () => { cy.visit('/profile/andy'); cy.profileMockAPICalls(); cy.createRangeViewRoute('rangeViewCurrentMonthRoute', currentMonthRangeItems, 0, 'month', true); cy.get('.tabs p').contains('Monthly Calendar').click(); cy.wait('@rangeViewCurrentMonthRoute'); const calendarMonth = moment().format('MMMM YYYY'); cy.getDataCy('currentMonth').should('contain.text', calendarMonth); cy.calendarWeekCheck(0, [null, 5.39, 5.83, 8.64, 5.96, 8.75, 9.12], 43.69); cy.calendarWeekCheck(1, [2.89, 5.89, 5.94, 11.96, 5.97, '8.80', 14.01], 55.46); cy.calendarWeekCheck(2, [2.94, 4.55, 6.28, 6.52, 6.58, '12.00', null], 38.87); cy.calendarWeekCheck(3, [null, null, null, null, null, null, null], '0.00'); cy.calendarWeekCheck(4, [null, null, null, null, null, null, null], '0.00'); cy.calendarWeekCheck(5, [null, null, null, null, null, null, null], '0.00'); // Go to the previous month. cy.createRangeViewRoute('rangeViewPreviousMonthRoute', prevMonthRangeItems, 1, 'month', true); cy.getDataCy('prevMonth').click(); cy.wait('@rangeViewPreviousMonthRoute'); const prevCalendarMonth = moment().subtract(1, 'month').format('MMMM YYYY'); cy.getDataCy('currentMonth').should('contain.text', prevCalendarMonth); cy.calendarWeekCheck(0, [null, 5.42, 5.36, '5.40', 6.51, 6.01, 12.23], 40.93); cy.calendarWeekCheck(1, [2.82, 5.42, 5.38, '5.40', 7.11, '6.00', 13.27], '45.40'); cy.calendarWeekCheck(2, [2.86, 5.44, 5.43, '5.40', 5.43, 11.27, 13.21], 49.04); cy.calendarWeekCheck(3, [5.38, 2.83, 5.36, '5.40', 6.49, 6.02, 7.02], '38.50'); cy.calendarWeekCheck(4, [null, 5.39, 5.83, 8.64, 5.96, 8.75, 9.12], 43.69); cy.calendarWeekCheck(5, [2.89, 5.89, 5.94, 11.96, 5.97, '8.80', 14.01], 55.46); // Return to the current month. cy.getDataCy('nextMonth').click(); cy.getDataCy('currentMonth').should('contain.text', calendarMonth); cy.calendarWeekCheck(0, [null, 5.39, 5.83, 8.64, 5.96, 8.75, 9.12], 43.69); cy.calendarWeekCheck(1, [2.89, 5.89, 5.94, 11.96, 5.97, '8.80', 14.01], 55.46); cy.calendarWeekCheck(2, [2.94, 4.55, 6.28, 6.52, 6.58, '12.00', null], 38.87); cy.calendarWeekCheck(3, [null, null, null, null, null, null, null], '0.00'); cy.calendarWeekCheck(4, [null, null, null, null, null, null, null], '0.00'); cy.calendarWeekCheck(5, [null, null, null, null, null, null, null], '0.00'); });

The test navigates to the profile page of user andy and clicks on the Monthly Calendar tab. From there, the Cypress test asserts that the calendar shows the proper month and exercise mileage statistics.

The monthly calendar Cypress test utilizes multiple custom Cypress commands. These include cy.profileMockAPICalls() (located in profile.ts and profile.d.ts), cy.createRangeViewRoute() (located in shared.ts and shared.d.ts), cy.calendarWeekCheck() (located in shared.ts and shared.d.ts), and cy.getDataCy() (located in commands.ts and commands.d.ts). I discuss custom Cypress commands in the next section of this article, but this code demonstrates how they can greatly simplify end to end tests.

For example, cy.calendarWeekCheck() checks the mileage for each day in a week on the calendar. cy.calendarWeekCheck(0, [null, 5.39, 5.83, 8.64, 5.96, 8.75, 9.12], 43.69) looks at the first week in the calendar, and asserts that the total mileage is 43.69. It iterates over each day, checking that the first day displays no mileage, the second day displays 5.39 miles, the third day displays 5.83 miles, etc.

This custom Cypress command is called six times for each month. Since the test changes the month displayed on the calendar multiple times, cy.calendarWeekCheck() is invoked almost 20 times. Placing the logic for testing a week of calendar data in a reusable Cypress command saved many lines of code!

One final advanced Cypress test checks what happens to the website if an API request fails. Specifically, it checks the scenario where adding a user to a group in the website fails. This test exists in a GroupAdmin.mock.e2e.test.ts file.

it('shows an error if adding a pending user fails', () => { cy.andyAdminMemberships(); const groupAlumniMembersRoute = cy.route({ method: 'GET', url: '**/api/v2/groups/members/1', response: '@groupAlumniAdminPendingMembers' });'groupAlumniMembersRoute'); cy.visit('/admin/group/1'); cy.alumniGroupAdminMockAPICalls(); const groupMembersUpdateErrorRoute = cy.route({ method: 'PUT', url: '**/api/v2/groups/members/1/Tom', response: { self: '/v2/groups/members/1/Tom', updated: false, group_member: null, error: 'The group membership failed to update.' }, status: 500 });'groupMembersUpdateErrorRoute'); cy.getDataCy('alert').should('not.exist'); cy.getDataCy('pendingMember').eq(0).find('.aj-contained-button').should('contain.text', 'Accept').click(); cy.getDataCy('acceptDenyModal').find('.aj-contained-button').contains('ACCEPT MEMBERSHIP').click(); cy.wait('@groupMembersUpdateErrorRoute'); cy.getDataCy('alert').should('exist'); cy.getDataCy('alert').should( 'contain.text', 'An unexpected error occurred while accepting a users membership request. ' + 'Try reloading the page. If this error persists, contact' ); cy.getDataCy('alert').getDataCy('alertCloseIcon').click(); cy.getDataCy('alert').should('not.exist'); });

The main objective of this test is to prove that an error message is displayed to the user during an unexpected API failure scenario.

The test starts by navigating to the webpage and mocking the API calls. This test does not directly call the API, due to its specific data requirements. It also needs to simulate a failed API call. The failed API call is defined in the statement which creates the groupMembersUpdateErrorRoute variable. The function call to cy.route() defines a custom API response and sets the HTTP status code to 500, which is an error code for an internal server error. Whenever an HTTP PUT request to a URL matching the pattern **/api/v2/groups/members/1/Tom is made, Cypress intercepts it and returns the custom API response and status code.

The API route with the mocked response is given an alias groupMembersUpdateErrorRoute. When attempting to accept a user into a group, the test ensures that this mocked API call is invoked with the command cy.wait('@groupMembersUpdateErrorRoute'). This command runs after the Accept Membership button is clicked.

After the mocked API call is invoked, the test asserts that the webpage displays an alert message explaining the error.

Cypress allows you to easily create custom commands or overwrite existing commands. This helps make tests more readable and promotes the creation of reusable test code. I use custom Cypress commands extensively in my end to end tests. Custom commands are found in the support subdirectory of my repositories cypress directory.

Cypress.Commands.add() creates a new Cypress command7. The following code creates a basic command named setUserInLocalStorage which puts a JSON string representing a user in the web browser's local storage. It exists in my commands.ts file, which contains custom commands used throughout the end to end tests.

Cypress.Commands.add('setUserInLocalStorage', () => { localStorage.setItem( 'user', JSON.stringify({ isFetching: false, didInvalidate: false, lastUpdated: 1596919187, activation_code: 'abc123', class_year: 2017, deleted: null, description: 'I sometimes like to run.', email: '', favorite_event: 'Shakeout', first: 'Andy', last: 'Jarombek', last_signin: '2020-08-13 12:53:18', location: 'Riverside, CT', member_since: '2016-12-23', password: null, salt: null, subscribed: null, username: 'andy', week_start: 'monday' }) ); });

Cypress.Commands.add() takes two arguments: the name of the custom command and the function body of the custom command. Since my Cypress code is written in TypeScript, it also needs a type definition for the custom command. A type definition prevents type errors when using custom commands, and also serves as useful documentation. Each of my TypeScript files containing custom commands has a corresponding *.d.ts file, containing the type definitions. For example, my commands.ts file has a corresponding commands.d.ts file, with the following type definition for my custom setUserInLocalStorage command.

declare namespace Cypress { interface Chainable { /** * Custom command to put user details in the browser's localStorage. * @example setUserInLocalStorage() */ setUserInLocalStorage(): void; ... } }

This code adds the custom command type to the global Cypress Chainable interface8, making it accessible in TypeScript Cypress tests. In my end to end tests, the setUserInLocalStorage command is commonly used in pre-test hook functions, such as the following code snippet from Dashboard.e2e.test.ts.

describe('Dashboard E2E Tests', () => { beforeEach(() => { cy.setUserInLocalStorage(); ... }); ... }

Most of my custom Cypress commands encapsulate other Cypress commands that assert information about HTML elements. In a prior example, I showed a Cypress test which looked at a monthly calendar of exercise data. One of the custom Cypress commands used in that test, calendarWeekCheck(), is shown below. It exists in a shared.ts file.

Cypress.Commands.add( 'calendarWeekCheck', (week: number, miles: (number | string | null)[], totalMiles: number | string) => { cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(0) .should(miles[0] ? 'contain.text' : 'not.contain.text', miles[0] ? `${miles[0]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(1) .should(miles[1] ? 'contain.text' : 'not.contain.text', miles[1] ? `${miles[1]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(2) .should(miles[2] ? 'contain.text' : 'not.contain.text', miles[2] ? `${miles[2]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(3) .should(miles[3] ? 'contain.text' : 'not.contain.text', miles[3] ? `${miles[3]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(4) .should(miles[4] ? 'contain.text' : 'not.contain.text', miles[4] ? `${miles[4]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(5) .should(miles[5] ? 'contain.text' : 'not.contain.text', miles[5] ? `${miles[5]}Miles` : 'Miles'); cy.getDataCy('week') .eq(week) .findDataCy('day') .eq(6) .should(miles[6] ? 'contain.text' : 'not.contain.text', miles[6] ? `${miles[6]}Miles` : 'Miles'); cy.getDataCy('week').eq(week).findDataCy('weekTotal').should('contain.text', `${totalMiles}Miles`); } );

This command goes through the seven days in the week and checks what text is displayed for each day in the calendar. While most of the sub-commands in the calendarWeekCheck() command are built-in Cypress commands, two are not: getDataCy() and findDataCy().

Cypress.Commands.add('getDataCy', (value) => { return cy.get(`[data-cypress=${value}]`); }); Cypress.Commands.add('findDataCy', { prevSubject: true }, (subject, value) => { return cy.wrap(subject).find(`[data-cypress=${value}]`); });

Both getDataCy() and findDataCy() retrieve HTML elements with data attributes named data-cypress. The value of the data attribute is passed to the Cypress commands through a value parameter. One thing you may have noticed is that getDataCy() and findDataCy() have return values, while the prior two custom Cypress commands did not. As previously stated, Cypress commands are built to be chained together. By returning the HTML elements with data-cypress data attributes, both these custom Cypress commands can be chained with regular Cypress commands. You can see this in the calendarWeekCheck() command code.

The chainable properties of getDataCy() and findDataCy() are made clear by looking at their type definitions in commands.d.ts, specifically their return types of Chainable<Element>.

/** * Chainable function for getting elements with a data-cypress attribute. * @param value The value of the data attribute on an HTML element. * @example getDataCy('button') */ getDataCy(value: string): Chainable<Element>; /** * Child chainable function for finding elements with a data-cypress attribute. * @param value The value of the data attribute on an HTML element. * @example findDataCy('button') */ findDataCy(value: string): Chainable<Element>;

Another thing you may have noticed is that the findDataCy command is defined with an additional { prevSubject: true } value. prevSubject configures a command to either be a parent command or a child command9. By default, prevSubject is assigned the value false, which means it is a parent command that doesn't take a value from a previous command. In other words, a parent command always appears at the beginning of a Cypress command chain. Unlike the previous commands I've shown which implicitly use the default value of prevSubject, findDataCy is explicitly configured to be a child command. This means it is always used in the middle or the end of a Cypress command chain. Look closely at the definition of findDataCy and you will see it takes an additional subject argument, which is the result of a prior Cypress command. In this case, the prior result is a list of HTML elements.

As a final custom Cypress command example, I want to show that custom commands don't necessarily have to just use built-in commands and other custom commands for their implementations. The following setTokenInLocalStorage custom command makes an API call to my applications authentication API, and then sets the authentication token response in the browsers local storage and in an environment variable.

import axios from 'axios'; import * as moment from 'moment'; Cypress.Commands.add('setTokenInLocalStorage', () => { const existingToken = Cypress.env('authToken'); const existingTokenExpiration = Cypress.env('authTokenExpiration'); if (existingToken && moment(existingTokenExpiration) > moment()) { cy.log('Using existing token'); cy.log(`Existing token expires ${existingTokenExpiration}`); localStorage.setItem('token', existingToken); return; } cy.log('Retrieving new auth token'); const instance = axios.create({ baseURL: Cypress.env('authUrl'), timeout: 5000 }); instance .post('/token', { clientId: 'andy', clientSecret: Cypress.env('SXCTF_PASSWORD') }) .then((res) => { const token =; Cypress.env('authToken', token); const tokenExpiration = moment().add('45', 'minutes').format(); Cypress.env('authTokenExpiration', tokenExpiration); localStorage.setItem('token', token); }) .catch((error) => {; }); });

All the custom commands shown in this article and many more are available on GitHub in my saints-xctf-web repository.

Cypress is my go-to end to end testing library. It works great with both JavaScript and TypeScript, and has an easy to learn and extensible API. The ability to run Cypress tests in a web browser and watch the tests interact with a web page is a fantastic feature that makes testing easy, even for newcomers to the front-end engineering space. All my Cypress test code is available in my apollo-client-server-prototype and saints-xctf-web repositories on GitHub.