Testing React Apps In 2022 With CypressAn In-Depth Guide For Beginners

Johannes KettmannPublished on 

You know that you should write tests for your React applications.

But reality shows that most Junior developers never have written a test. And to be honest, it doesn’t feel easy to start. You have to decide between a gazillion tools. Writing tests feels very different than writing your normal frontend code. It’s awkward. And frustrating. Strange errors everywhere that you don’t know how to debug.

But once you get used to it you won’t wanna miss it. You’ll feel much safer because code changes won’t break your app. No need for endless manual tests anymore. Not only that: Writing tests is best practice in many companies and lets you stand out as a candidate if you’re looking for your first developer job.

And turns out, it doesn’t have to be that hard. If you pick the right tools to start with and get a bit of guidance.

On this page, you can find exactly that:

  • We cover why Cypress is a great tool (especially for developers who are new to testing).
  • We start by writing a few simple UI tests.
  • We move on to more complex tests involving data from API requests.
  • Finally we learn how to debug our way through problems.

But hold on. This is not a theoretical lesson where you simply lean back and enjoy the content. I prepared a project that you can use to follow along. It’s a Next.js app that needs some testing love. We’ll write a few tests together but in the end, I’ll leave a few test cases for you to implement on your own.

The goal is that you leave this tutorial with the skills to start testing your own applications. This is your chance to get hands-on testing experience! I hope you’re thrilled.

Introduction

Why Cypress

As with everything JavaScript, there are tons of testing tools in the React and web dev ecosystem. Some are outdated (like Enzyme). Some are super popular but somewhat hard on beginners (like React Testing Library). And one testing tool is popular in real-world projects AND has a relatively low learning curve:

Cypress

The big advantage and reason why I recommend it to beginners is the level of visual feedback and interactivity.

The problem with other testing frameworks like RTL is that

  1. Writing tests feels different from your usual frontend code.
  2. All the output is in the terminal while you’re used to looking at the browser for feedback.
  3. The debugging experience is vastly different. More like debugging Node.js servers than React apps.

With Cypress, you only have the first point.

Once you get used to writing tests you can transfer this knowledge to other frameworks like RTL and get comfortable with the debugging experience there. And that’s surely a good idea since most tests are likely written with React Testing Library nowadays.

But one step at a time. Let me show you why Cypress is so powerful. On your local machine, it has a separate UI where all the tests are run. Next to a browser window where you can watch your tests step by step!

You can click on each step of the tests and get some additional information. You can even open the Chrome dev tools in the browser, read the console output, inspect elements, and use the debugger.

I hope the advantages are clear. You’re likely used to a lot of this stuff already. So it’s mostly Cypress and its API that you have to get used to. On top of that Cypress is super easy to set up. In your project you can simply run

npm install --save-dev cypress

That's it.

The Project Used In This Tutorial

As I mentioned, I created a Next.js app that we'll use as an existing codebase to cover with tests on this page. It's an error tracking tool similar to Sentry or Rollbar. You can find the running app here.

The app belongs to the React Job Simulator program where you can learn and apply professional React dev skills like testing.

I personally am a learning-by-doing type. So I highly recommend following along with this tutorial. From my experience just reading only gets you this far. But applying the newly gained knowledge will make it stick. To get the source code drop your email below.

What To Test

In this tutorial we will test two things: The navigation bar (see screenshot below) and a list of issues that you can see here.

Let’s start with the navigation bar.

With Cypress (as well as React Testing Library) we typically test an application the same way a user would interact with it. For example, we click on an element and see if the browser shows the correct result.

The purpose of a test is to ensure that a feature works correctly. Not that a component gets the right props or a certain callback was run. That’s not what the user sees or is interested in. Shortly it’s irrelevant.

There are lots of things that we could potentially test. But it’s a good idea to focus on the important parts of a feature’s functionality.

In this case, the sidebar navigation serves its purpose if

  • the links send the user to the correct page
  • the navigation bar is collapsible.

These are relatively simple UI tests that we'll implement in the following two sections. Testing the issues page is a bit more complex as it involves API requests. We'll have a look at that in the 4th section.

First, let's focus on the links in the navigation. Here is one way of testing them:

  1. We get the links in the navigation.
  2. We click each link.
  3. We check the URL to ensure that the correct page has opened.

