E2E & API testing (finally) made simple with Cypress

Last updated: 2023-10-13 (5 months ago)
Front-end testing is in React is a pretty hot topic, torn between those who claim it is not worth it and that you should thoroughly test the backend instead, and those in support of unit testing everything before they even start implementing, test driven development style. Quite frankly, I was sitting somewhere in the middle, having tried several times to include testing as part of my PR routine, full of hope but eventually I have never been able to keep at it consistently. Besides, tools are plentiful, testing strategies almost as numerous (unit tests, TDD, E2E, API, functional testing, integration, etc), and it is quite easy to be overwhelmed before you even got the chance to run your first test.
Cypress is my personal favourite testing framework, by a landslide. I warmly recommend it to anyone trying to add tests to their React project (because seeing those green lights before deployment IS a huge confidence boost). This article is meant to show you the basics of Cypress, how to set it up, what it can be used for, as well as an overview of the different methods provided to simplify interacting with the browser (clicking/typing), waiting for a page to load , mocking data, among other things. What's more, I will be using this page as a guide to myself to list test cases I commonly run into while working. That's about it, let's dive into it!

This page is divided in three main sections:
  • First section will be a short summary of what Cypress can do, and what it looks like once you add it to your project;
  • Next section will revolve around a workshop by Filip Hric and its accompanying project, which bundles a 'real life' Trello app to test, covering all the basics, with exercices and solution. That's a spectacular resource for anyone willing to get foundational knowledge of Cypress, free of charge;
  • Finally we will be taking this knowledge and applying it to test this very website.

Introduction and installation

