Modern React testing, part 4: Cypress and Cypress Testing Library
Cypress is a framework-agnostic end-to-end testing (also known as E2E, or integration testing) tool for web apps. Together with Cypress Testing Library and Mock Service Worker, it gives the best test writing experience and makes writing good, resilient to changes, tests straightforward.
This is the fourth article in the series, where we learn how to test React apps end-to-end using Cypress and Cypress Testing Library, and how to mock network requests using Mock Service Worker.
Cypress has many benefits over other end-to-end test runners:
The best experience of writing and debugging tests.
Ability to inspect the page at any moment during the test run using the browser developer tools.
All commands wait for the DOM to change when necessary, which simplifies testing async behavior.
Tests better resemble real user behavior. For example, Cypress checks that a button is visible, isn’t disabled, and isn’t hidden behind another element before clicking it.
Supports Chrome, Firefox and Edge.
Cypress Testing Library makes Cypress even better:
Convenient semantic queries, like finding elements by their label text or ARIA role.
Libraries for other frameworks with the same queries.
Testing Library helps us write good tests and makes writing bad tests difficult. It allows us to interact with the app similar to how a real user would do that: for example, find form elements and buttons by their labels. It helps us to avoid testing implementation details, making our tests resilient to code changes that don’t change the behavior.
Cypress, unlike React Testing Library or Enzyme, tests a real app in a real browser, so we need to run our development server before running Cypress. We can run both commands manually in separate terminal windows — good enough for local development — or use start-server-and-test tool to have a single command that we can also use on continuous integration (CI).
As a development server, we can use an actual development server of our app, like Create React App in this case, or another tool like React Styleguidist or Storybook, to test isolated components.
We’ve added two scripts to start Cypress alone:
npm run cypress to open Cypress in the interactive mode, where we can choose which tests to run in which browser;
npm run cypress:headless to run all tests using headless Chrome.
And two scripts to run Create React App development server and Cypress together:
npm run test:e2e to run dev server and Cypress ready for local development;
npm run test:e2e:ci to run dev server and all Cypress tests in headless Chrome, ideal for CI.
Tip For projects using Yarn, change the start-server-and-test commands like so:
Then, create a Cypress config file, cypress.json in the project root folder:
The options are:
baseUrl is the URL of our development server to avoid writing it in every test;
video flag disables video recording on failures — in my experience, videos aren’t useful and take a lot of time to generate.
Now, run npm run cypress to create all the necessary files and some example tests that we can run by pressing “Run all specs” button:
Before we start writing tests, we need to do one more thing — set up Cypress Testing Library. Open cypress/support/index.js, and add the following:
We’re going to use Mock Service Worker (MSW) for mocking network requests in our integration tests, and in the app during development. Cypress has its way of mocking network, but I think MSW has several benefits:
It uses Service Workers, so it intercepts all network requests, no matter how there are made.
A single place to define mocks for the project, with the ability to override responses for particular tests.
An ability to reuse mocks in integration tests and during development.
Requests are still visible in the browser developer tools.
Note The public directory may be different for projects not using Create React App.
Create another JavaScript module that will register our Service Worker with our mocks, src/mocks/browser.js:
And the last step is to start the worker function when we run our app in development mode. Add these lines to our app root module (src/index.js for Create React App):
Now, every time we run our app in development mode or integration tests, network requests will be mocked, without any changes to the application code or tests, except four lines of code in the root module.
By default, Cypress looks for test files inside the cypress/integration/ folder. Feel free to remove the examples/ folder from there — we won’t need it.
Here, we’re visiting the homepage of our app running in the development server, then testing that the text “pizza” is present on the page using Testing Library’s findByText() method and Cypress’s should() matcher.
Run the development server, npm start, and then Cypress, npm run cypress, or run both with npm run test:e2e. From here run a single test or all tests, Cypress will rerun tests on every change in the code of the test.
When I write tests, I usually run a single test, otherwise it’s too slow and too hard to see what’s wrong if there are any issues.
Run npm run test:e2e:ci to run all tests in headless mode, meaning we won’t see the browser window:
Tests should resemble how users interact with the app. That means we shouldn’t rely on implementation details because the implementation can change and we’ll have to update our tests. This also increases the chance of false positives when tests are passing but the actual feature is broken.
Let’s compare different methods of querying DOM elements:
Selector
Recommended
Notes
button
Never
Worst: too generic
.btn.btn-large
Never
Bad: coupled to styles
#main
Never
Bad: avoid IDs in general
[data-testid="cookButton"]
Sometimes
Okay: not visible to the user, but not an implementation detail, use when better options aren’t available
[alt="Chuck Norris"], [role="banner"]
Often
Good: still not visible to users, but already part of the app UI
[children="Cook pizza!"]
Always
Best: visible to the user part of the app UI
To summarize:
Text content may change and we’ll need to update our tests. This may not be a problem if our translation library only render string IDs in tests, or if we want our test to work with the actual text users see in the app.
Test IDs clutter the markup with props we only need in tests. Test IDs are also something that users of our app don’t see: if we remove a label from a button, a test with test ID will still pass.
cy.findByLabelText() finds a form element by its <label>;
cy.findByPlaceholderText() finds a form element by its placeholder text;
cy.findByText() finds an element by its text content;
cy.findByAltText() finds an image by its alt text;
cy.findByTitle() finds an element by its title attribute;
cy.findByDisplayValue() finds a form element by its value;
cy.findByRole() finds an element by its ARIA role;
cy.findByTestId() finds an element by its test ID.
All queries are also available with the findAll* prefix, for example, cy.findAllByLabelText() or cy.findAllByRole().
Let’s see how to use query methods. To select this button in a test:
We can either query it by the test ID:
Or query it by its text content:
Note the regular expression (/cook pizza!/i) instead of a string literal ('Cook pizza!') to make the query more resilient to small tweaks and changes in the content.
Or, the best method, query it by its ARIA role and label:
Benefits of the last method are:
doesn’t clutter the markup with test IDs, that aren’t perceived by users;
doesn’t give false positives when the same text is used in non-interactive content;
makes sure that the button is an actual button element or at least have the button ARIA role.
A typical integration test looks like this: visit the page, interact with it, check the changes on the page after the interaction. For example:
Here we’re finding a link by its ARIA role and text using the Testing Library’s findByRole() method, and clicking it using the Cypress’ click() method. Then we’re verifying that we’re on the correct page by checking its heading, first by finding it the same way we found the link before, and testing with the Cypress’ should() method.
With Cypress we generally don’t have to care if the actions are synchronous or asynchronous: each command will wait for some time for the queried element to appear on the page. Though the code looks synchronous, each cy.* method puts a command into a queue that Cypress executes asynchronously. This avoids flakiness and complexity of asynchronous testing, and keeps the code straightforward.
Also, note calls to the Cypress’ log() method: this is more useful than writing comments because these messages are visible in the command log:
Testing Library allows us to access any form element by its visible or accessible label.
For example, we have a registration form with text inputs, selects, checkboxes and radio buttons. We can test it like this:
Here we’re using Testing Library’s findByLabelText() and findByRole() methods to find elements by their label text or ARIA role. Then we’re using Cypress’ clear(), type(), select() and check() methods to fill the form, and the click() method to submit it by clicking the submit button.
In the previous example, we used the findByLabelText() method to find form elements, which works when all form elements have unique labels, but this isn’t always the case.
For example, we have a passport number section in our registration form where multiple inputs have the same label — like “year” of the issue date and “year” of the expiration date. The markup of each field group looks like so:
To access a particular field, we can select a fieldset by its legend text, and then select an input by its label inside the fieldset.
We call Testing Library’s findByRole() method with group — ARIA role of fieldset — and its legend text.
Any Cypress’ commands we call in the within() callback only affect the part of the page we call within() on.
Cypress doesn’t support multiple tabs, which makes testing links that open in a new tab tricky. There are several ways to test such links:
check the link’s href without clicking it;
remove the target attribute before clicking the link.
Note that with external links, we can only use the first method.
In the first method, we query the link by its ARIA role and text, and verify that the URL in its href attribute is correct:
The main drawback of this method is that we’re not testing that the link is actually clickable. It might be hidden, or might have a click handler that prevents the default browser behavior.
In the second method, we query the link by its ARIA role and text again, remove the target="_blank" attribute to make it open in the same tab, and then click it:
Now we could check that we’re on the correct page by finding some text unique to this page.
I recommend this method because it better resembles the actual user behavior. Unless we have an external link, and the first method is our only choice.
There are a few other solutions, but I don’t think they are any better than these two.
Having MSW mocks setup (see “Setting up Mock Service Worker” above), happy path tests of pages with asynchronous data fetching aren’t different from any other tests.
For example, we have an API that returns a list of pizza ingredients:
Cypress will wait until the data is fetched and rendered on the screen, and thanks to network calls mocking it won’t be long.
For not so happy path tests, we may need to override global mocks inside a particular test. For example, we could test what happens when our API returns an error:
Here, we’re using the MSW’s use() method to override the default mock response for our endpoint during a single test. Also note that we’re using res.once() instead of res(), otherwise the override will be added permanently and we’d have to clean it like this:
We should avoid test IDs wherever possible, and use more semantic queries instead. However, sometimes we need to be more precise. For example, we have a “delete profile” button on our user profile page that shows a confirmation modal with “delete profile” and “cancel” buttons inside. We need to know which of the two delete buttons we’re pressing in our tests.
Here, we’re using Testing Library’s findByRole() method, as in previous examples, to find both “delete profile” buttons. However, for the button inside the modal we’re using findByTestId() and Cypress’s within() method to wrap the findByRole() call and limit its scope to the contents of the modal.
If the UI differs depending on the screen size, like some of the components are rendered in different places, it might be a good idea to run tests for different screen sizes.
With the Cypress’ viewport() method, we can change the viewport size either by specifying exact width and height or using one of the presets, like iphone-x or macbook-15.
However, it’s usually enough to inspect the DOM for a particular step of the test after running the tests. Click any operation in the log to pin it, and the resulting DOM will appear in the main area, where we could use the browser developer tools to inspect any element on the page.
I also often focus a particular test with it.only() to make rerun faster and avoid seeing too many errors while I debug why tests are failing.
I don’t recommend doing this, but on legacy projects we may not have other choices than to increase the timeout for a particular operation. By default Cypress will wait for four seconds for the DOM to be updated. We can change this timeout for every operation. For example, navigation to a new page may be slow, so we can increase the timeout:
This is still better than increasing the global timeout.
Good tests interact with the app similar to how a real user would do that, they don’t test implementation details, and they are resilient to code changes that don’t change the behavior. We’ve learned how to write good end-to-end tests using Cypress and Cypress Testing Library, how to set it up, and how to mock network requests using Mock Service Worker.
However, Cypress has many more features that we haven’t covered in the article, and that may be useful one day.