Opening A URL With Cypress

First, we need to do some setting up. Before each test, we need to open the application from within Cypress (here the home page works well). This is done via the cy.visit() command.

Open the file cypress/integration/navigation.spec.ts and add the following code:

describe("Sidebar Navigation", () => {
beforeEach(() => {
cy.visit("http://localhost:3000/dashboard");
});
});

But how do we get the links? First, it’s a good idea to get an overview of the navigation DOM structure. In our normal browser, we can open the dev tools and inspect the navigation.

Accessing An Element Globally

All the links are grouped in a nav tag.

Since there are other links on the page it’s smart to start with that group. We can access an element with the cy.get() command. This command accepts a selector as a parameter (the tag nav here) and returns all corresponding elements on the website.

describe("Sidebar Navigation", () => {
beforeEach(() => {
cy.visit("http://localhost:3000/dashboard");
});
it("links are working", () => {
cy.get("nav");
});
});

Easy. Now let’s double-check that everything’s working fine.

In the Cypress UI, we click on navigation.spec.ts. This should run the tests which looks like this:

If Cypress doesn’t find the element in question it throws an error. But the test passes so everything is alright. Accessing the nav element via cy.get("nav") worked.

Switching Viewports

But wait. Where is the navigation bar?

Have a close look at the Cypress browser. This is the mobile view. And on mobile the navigation is hidden.

That means we first need to set the viewport to desktop. That can be done with the cy.viewport() command.

describe("Sidebar Navigation", () => {
beforeEach(() => {
cy.viewport(1025, 900);
cy.visit("http://localhost:3000/dashboard");
});
it("links are working", () => {
cy.get("nav");
});
});

When you save the file the test should automatically re-run. Now our Cypress UI should show this:

Accessing A Specific Element By Text

Now let’s test the first link. Since the “Projects” link points to the route / (which is the current route) we test the “Issues” link first.

The problem is that there are multiple links in the navigation. The cy.get() command that we used before doesn't work well in this case. It would get all links on the whole page. But we want a specific one. The one inside the navigation that says “Issues”.

To get an element by its text content we can use the contains() command. To make it even more specific we can chain it with the get() command.

it("links are working", () => {
// check that issues link leads to the right page
cy.get("nav").contains("Issues");
});

Interacting With Elements - Clicking

Now we should have access to the "Issues" link and can interact with it. To click the link we can simply use the click() command.

it("links are working", () => {
// check that issues link leads to the right page
cy.get("nav").contains("Issues").click();
});

The test should run again and hopefully pass.

Cypress Helps With Debugging

It’s time for some Cypress magic. In the screenshot above do you see what happens when you hover over the “click” step on the left? It highlights the element that was clicked in the browser.

This is super helpful to understand what’s going on and debug issues.

Accessing The URL

OK, looks like everything is fine for now.

The last step to test the link is to check that the correct page is opened. As mentioned above we can simply check the URL. We can use the cy.url() command and test its value with the should() command.

it("links are working", () => {
// check that issues link leads to the right page
cy.get("nav").contains("Issues").click();
cy.url().should("eq", "http://localhost:3000/dashboard/issues");
});

The test passes. Everything is as expected, yay!

Exercise

Now is your chance to get your hands dirty. We created a test for the first link here. But there are a few links that need to be tested as well:

  • Projects
  • Alerts
  • Users
  • Settings

Note: You can add all these tests to the same it("links are working", ...) test case.

Testing That The Navigation Is Collapsible

Now let’s test a dynamic part of the sidebar navigation. When you click the”Collapse” button at the bottom left (either on your local machine or here) you should see the navigation bar collapse.

Clicking A Button

We start with another it(...) wrapper. Same as in the tests before we get the nav element and find the “Collapse” button inside using cy.get() and cy.contains(). Finally we click it.

it("is collapsible", () => {
// collapse navigation
cy.get("nav").contains("Collapse").click();
});

When you check the Cypress UI the test passes and the navigation is collapsed. Everything seems to work fine. Again you can hover on each of the steps on the left to see what happened in the UI.

What To Test

Now how do we test that the navigation is collapsed and ensure that it works? There are two things that seem sufficient to me:

  1. Check that all links are rendered and work correctly.
  2. Check that the text next to the icon is hidden.

