End-to-End Testing React Applications with Cypress
Cypress has been creating a lot of buzz in the JavaScript ecosystem recently, promising a simple yet powerful API for writing integration and end-to-end tests. With an open source core written in JavaScript, you can test anything that runs in a browser, regardless of language or framework (think Reason, WASM, or Svelte). Cypress is unique in that it operates in the same run-loop as your application—client code and test code are embedded in separate iframe
s right next to each other and communicate over websockets. Pretty clever!
When it comes to end-to-end testing React applications, Cypress is rapidly emerging as the community standard. While nothing about Cypress is React-specific, the design of its APIs pairs uniquely well with the nuances of React's reconciliation process and virtual DOM. In this post we'll dig into how Cypress works with React, focusing specifically on how it addresses the challenges of DOM-based testing and manipulation in the era of asynchronous web applications.
The DOM is Unstable
I've spent a lot of time perusing the Cypress docs recently while setting up end-to-end tests for clients, and I love the section on Conditional Testing. The central problem outlined concerns the instability of the DOM in modern client-side web applications. In React, changes to application state trigger a reconciliation of the DOM by way of the diffing algorithm. For human users, the reconciliation process is barely perceptible in even moderately performant React applications. Other visual cues like loading spinners let us know that elements aren't "interact-able" or that data is being fetched. For test runners like Cypress or Puppeteer, however, this instability can be a problem.
Test runners attempting to access and operate on DOM nodes that haven't yet been mounted or are in the midst of an update may error out. Moreover, discrepancies across hardware and memory resources between developer machines and CI environments mean your application may run differently for different users. This leads to test "flake" and a handful of "works on my machine" scenarios that are hard to debug.
Cypress takes care of a lot of test flake for you via built-in "retry-ability", in which the test runner recursively tries commands until it reaches a timeout. For example, if you attempt to access a DOM node via cy.get
that takes, let's say, a full second to mount, Cypress will keep querying the DOM automatically for you until it finds the node. This is awesome, because we as developers get to leave retry boilerplate to Cypress and instead focus on writing reproducible, resilient tests. However, even automatic retry may not save us in the most unstable stage of a React application—the initial render.
Dealing with Initial Render
A lot of applications do some pretty heavy lifting on initial render. In addition to mounting the entire component tree, users are being authorized, data is being fetched from multiple remote resources, and React is, well, reacting to all these changes. Cypress isn't aware of this instability; once it receives the load
event from your application, it'll start running your tests. How can we tell Cypress to wait until the DOM has reached a more settled state? One strategy is to use our network traffic as a proxy for DOM stability using cy.wait
.
cy.wait
allows us to specify particular network requests for Cypress to "wait" on. For example, if our application makes a request to our API's /users
endpoint, Cypress will wait to execute subsequent commands until it's received a successful response from /users
. To get this working you'll need to setup cy.server
to intercept all requests from and responses to your app, and cy.route
to specify the route to listen for. Here's how this might look in a simple test suite:
describe('App', () => { beforeEach(() => { // setup cy.server to intercept network requests cy.server(); // alias the /users endpoint to @users cy.route('/users').as('users'); // wait until /users has responded cy.wait('@users'); }); it('should not run until a response has been received from /users', () => { // Start accessing the DOM and running tests! cy.get('[data-test-id="node-under-test"]) .click(); }); });
So why does this strategy work? For most React applications data fetching will be initiated after first mount, either in componentDidMount
or inside of a useEffect
call if you're using hooks. This means React has likely finished its initial render of the tree, your API requests have been triggered, and most DOM nodes have been mounted. By the time the /users
endpoint has responded to your application's request, it's a pretty safe bet that React has finished most of its initial rendering work. The great thing about cy.wait
is that it also accepts an array of aliased routes—in the event that your initial render relies on more than one network request, you can give React additional time to complete its reconciliation. Combined with Cypress's support for custom commands, you can abstract this nicely. Let's look at an example using TypeScript:
// cypress/support/commands.ts declare global { namespace Cypress { interface Chainable { // add our custom command to Cypress's namespace visitAndWait: typeof visitAndWait; } } } interface Endpoint { route: string | RegExp; alias: string; } function visitAndWait(appRoute: string, endpoints: Endpoint[]) { // setup cy.server to intercept network requests cy.server(); // iterate over endpoints and alias them endpoints.forEach(({ route, alias }) => { cy.route(route).as(alias); }); // visit a page in your application cy.visit(appRoute); // wait for all of the endpoints to respond before beginning tests cy.wait(endpoints.map(({ alias }) => `@${alias}`)); } // register the custom command with Cypress Cypress.Commands.add("visitAndWait", visitAndWait);
// cypress/integration/app.spec.ts describe('App', () => { beforeEach(() => { cy.visitAndLoad( '/home', [ { route: '/users', alias: 'users' }, { route: /(\/order)(\?.*)?/g}, alias: 'order' }, { route: '/home/**', alias: 'home' } ] ) }); it('should not run until a response has been received from /users, /order, and /home', () => { // Start accessing the DOM and running tests! }); });
Awesome! By using the network as a proxy for DOM stability we can normalize differences in memory and computing power across different environments and work cleanly with React's reconciler and virtual DOM. This makes our end-to-end tests more reliable, meaning that test failures become better indicators of something amok in our application.
Beyond REST
While the above strategy works well if your application is solely comprised of calls to a REST API, it breaks down a little bit for another popular transport protocol—WebSockets. The Cypress docs are a bit cryptic on WebSocket support.
By this, the Cypress team means that you can still run end-to-end tests against an application backed by WebSockets. However, you're not able to cy.wait
on a WebSocket connection or mock its return values in any way. More precisely, this means you can't wait
until the client and server have completed their handshake and the first frame has been received from the WebSocket server. If your React application depends on data returned by those frames to render specific UI that you want to test, cy.wait
can't really help you. So what can we do here? We can implement retry-ability ourselves with simple recursion!
Let's say, for example, we want to get an element with data-test-id="modal"
. The node is mounted in our application with a default value of "Loading..."
and populates with data once the WebSocket has begun sending frames. How could we get a handle on this element and only run assertions once we're certain it has data? We could check the inner text of the element recursively every 500ms or so until the value is no longer the default of "Loading..."
.
const MAX_RETRIES = 5; const TIMEOUT = 500; function selectModal(retryCount = 0) { cy.get('[data-test-id="modal"']).then((el) => { // wrap the element as a Cypress.Chainable so we can chain further commands cy.wrap(el) // grab the element's text content .invoke('text') .then((val) => { // if we hit the max retries, throw an error if (retryCount > MAX_RETRIES) { throw new Error( `Element could not be found after ${TIMEOUT * MAX_RETRIES} ms` ); // if the element still says loading, wait 500ms and recurse } else if (((val as unknown) as string) === 'Loading...') { cy.wait(500); return selectModal(retryCount++); // if the element is found, return the element (base case) } else { return cy.wrap(el) } }); }); } describe('App', () => { it('should not run assertions until the value is populated', () => { selectModal() .then((modal) => { // begin running assertions on the modal }).catch((err) => { // catch the max retry error }); }); });
This strategy isn't half bad and acts like a naive implementation of what Cypress does already under the hood. However, this should be treated as somewhat of a last resort. You may get more value out of integration testing components that rely on WebSockets rather than spinning up a full end-to-end test for them. I'd definitely recommend the awesome react-testing-library
for such cases.
Go Forth and Test (Reliably)
In this post, we've dug into the challenges of end-to-end testing with asynchronously rendered React applications. As we head into the era of Suspense
and Concurrent Mode in React, we're only going to see our applications become more asynchronous. Cypress' APIs give us great tools to check our DOM for relative stability before running end to end tests, which gives us more confidence in our tests in the long run. We've had great success with Cypress both in client work and open source here at Formidable, and we think you will too!
Thanks to my wonderful colleagues Amy Dickson, Mariano Martinez III, and Brian Mathews for reviewing this piece.