As mentioned on the official website:
"Cypress is a next generation front end testing tool built for the modern web, [...] it enables you to write end-to-end tests, component tests, integration tests and unit tests"
Like less words? "Cypress can test anything that runs in a browser."
In other words, it is a framework that can automate a browser, interact with your application and verify assertions about what's happening on the screen, or on the network. You can install it in your web project via npm by running the command:
npm install cypress --save-dev
Once that's done, open another terminal alongside your app (make sure your app is running!), and you can launch cypress using the command:
npx cypress open
You can find on this page of the docs step by step instructions to set up Cypress depending on what you want to do with it. Basically:
  • 'E2E testing' or 'Component testing' (we'll choose the former);
  • Config files that will be added to your project;
  • What browser you want to test your application against (Cypress will scan your PC to see what browsers are available).
Regarding the file that were added to your project, you'll find:
  • 'cypress.config.ts' where you can add global configuration;
  • '/cypress/e2e' is a folder where Cypress will be default look for tests to run;
  • '/cypress/fixtures' is a folder where Cypress will be default look for fake data that you can use when mocking API calls;
  • '/cypress/support/commands.ts' is a file where you can add custom commands for Cypress to use. We'll touch down on that later on in the article;

Cypress workshop

Hopefully you now have a clearer picture of what Cypress can do, let's dive in and look at the Trello app that's part of the workshop. Using 'npx cypress open' we can spin up a Cypress-controlled browser, that is listing all the tests it can find within the 'e2e' folder, as we've explained:
image previewCypress Dashboard
Notice that all the folders are displayed so you can easily locate the test suite you're interested in. Let's click on the first section, which will run the test suite within:
image previewTest suite view
The Trello app is quite simple, you can create boards, where you can add a lists that are composed of a number of cards. One great feature of Cypress is that it is giving you a lot of information about what's going on in the browser, which is valuable when you are crafting tests or trying to understand why a test is not behaving the way you would expect. See the following screenshot:
image previewDev tool view
On the left side, Cypress is listing all commands issued by the test (here the command is 'visit', used 4 times), along with all outbound API traffic. As you can see we are dispatching a bunch of 'GET' requests to populate our application. On the right side is the dev tools which can do everything your usual dev tools can do, but provides also additional information depending on which step is clicked on the left panel. In the example above, we've clicked on the last request 'GET /api/card/1', and the Console tab of the dev tools is showing useful data about the request and its response. If a command is selected on the left panel (as opposed to a request), then the console tab would show information about that command, for example which elements were selected, as we'll see later on.
Nice, it's time to test, we'll start from the first folder and do each challenge, explaining the new concepts as we go along.
1 - Opening and browing the app
Challenge 1

2 - Selecting elements
Challenge 2

3 - Writing our first test
Challenge 3

4 - Making assertions
Challenge 4

5 - Chaining commands
Challenge 5

6 - Testing dynamic pages
Challenge 6

7 - Handling data within tests
Challenge 7

8 - Creating custom commands
Challenge 8

9 - Intercepting network requests
Challenge 9

10 - API testing
Challenge 10

11 - Installing plugins
Challenge 11

12 - Handling authentication
Challenge 12

Applying the knowledge


Thanks to the workshop and its challenges, we've got a nice grasp on Cypress and its capabilities. Now let's try to apply this knowledge to my portfolio, see what adding tests to an existing application can look like. There are several ways one can structure test suites, here we are going to create one test suite for one page or feature. As to Cypress configuration, see 'cypress.config.ts' file:
1 2 3 4 5 6 7 8 9 10 11 12 13 import { defineConfig } from "cypress"; export default defineConfig({ e2e: { setupNodeEvents(on, config) { // implement node event listeners here }, baseUrl: "http://localhost:3000", watchForFileChanges: false, viewportHeight: 1080, viewportWidth: 1920 }, });
This file sets up global configuration used throughout all our tests, we can find the baseUrl, which is used as prefixed for every relative URL we might use. We also have two variables to define the size of the browser we'd like to spin up and test against, here we've chosen 1920*1080 as default, but we will overwrite that value on certain tests to make sure the application is behaving on mobile as well. Finally, 'watchForFileChanges' indicates we don't want Cypress to execute the tests every time a file is saved, which is just a developer's preferences. With that said, time to test!
Testing navigation
To kick things off, we can test the general navigation on the website, the navbar, language selection and media responsiveness. Here is the test suite:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 describe('Global navigation', () => { it('loads the landing page', () => { cy.visit('/') cy.get('[data-cy=main-page]') .should('contain.text', 'Hello there'); }) it('toggles languages', () => { cy.visit('/') cy.contains('span', "Passez une bonne journée !") .should('not.exist'); cy.get('[data-cy=language-selector]') .click(); cy.contains('[data-cy=language-selector-items] a', "Français") .click(); cy.contains('span', "Passez une bonne journée !") .should('be.visible'); }) it('menu can take the user to different pages', () => { cy.visit('/') cy.get('[data-cy=navbar-item]') .filter('.text-white') .should('be.visible') .should('have.length', 1) .should('contain.text', 'Homepage'); cy.contains('[data-cy=navbar-item]', 'About') .click(); cy.get('[data-cy=navbar-item]') .filter('.text-white') .should('have.length', 1) .should('contain.text', 'About'); cy.contains('h2', "Hello again!") .should('exist'); }) it('menu can take the user to different pages - mobile', { viewportHeight: 800, viewportWidth: 360, }, () => { cy.visit('/') cy.get('[data-cy=mobile-menu-button]') .click(); cy.get('[data-cy=navbar-item]') .filter('.text-white:visible') .should('have.length', 1) .should('contain.text', 'Homepage'); cy.get('[data-cy=navbar-item]') .filter(':visible') .last() .click(); cy.get('[data-cy=navbar-item]') .filter('.text-white') .should('have.length', 1) .should('contain.text', 'About'); cy.contains('h2', "Hello again!") .should('exist'); }) })
Key points:
  • the first test is quite straightforward, we're making sure the home page has loaded correctly. One thing to note however, we've added an attribute 'data-cy' to the React in order to select it more efficiently during tests. If used parsimoniously, it doesn't really have downsides, so we'll be adding more of this attribute throughout our tests;
  • next up we're making sure toggling languages switches the text content to French, by asserting the text content of a example div;
  • the third test is about navigation, we assert the currently selected menu item before and after changing tab, to make sure the navbar reflects the content;
  • for the last test, we are overwriting the viewport's dimensions, in order to simulate navigation on a mobile device. To do that, we overwrite the values in the 'options' parameter, that comes right before the test body, the rest is our usual syntax.
This test suite doesn't cover every possible edge case, but still is enough to boost our confidence that no major bug has found its way into the app. All tests pass and we can focus our effort on another page:
image previewNavigation tests passed

Testing Apex project page
The page that is the most interesting testing next, is the Apex project page, which needs user input and is calling a couple of API endpoints. Here is the test suite:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 describe('Apex project page', () => { beforeEach(() => { cy.visit('projects/apexProject'); }) it('loads content', () => { cy.contains('h2', 'Have I Played Apex with that guy?') .should('exist'); }) it('API GET /frames/TRUPER', () => { cy.request('GET', '/api/frames/TRUPER') .then(results => { expect(results.body).to.have.length(3); expect(results.body[0].frameNumber).to.equal('frame0'); expect(results.body[1].frameNumber).to.equal('frame1'); expect(results.body[2].frameNumber).to.equal('frame2'); expect(results.body[0].videoId).to.equal('doesntUsuallyWork'); expect(results.body[1].videoId).to.equal('doesntUsuallyWork'); expect(results.body[2].videoId).to.equal('doesntUsuallyWork'); }); }) it('API GET /videos/details/doesntUsuallyWork', () => { cy.request('GET', '/api/videos/details/doesntUsuallyWork') .then(results => { expect(results.body).to.deep.equal([{ _id:"6475433f3cf4007664f00465", name:"doesntUsuallyWork", date:"2023-01-16T10:00:00.000Z", title:"This Doesn't Usually Work as a Solo Player... - Apex Legends Season 15", creator:"ITemp", url:"https://youtu.be/C0I2o8Axv50" }]); }); }) it('displays a message when no results found', () => { cy.get('input') .type('fooBarPlayer'); cy.get('[data-cy=apex-search-button]') .click(); cy.contains('p', 'No perfect match found for username: fooBarPlayer') .should('exist'); }) it('can search a username and display results', () => { cy.intercept({ method: 'GET', url: /\/api\/frames\//, }, { fixture: 'apex-frames-results' }); cy.intercept({ method: 'GET', url: /\/api\/videos\/details\//, }, { fixture: 'apex-video-details' }); cy.get('input') .type('fooBarPlayer'); cy.get('[data-cy=apex-search-button]') .click(); cy.get('[data-cy=apex-search-results]') .should('be.visible'); cy.get('[data-cy=apex-results-video-list] li') .should('have.length', 2); cy.get('[data-cy=apex-results-frame-list] li') .should('have.length', 3); cy.get('[data-cy=apex-results-video-list] li') .eq(1) .click(); cy.get('[data-cy=apex-results-frame-list] li') .should('have.length', 1); }) it.only('can scroll automatically using the side scroller', () => { cy.get('[data-cy=step-scroller] a:visible') .should('have.length', 5); cy.get('[data-cy=step-scroller] a:visible') .eq(2) .click(); cy.wait(2000); cy.get('h3:visible') .should('have.text', Cypress.env('sectionTitle')) cy.get('[data-cy=step-scroller] a:visible') .filter('.border-indigo-600') .should('have.length', 3); }) })
Key points:
  • to avoid repetition, we define a 'beforeEach' section that will take us to the correct page before each test;
  • then we've got two tests specifically targeting the API, in the first we are sending a request to '/api/frames/' with the username 'TRUPER', and we create some assertions regarding what the expecting response should look like. The second one relates to '/api/videos/details' and we are asserting the result against an entire object, to make sure they are equal. Usually testing API endpoints separately is good practice, so you can then mock them in all following tests that might need them, thus helping against flaky tests;
  • in the fifth test, we are intercepting the 2 requests triggered during the interaction (which were tested previously in a separate test), and we are using fixture files to mock the response we want to receive. This way, we are actually testing the UI separately from the API;
  • the last test of the suite introduces a Cypress environment variable, called 'sectionTitle'. There are several ways to make environment variables available to Cypress, please refer to the documentation for more details. In our case we are setting it up via 'cypress.env.json' file, which contains our variable:
    1 2 3 { "sectionTitle": "Optical Character Recognition (OCR)" }
  • this example is simply to illustrate the use of environment variables, in reality you would use them to store data specific to your dev machine, like local credentials, or to separate between stage and prod variables. You can get them listed in Cypress by going to 'Setting' and 'Project settings', and scrolling down a little:
    image previewEnvironment variables
This test suite was another good practice to apply our Cypress knowledge, let's make sure all tests are passing:
image previewApex project tests passed
That's about it for this article, I will add more content as my portfolio grows, thank you for reading!

References