Let’s start with the links. Since we tested each link’s target in the previous test I don’t think we need to do that again. Instead we can check that the correct number of links is rendered and that any of these is works correctly.

To get all links, we can use the cy.find() command.

it("is collapsible", () => {
// collapse navigation
cy.get("nav").contains("Collapse").click();
// check that links still exist
cy.get("nav").find("a").should("have.length", 5);
});

Now we can take any one of these links and check that it still leads the user to the correct page. To access a specific element from an array Cypress provides the first(), eq(), or last() commands.

cy.first() doesn’t work in our case since that would be the “Projects” link which is the current page. The test would pass even if the link didn’t do anything. cy.last() seems a bit more fragile. It would break if we’d add another link to the navigation.

So let’s settle for cy.eq(). This would still break if we change the order of the links but seems like the least fragile option.

it("is collapsible", () => {
// collapse navigation
cy.get("nav").contains("Collapse").click();
// check that links still exist and are functional
cy.get("nav").find("a").should("have.length", 5).eq(1).click();
cy.url().should("eq", "http://localhost:3000/dashboard/issues");
});

Testing That Elements Are Hidden

Finally, let’s check that the user only sees the icons and not the text next to them. Testing one link should be the same as they’re handled by a single component. So let’s use the “Issues” link.

it("is collapsible", () => {
// collapse navigation
cy.get("nav").contains("Collapse").click();
// check that links still exist and are functional
cy.get("nav").find("a").should("have.length", 5).eq(1).click();
cy.url().should("eq", "http://localhost:3000/dashboard/issues");
// check that text is not rendered
cy.get("nav").contains("Issues").should("not.exist");
});

Great, we added our first simple UI tests. There are other things that we could test (like the navigation on mobile devices). But more on that later. Let’s continue with testing data-driven components.

Testing Pages With API Requests

If you visit the app’s projects page and look carefully (or with a sufficiently slow internet connection) you can see this loading state. This indicates that the page loads data from an API.

To be fair, this is not the best loading indicator but that’s a task for the React Job Simulator.

Once the data is loaded we see the projects rendered on the page.

What To Test

Now, how do we test this data?

Again like a user would use the app. We get the elements on the page (here the project cards) and check that the correct values are rendered.

First, let's again get an overview of the DOM structure. When you inspect the first project card in your dev tools you’ll see this:

We can see that each project card is wrapped in a li tag. This seems like a good selector for our tests.

Accessing Elements Within A Parent

The problem is that we know from the previous tests that there are li elements in the navigation bar as well. So when we try to access the project cards we need to be careful not to grab the navigation links.

The easiest way to prevent this is to identify a suitable parent element of the project cards and access this first. Here the main element seems like a good fit since the navigation is rendered outside of it.

That’s enough information for now. Open the file cypress/integration/project-list.spec.ts and add the following code.

describe("Project List", () => {
beforeEach(() => {
cy.viewport(1025, 900);
cy.visit("http://localhost:3000/dashboard");
});
it("renders the projects", () => {
// get all project cards
cy.get("main")
.find("li")
});
});

Iterating Over A List Of Elements

Now we can make use of the each($el ⇒ …) command to iterate through this list. The first parameter $el of the callback function allows us to access each element.

So to test that e.g. the first card contains the project title “Frontend - Web” it makes sense to simply try to write $el.contains("Frontend - Web"). Unfortunately, this gives us an error.

The type of the parameter $el is JQuery<HTMLElement>. And this doesn’t support the Cypress commands like contains().

Looking at the docs there is a simple solution though: we can just wrap $el with cy.wrap() and access all Cypress commands as we’re used to.

So let’s test the first project card. For now, we simply hard-code the expected values.

describe("Project List", () => {
beforeEach(() => {
cy.viewport(1025, 900);
cy.visit("http://localhost:3000/dashboard");
});
it("renders the projects", () => {
// get all project cards
cy.get("main")
.find("li")
.each(($el, index) => {
// only test the first project card
if (index === 0) {
cy.wrap($el).contains("Frontend - Web");
cy.wrap($el).contains("React");
cy.wrap($el).contains("73");
cy.wrap($el).contains("12");
cy.wrap($el).contains("Error");
cy.wrap($el).find("a").should("have.attr", "href", "/issues");
}
});
});
});

