Write the tests that matter with end-to-end testing with Cypress
Let me paint you a picture. You’re working on an app you don’t know too well, and you want to make sure you haven’t broken anything with whatever wide-reaching change you’ve made. The QA department is on vacation, and they hate doing those full regression tests anyways. Besides, you need to get those totally safe changes out to your users instantly!
From one overpaid developer to another, I’ve broken production so many times because I didn’t think of some weird edge case, or didn’t bother to run through all the manual tests before I hit the big red deploy button. It’s too much of a hassle, I probably wouldn’t have caught the error anyways, and sometimes I’m just lazy.
I had heard of end-to-end tests previously, but they were always talked about as those flaky, hard-to-get-running, impossible-to-keep-up-to-date monstrosities we just ignored. Selenium was free and terrible, and the other options on the market were thousands of dollars (and probably not all that much better). So automating those boring regression tests wasn’t really an option either.
So imagine my skepticism when I started hearing about this new tool that supposedly made end-to-end testing a breeze. It was called Cypress, and I wrote it off as “yet another Selenium with a great marketing team” for the longest time. Surely, it wasn’t even worth exploring.
One day, though, I was tasked with doing a proof of concept on how we should write end-to-end and integration tests for an app at work. The backlog was closing in on empty, and it was definitely time to improve the test coverage of our app. So perhaps it was finally time – let’s give Cypress a shot. This article will convince you to do the same.
In this article, we’ll go through Cypress, Cypress Testing Library, and Axe – three tools that together will give you real confidence that your application works as expected.
So before we get into the nitty-gritty details, let’s look at what these three tools are, and how they help you towards your goal of creating better apps.
Cypress is a tool for creating and running end-to-end tests. It spins up a browser, visits your app, and runs through a set of predefined steps like a regular user would. Finally, it verifies that the result is what you’d expect.
These kinds of tests are slow compared to unit and integration tests, but they do an amazing job of making sure your app works as expected for the end user. You shouldn’t write too many of them but instead, aim for covering the main paths of your app.
Cypress Testing Library is a library that plugs into Cypress and makes it easy to write tests that encourage accessible code. It does away with one of Cypress’ pitfalls — the way you select elements — and provides you with an API you probably know from unit testing with React Testing Library, Vue Testing Library, or other libraries in the same family.
Finally, Axe is a tool for verifying that your app is both accessible and WCAG compliant. It’s available as a browser plugin, but it only verifies how your app looks right now – not after 13 interactions and a navigation event. Luckily, the combination of Cypress and Axe makes that last part a breeze, too – I’ll show you how later in this article.
So we’ve found a toolchain that looks promising – but how do we get set up? First, let’s install the tools from npm:
npm install cypress @testing-library/cypress cypress-axe --save-dev
This will create an empty cypress.json
file, as well as a cypress
folder with a few configuration files and examples in it.
The default cypress.json
is empty, because Cypress ships with really sensible defaults. There’s a lot of ways to customize it though! We won’t do a lot now, but you might want to specify the base path of your application, so you don’t have to start every test by navigating to it. That’s done by setting the baseUrl option:
{
"baseUrl": "http://localhost:3000"
}
Cypress has this concept of custom commands that you can call while testing, and both Cypress Testing Library and cypress-axe provide you with a few extra “commands”. So to get them set up, navigate to the newly created file cypress/support/commands.js and change its content to the following:
import '@testing-library/cypress/add-commands';
import 'cypress-axe';
If you’re using ESLint, you might want to create an .eslintrc
file in the cypress folder with the following content:
module.exports = {
root: true,
plugins: ['eslint-plugin-cypress'],
extends: ['plugin:cypress/recommended'],
env: { 'cypress/globals': true },
};
If you’re using TypeScript, you want to add a custom tsconfig.json
in the cypress
folder as well:
{
"compilerOptions": {
"strict": true,
"baseUrl": "../node_modules",
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts"]
}
You also need to create a type definition file to include the types for cypress-axe. We’ll call it cypress/support/index.d.ts
, and fill it with this:
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
injectAxe(): Chainable<EventEmitter>;
checkA11y(): Chainable<EventEmitter>;
}
}
Finally, let’s add two new scripts to the package.json file, so we can run our tests, too:
{
"scripts": {
"test:e2e": "cypress open",
"test:e2e:ci": "cypress run"
}
}
The test:e2e
script opens up Cypress’ built-in UI, which lets you trigger tests and inspect them step for step. The test:e2e:ci
runs the tests in a headless browser – perfect for running as a part of your continuous integration pipeline.
So the setup is done and writing the tests remains. But what makes for a good end-to-end test case?
As I mentioned initially, you shouldn’t really have too many of these end-to-end tests. They are slow, resource intensive, and require you to keep stuff up to date. Therefore, you should focus on the main paths of your application, and leave the rest to your integration and unit tests.
By following this approach, you can add tests for all of the main paths of your application, while still keeping your test suite fast enough for running often.
Enough setup and theory – let’s get to testing! We’ll be interacting with an imagined application to keep things simple.
First, we create a new file in our cypress/integration folder, which we’ll call todo-tests.ts
. We’ll start with adding a test for adding a todo to our list, which I guess is the main use of todo apps 😅 It will look like this:
describe('todo', () => {
beforeEach(() => {
cy.injectAxe();
})
test('adds a todo', () => {
cy.checkA11y();
cy.findByText("Learn Cypress").should('not.exist')
cy.findByLabelText(/What do you want to do/i)
.type('Learn Cypress{enter}');
cy.findByText("Learn Cypress").should('exist')
cy.checkA11y();
})
})
There’s a lot going on here, so let’s go through it step by step.
cy
is the variable we interact with to run our tests. It’s the one we’ve added all the custom commands to previously!
First, we make sure to call the injectAxe
function on it before we start every test so that the axe plugin is loaded and ready to rumble.
We start our first test by calling cy.checkA11y()
. This runs a thorough accessibility audit on your app in its current state. Next, we make sure the todo “Learn Cypress” isn’t added to the list before we begin.
Now, it’s time to add our todo. We find the input field by looking up its label with cy.findByLabelText
. This is a great extra check to make sure our input field is accessible!
We type into the input field by calling the .type
method with the text we want. We can trigger the enter button by writing “{enter}”. This also makes sure we’ve placed our input field inside of a <form/>
tag.
After adding the todo, we want to make sure the “Learn Cypress”-todo is added to our list. We use the findByText
function to look it up, and assert that it should exist.
As a final touch, we check that the app is still accessible with a todo item added.
There are a few more tests I might want to add. I want to make sure I can set a todo
as done and that I can filter out the ones I’ve already done. Perhaps I want to test that I get an error message if I try to add an empty todo
?
For brevity’s sake, I won’t go through any more tests in this article, but they all follow the same pattern. Make sure to check your app is always in an accessible state, and use accessible selectors that nudge you towards writing accessible apps from the get-go.
We could very easily have verified that our app works by manually going through these steps in a browser. These automated end-to-end tests aren’t really revolutionizing anything – but they sure are incredibly handy!
If you’re lucky enough to have a QA engineer on your team, they’ll love you for writing these sorts of tests too. You won’t take their job away – but you’ll help them automate the part of their job that is tedious and mundane!
As a matter of fact, you can bring test-driven development to a new level. Instead of focusing on small features, you can create a specification of what you want your app to do, and you can let your tools verify your progress as you build out your app!
Manual verification is a thing of the past. It’s a brittle technique that is perfect for forgetting edge cases and hard-to-reach states. Instead, get with the program and join the revolution that is Cypress. You’ll be able to know your app works as expected for your users without actually checking it yourself. You’ll know the main flows of your application is working, and you’ll know it will work for people using assistive technologies as well. And finally, you’ll know you’ll pass any accessibility review since accessibility is built into how you write your tests.
I hope this article got you as fired up as I am about testing. Not because testing is cool, but because it’s extremely boring. So skip the manual testing and automate it all away.
My favorite resource on Cypress is Kent C Dodds’ course on TestingJavaScript.com. It’s a great investment in your testing skills, and I could not recommend it enough. The official Cypress docs are also a great read, and includes real-world examples, getting started guides and lots of neat tutorials.
Best of luck!
All rights reserved © 2024