How to level-up your Cypress testing
March 17, 2020
Cypress for end-to-end testing is awesome and easy to get started with. But once you’ve written more than a few tests , you’ll start to realize ways to improve your test infrastructure and implementations. Here are 5 techniques I’ve picked up that made huge improvements to my Cypress testing.
We’ll walk through 5 techniques
- Use cypress-testing-library
- Wait explicitly for network requests
- Turn common actions into commands
- Log browser warnings and errors in the terminal output
- Fail tests when there’s an unknown console error
Set up the test project
Create a new React project with create-react-app and cd
into it.
npx create-react-app cypress-tutorial
cd cypress-tutorial
Install cypress.
npm i -D cypress
Initialize cypress in your project by opening the development GUI for the first time.
npx cypress open
Delete the example test directory.
rm -R ./cypress/integration/examples
Run the app in a terminal window and keep it running at http://localhost:3000
throughout the tutorial.
npm start
Add ESLint and the Cypress linting rules
To avoid annoying linting issues with Cypress in your IDE, let’s setup linting such that it understands Cypress’s
global cy
object.
npm i -D eslint eslint-plugin-cypress
Create an .eslintrc.json
.
touch .eslintrc.json
Paste this code into your .eslintrc.json
so that your IDE can understand the Cypress code and modern JavaScript.
{
"plugins": ["cypress"],
"env": { "es6": true }
}
Write a basic test
To have a starting point for implementing more advanced techniques, let’s write a super basic test.
Create a new test file called tutorial.spec.js
.
touch ./cypress/integration/tutorial.spec.js
Paste the following Cypress test code into the tutorial.spec.js
file. All this test does is make sure the app works
by looking for the CRA boilerplate “Learn React” link.
NOTE: Normally, you’d set the baseUrl
in cypress.json
!
describe("Cypress Tutorial", function() {
it("works", function() {
cy.visit("http://localhost:3000");
cy.get("a").contains("Learn React");
});
});
Run the test by opening the GUI with npx cypress open
and see that the test passes.
Technique 1: Use cypress-testing-library
You may have heard of Kent C. Dodds and his testing libraries like dom-testing-library and react-testing -library. Well there’s one for cypress, too, called cypress-testing-library and it’s pretty sweet for easily writing tests in a way that more closely mimics how a user will actually interact with your app, which means your tests are better.
Install the library.
npm install -D @testing-library/cypress
Per the docs, we need to import the library into cypress/support/commands.js
for it to work. So update that file
with the following…
import "@testing-library/cypress/add-commands";
Now we have access to the awesome commands from cypress-testing-library right from the cy
object just like normal
commands. Let’s use one in our existing tutorial.spec.js
test.
describe("Cypress Tutorial", function() {
it("Makes sure the app is working", function() {
cy.visit("http://localhost:3000");
cy.findByText("Learn React"); // formerly cy.get("a").contains("Learn React");
});
});
Not much changed, but now we aren’t looking for an a
tag anymore, merely anything on the page that has
Learn React
. This way of looking for it is closer to how a user will read and interact with the screen. You can
read more about that concept here.
And to make sure our test actually works and breaks when it’s supposed to, change the text to search for Learn Vue
and see the test break in the GUI.
Technique 2: Wait explicitly for network requests
I’ve found that network requests are the biggest cause of flakiness in tests. Sometime they’re quick, other times they exceed your default timeout and cause your test to fail. While you could just increase the timeout, that makes your tests longer than they need to be. There’s a better way.
Add a network request to the React app
We need a network request to test, so let’s add a dummy one to the App.js
file. Also, for some reason the native
fetch
api doesn’t work with cypress route stuff, so let’s install and use axios.
npm i axios
Now paste this in App.js
.
import React, { useState, useEffect } from "react";
import axios from "axios";
import logo from "./logo.svg";
import "./App.css";
function App() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const secondsToWait = 5;
axios(`https://httpstat.us/200?sleep=${secondsToWait * 1000}`).then(
response => {
setLoaded(true);
}
);
}, []);
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
{loaded ? (
<a
className="App-link"
href="https://reactjs.org"
rel="noopener noreferrer"
>
Learn React
</a>
) : null}
</header>
</div>
);
}
export default App;
With the updates above, the app now doesn’t show the “Learn React” link until the network request finishes after 5
seconds. Check it out for yourself by reloading your app at http://localhost:3000
.
Because the “Learn React” link doesn’t show up for 5 seconds, which is longer than Cypress’ default timeout, the test now fails.
The bad fix: increasing the timeout
One suboptimal way to “fix” your test would be to increase the default timeout of the findByText
command, like below.
describe("Cypress Tutorial", function() {
it("Makes sure the app is working", function() {
cy.visit("http://localhost:3000");
cy.findByText("Learn React", { timeout: 10000 });
});
});
Now your test passes, but it waits for the network request for 10 seconds every time you run the test - even if the network only needs 2 seconds. That’s a lot of time to add to your test suite - multiplied by however many times you use this strategy!
The better fix: alias and wait
Cypress has a thing called aliases that let you explicitly reference various things in your tests like elements, fixtures and, in our case, network requests!
Let’s improve our test by aliasing the network request and waiting for it before looking for the “Learn Text” link.
describe("Cypress Tutorial", function() {
it("works", function() {
cy.server();
// alias the network request
cy.route("/200?**").as("fakeNetworkRequest");
cy.visit("http://localhost:3000");
// wait for the network request to complete
cy.wait("@fakeNetworkRequest");
// now look for the link
cy.findByText("Learn React");
});
});
Some explanations/things to note about the above…
cy.server()
lets you “start a server to begin routing responses to cy.route() and to change the behavior of network requests.”cy.route()
lets you manage network behavior and takes an explicit url or a glob to match certain request patterns. You can also get more specific with a config object, which is what I chose to do above.
Now our test passes and waits only as long as it needs to, even if you increase the secondsToWait
in App.js
!
Technique 3: Turn common actions into commands
As you write more and more tests, you’ll find that you’re reusing a lot of the same logic for doing common interactions like…
- logging into your app
- selecting weird elements
- navigating to a page
- prepping some data/state on the page
- much more…
Our tutorial app and test are really simple - but this wouldn’t be a tutorial without a convoluted example to show off some technology!
So let’s write a new test that checks that the “Learn React” href is ”https://reactjs.org”. In that test , we’ll use some reusable commands that we create.
Write the new tests, without any commands
In the same describe
block as the first test, add this one. Obviously very similar to the first, so we’ll extract
out the common bits shortly.
it("has a link pointing to the react website", function() {
cy.server();
cy.route("/200?**").as("fakeNetworkRequest");
cy.visit("http://localhost:3000");
cy.wait("@fakeNetworkRequest");
cy.findByText("Learn React").should(
"have.attr",
"href",
"https://reactjs.org"
);
});
Run that test in the GUI to make sure it passes.
Our first command
We may have a reason to verify the href
of links in other tests, which we could extract out into a nice, custom
Cypress command.
Obviously your real world app and tests will provide more legit reasons to write reusable commands.
Open up cypress/support/commands.js
and add the below code, a.k.a. our custom command.
import "@testing-library/cypress/add-commands"; //same as before for cypress-testing-library
Cypress.Commands.add("verifyLink", (linkText, href) => {
cy.findByText(linkText).should("have.attr", "href", href);
});
So we just added a command verifyLink
that takes two args - the linkText
to look for and the href
the link
should have.
Go back to tutorial.spec.js
and use the command!
it("has a link pointing to the react website", function() {
cy.server();
cy.route("/200?**").as("fakeNetworkRequest");
cy.visit("http://localhost:3000");
cy.wait("@fakeNetworkRequest");
cy.verifyLink("Learn React", "https://reactjs.org");
});
Bonus: Use beforeEach to reuse test code within the same test suite.
Sometimes test code is repeated just within a spec file/suite, which we can extract out with options like beforeEach
, afterEach
, and sometimes just good ole fashioned JavaScript functions.
Here in tutorial.spec.js
, we’ll leverage beforeEach
. Below is the latest version of our test suite.
describe("Cypress Tutorial", function() {
beforeEach(function() {
cy.server();
cy.route("/200?**").as("fakeNetworkRequest");
cy.visit("http://localhost:3000");
cy.wait("@fakeNetworkRequest");
});
it("works", function() {
cy.findByText("Learn React");
});
it("has a link pointing to the react website", function() {
cy.verifyLink("Learn React", "https://reactjs.org");
});
});
Technique 4: Log browser warnings and errors in the headless/terminal output
When you run the Cypress test suite in headless mode, it’s nice to be able to see console warnings and errors that may help you debug why your tests failed. This doesn’t happen by default in the terminal output, so let’s make it happen.
Add a console warning and error to the app
First, we’ll need errors and warnings to pass through to the terminal. So update App.js
with the following code…
//... same above
function App() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
console.log("This is a log");
console.warn("This is a warning");
console.error("This is an error");
const secondsToWait = 1;
axios(`https://httpstat.us/200?sleep=${secondsToWait * 1000}`).then(
response => {
setLoaded(true);
}
);
}, []);
//... same below
Check in your browser console for those three messages.
Run the tests in headless mode
To see the terminal test output before it shows console warnings and errors, run the following.
npx cypress run
Logging console output
This is going to seem kinda elaborate for something so simple, but it’s worth it and exposes some possibilities in Cypress generally.
Open up cypress/commands/index.js
and add the following code to the bottom of the file.
Cypress.on("window:before:load", win => {
cy.stub(win.console, "error", msg => {
cy.now("task", "error", msg);
});
cy.stub(win.console, "warn", msg => {
cy.now("task", "warn", msg);
});
});
There’s a decent bit going on here - let’s break it down.
Cypress.on
is a way to listen for Cypress events,- we run a function on the
window:before:load
event (basically before the tests run) - To intercept the console methods we stub them.
cy.now
is something I learned about here and here and seems to avoid an issue withcy.task
and promises that Cypress doesn’t like. I wish there was a better way , so if you know of any let me know!
We need to update one more file, cypress/plugins/index.js
, to get things to work. Here’s the code you’ll need
in that file, which listens for the tasks
we just set up in cypress/commands/index.js
and logs to the internal
Cypress process (including the terminal
output with nice colors).
module.exports = on => {
on(`task`, {
error(message) {
console.error("\x1b[31m", "ERROR:", message, "\x1b[0m");
},
warn(message) {
console.warn("\x1b[33m", "WARNING:", message, "\x1b[0m");
},
});
};
Now run the tests in headless mode with npx cypress run
and you should see the logs in your terminal output!
Technique 5: Fail tests when there’s an unknown console error
Cypress tests fail on uncaught exceptions already, which is basically your app crashing. But sometimes we might want
to fail the test when there’s a console.error
in the browser during the test.
Fortunately, we can easily add this behavior to our Cypress tests with a small addition to the code from the preview
section. Open cypress/support/index
and update the Cypress.on
listener block to match below, which just has one
extra line.
Cypress.on("window:before:load", win => {
cy.stub(win.console, "error", msg => {
cy.now("task", "error", msg);
throw new Error(msg); // all we needed to add!
});
cy.stub(win.console, "warn", msg => {
cy.now("task", "warn", msg);
});
});
No rerun the test suite and see that it fails!
Bonus - ignore known errors
Sometimes we know of console errors that we just can’t get rid of, but we want to still fail tests for unknown
errors. That’s a simple fix by only throwing the Error
when we don’t recognize the error message. See below for
how we can ignore our current app’s console error.
Cypress.on("window:before:load", win => {
cy.stub(win.console, "error", msg => {
if (msg.includes("This is an error")) {
return null;
}
cy.now("task", "error", msg);
throw new Error(msg);
});
cy.stub(win.console, "warn", msg => {
cy.now("task", "warn", msg);
});
});
Rerun the test suite and the console error will no longer fail your tests!
And that’s it!
I have more Cypress techniques (like making it work with modern SPA apps/tools like react-router and adding to a CI /CD pipeline) I didn’t have space for in this tutorial, so those will be coming soon!
- ← Projects starred by Dan Abramov in the past year
- How to Manage and Scale Your AI Prompts in Node with ai-prompt-organizer →
Hi, I'm Ryan. I live in Denver and work remotely as a JavaScript/React/Node Developer. I'm always having fun building side projects and sometimes write JavaScript-related tutorials that help folks build things, too.