Ok, all tests pass. That tells us that the overall approach works. But obviously hard-coding the expected data and wrapping it in an if clause isn’t really an option. There are two problems:

  1. The tests would get unnecessarily long.
  2. What happens when the data returned by the server changes? The tests would fail.

Detour: Different Types Of Testing

In order to make these tests pass reliably, we need to always have the same data. There are two ways of achieving that

  • we have a server that always delivers the expected data
  • we mock the responses

In larger projects, you often find test environments that include databases, servers, and so on. This is a great way to test the application from the database to the frontend. These tests are also called “end-to-end test”.

Here we don’t have such a test server so we’ll mock the responses. This way we only test the frontend isolated from the rest of the system. This is called an integration test.

If you want to read more about the different types of testing and their advantages read this awesome blog post by Kent C. Dodds. As a summary, he argues that integration tests usually get you the most bang for the buck. They are relatively performant (better than end-to-end tests but worse than unit tests) and give you good confidence that your application is functioning (less than e2e but more than unit tests).

Note that Cypress is probably not the best tool to use for perfomant tests. That would rather be Jest + React Testing Library. But as I said earlier, it’s a better option for beginners due to the easy setup and visual feedback.

Mocking Requests/Responses With Cypress

So we decided to write integration tests by mocking out our API requests. The first step is preparing our mock data. There are a few options:

  • Write the data by hand.
  • Use a tool to create random data from a schema.
  • Use existing API data.

In our case, we already have a working API. So we can simply copy our mock data from one of the requests. The easiest way to access this data is via the browser’s dev tools:

To get data from any request in Chrome simply follow these steps:

  1. Open the “Network” tab.
  2. Filter the requests by “Fetch/XHR”.
  3. Click on the “project” request.
  4. Open the “Preview” tab.
  5. Right-click below the data and click “Copy object”.

Cypress has a separate folder for mock data at cypress/fixtures. A fixture in our case is simply the mock data that makes our tests repeatable. Create a file in that folder called projects.json and paste the data you copied above there.

Mocking the response to this request is now simple with the cy.intercept() command. You can get the URL of the API endpoint again from the network tab in the browser’s dev tools.

beforeEach(() => {
// setup request mock
cy.intercept("GET", "https://prolog-api.profy.dev/project", {
fixture: "projects.json",
}).as("getProjects");
// set desktop viewport
cy.viewport(1025, 900);
cy.visit("http://localhost:3000/dashboard");
// wait for the projects response
cy.wait("@getProjects");
});

In the above code we also use the as() command to create an alias. We can use this alias to wait for the response before the tests start. This is useful to prevent the tests from randomly failing due to timing issues (aka making them less “flaky”).

When you re-run the test now you should receive the mock data. You can double-check that the test doesn’t send real requests either by

  • changing something inside the mock data or
  • checking the request in the Cypress UI (as shown in the screenshot below).

Asserting Against The Mock Data

Remember that we hard-coded the expected data for the first project card in the test? Now we can replace it with the data from our fixture.

import capitalize from "lodash/capitalize";
import mockProjects from "../fixtures/projects.json";
import { ProjectLanguage } from "features/projects/types/project.types";
describe("Project List", () => {
beforeEach(() => {
// setup request mock
cy.intercept("GET", "https://prolog-api.profy.dev/project", {
fixture: "projects.json",
}).as("getProjects");
cy.viewport(1025, 900);
cy.visit("http://localhost:3000/dashboard");
cy.wait("@getProjects");
});
it("renders the projects", () => {
const languageNames = {
[ProjectLanguage.react]: "React",
[ProjectLanguage.node]: "Node.js",
[ProjectLanguage.python]: "Python",
};
// get all project cards
cy.get("main")
.find("li")
.each(($el, index) => {
const projectLanguage = mockProjects[index].language as ProjectLanguage;
// check that project data is rendered
cy.wrap($el).contains(mockProjects[index].name);
cy.wrap($el).contains(languageNames[projectLanguage]);
cy.wrap($el).contains(mockProjects[index].numIssues);
cy.wrap($el).contains(mockProjects[index].numEvents24h);
cy.wrap($el).contains(capitalize(mockProjects[index].status));
cy.wrap($el).find("a").should("have.attr", "href", "/issues");
});
});
});

