We added cypress E2E testing to our grad project. We found it really user-friendly, straightforward to set-up, and easy to read and write tests. Here's a run through of how we set it up and some things we learned about best practices.
We installed cypress using: npm install cypress --save-dev
in the client
directory of our project.
We then ran: npx cypress open
to start up cypress. This opened the test runner UI which we used to choose which tests to run and inspect the results. On install, cypress helpfully creates example tests and default configuration files so before writing any code we were able to start getting our heads around how it worked.
A cypress.json
file was automatically created in the client
folder, which is where the default cypress configuration was specified. The rest of the basic configuration setup and examples were stored in a cypress
folder which cypress creates for you.
As we were using multiple E2E testing frameworks on the project, we wanted to store these files in a directory below our e2e
directory, so we modified cypress.json
to:
- Set
integrationFolder
toe2e/cypress/tests
(where we'd be writing our tests) - Set
pluginsFile
to false (we wouldn't be using plugins) - Set
fixturesFolder
to false (we wouldn't be loading data from separate files) - Set
supportFile
to false (we wouldn't use the same setup across all our different tests) - Set
baseUrl
tohttp://localhost
We also added a script to package.json
so that we could run cypress using an npm script:
"test:cypress": cypress open"
Our project used eslint so we also needed to install the cypress plugin for eslint using:
npm i --save-dev eslint-plugin-cypress
and update the eslint config .eslintrc.json
by adding:
plugin:cypress/recommended
to the extends
array and cypress
to the plugins
array.
Cypress best practices came in really useful when we were writing tests and setting up the structure of our test flow. One thing this stresses is that it's really bad practice to use the UI to do things like logging in to test restricted pages/pages that require authentication.
Doing this is slow and repetitive but most importantly, specs should be tested in isolation. Logging in for tests that aren't explicity testing the login function should be done programmatically. This is good practice for all tests, not just cypress.
So, what did we do instead? If you don't know much about authentication, in different apps it can be set via cookies, localStorage or sessionStorage, but in our app we authenticated users using jwts stored in sessionStorage.
If you're not sure, you can check in devTools, under the Application
menu and Storage
.
The format of our authorization storage object was:
Authorization: Bearer string
Username: String
UserEmail: String
UserRole: Role type
Our helper file therefore looked a bit like:
const setSessionStorage = user => {
cy.visit("/", { //visit homepage (relative to baseurl)
onBeforeLoad(win) { //intercept the page before the UI loads to set credentials
const userCredentials = [
{ Authorization: "Bearer jwt" },
{ UserName: "Fake name" },
{ UserEmail: "Fake email" },
{ UserRole: "AUTHORIZED_ROLE" }
];
userCredentials.map(userCredential => {
win.sessionStorage.setItem(
Object.keys(userCredential),
Object.values(userCredential)
);
});
}
});
};
We then ran this function before accessing restricted pages rather than using the UI to login each time, meaning we were testing just the target functionality. We did also have an explicit login test - but this was the only place logging in was done through the UI. Success!
Our app's workflow for publishing reports was a bit like:
User A submits a report (POST) => User B gets User A's report (GET) => User B approves user A's report (POST) => User A & B can see User A's report on their feeds (GET)
While all these actions are done by a user on the UI, we'd learned that doing the report submission on the UI to test the viewing of a report on the feed would be very out of scope, making the tests slower, much more likely to be flaky and much harder to troubleshoot.
So instead, we set up API helper functions to post and approve reports, so that when testing the display of a published report, that would be all that was being tested.
They looked a bit like this:
const approveReport = (reportId, jwt) => {
cy.request({
method: "POST",
url: `/api/reviews/${reportId}/approve`,
headers: {
"Content-Type": "application/json",
authorization: jwt
}
});
};
Approving a report would also be tested by the UI - but only for the approving reports spec test!
To help minimise code and to be explicit about what we were actually testing in a test rather than what's setup, we made extensive use of beforeAll
, beforeEach
, afterAll
and afterEach
.
We used these for helper functions like createUser()
(set up a user before all the tests start so that you can actually do stuff), setSessionStorage()
(authenticate a session, again, so that you can access and do stuff that's restricted) and clearDb()
(wipe the state after all the tests are completed).