Note that we need to add some additional logic. The reason is that in some cases, the data is changed by the frontend before displaying it to the user. For example, the project language can be node in the response data but will be displayed as Node.js.

Debugging Broken Tests

When you write your own tests you will run into problems at some point. So it’s important that you know how to debug your tests.

I prepared an example for a failing test in the file cypress/integration/issue-list.spec.ts for us to debug together.

it("renders the issues", () => {
cy.get("main")
.find("tr")
.each(($el, index) => {
const issue = mockIssues1.items[index];
const firstLineOfStackTrace = issue.stack.split("\n")[1].trim();
cy.wrap($el).contains(issue.name);
cy.wrap($el).contains(issue.message);
cy.wrap($el).contains(issue.numEvents);
cy.wrap($el).contains(firstLineOfStackTrace);
});
});

This looks pretty similar to the test for the project pages. It’s supposed to check that the data inside the issues table is rendered correctly.

But when you run it you’ll see that there’s an error:

Note: you can tell Cypress to only run a certain test or set of tests by appending .only to describe or it. This way you don’t have to wait for all tests to run while you’re working on a single test. In our case, we can write it.only("renders the issues", () => {.

The error above looks strange: Cannot read properties of undefined (reading 'stack'). It’s not immediately clear what that means.

So let’s dig deeper. But how to debug your tests?

Using Chrome Dev Tools

The answer is simple: You can do the same things that you can do in your normal Chrome browser as well. For example:

  • Inspect DOM elements
  • Print out variables with console.log() from either your code or your tests
  • Add breakpoints to stop code execution

In our case, we can start by investigating the test file. We could add console.log() statements in the code. But let’s be a bit more professional and use the debugger.

Setting Breakpoints

First, open the dev tools inside your Cypress UI. You can do that either with your usual key combination or by right-clicking somewhere in the browser window and clicking “Inspect”

Next click on the “Sources” tab. You should see a hint on how to open a file (in my case + P).

Now you start typing the file name you want to investigate. In this case, “issue-list” should be sufficient to get the correct results.

Note that you can also open the React component (”issue-list.tsx”) which means you can also debug your application code

Once you opened the file you can add a breakpoint. The error message at the left of the Cypress UI tells us that something is wrong in the .each loop. So let’s start there:

Note that I only added one breakpoint. The second appeared by itself. I guess that’s because internally we debug a JS file that is mapped back to its original TS source.

Now we can re-run the test by pressing the refresh button inside the top bar. The code execution should stop within the .each() loop.

Inpsecting Variables

Now isn’t this magic?

We can inspect the variables (specifically $el ) in the right-side menu. We can already see from the class name that this is the header row of the table. But when we hover over the class name it even highlights the element in the UI above. 🤯

The problem is clear now (at least to me since I wrote the test): We wanted to test that the data inside the table is rendered correctly. But the first item we get is the header row which doesn’t contain any data.

Inspecting DOM Elements

As we’ve done before we need to narrow down the search for the correct elements. Let’s look at the DOM structure again to see our options. When you inspect the table with your dev tools you can see this:

The table rows that contain the data are wrapped in a <tbody> tag. So we can use this information to filter out the table header row from our test:

it("renders the issues", () => {
cy.get("main")
.find("tbody")
.find("tr")
.each(($el, index) => {
const issue = mockIssues1.items[index];
const firstLineOfStackTrace = issue.stack.split("\n")[1].trim();
cy.wrap($el).contains(issue.name);
cy.wrap($el).contains(issue.message);
cy.wrap($el).contains(issue.numEvents);
cy.wrap($el).contains(firstLineOfStackTrace);
});
});

The test should pass now.

Your Turn - More Exercises

If your fingers are itchy and you want to try this stuff out yourself here is the good news: There are a few test cases left that you can implement yourself.

  1. The issue list has a pagination at the bottom. You can test that the buttons are working correctly and that the right data is rendered.
  2. The page number of this pagination should persist after a refresh.
  3. On mobile devices, the sidebar navigation should not be visible initially and be toggled by the menu button in the header.

Note that the last test is a bit more complex. Cypress doesn’t have a native way to test if an element is outside the viewport. I found this GitHub issue to be helpful.

If you want to try these exercises you can either implement the tests against the deployed version of the app that I shared at the beginning of the page. Or you drop you email below to get access to the source code. There you can also find my implementations of these tests.