Modern ways of UI end to end testing with Cypress

20 August 2020

16 min read

This blog post was originally published in Mattermost blog.

Why write tests ?

The ultimate goal of writing tests should be improving the user’s in-app experience and increasing developers’ confidence in shipping new or improved apps.

The Mattermost team has been continuously writing different types of tests to improve the product. Such extensive automated testing has enabled them to ship a new release—with new features and improvements—every single month for the last few years. Thousands of developers contribute to the codebase. With automated testing in place, the core team at Mattermost can manage, review, and merge such contributions into the product with confidence.

Types of tests

There are many types of tests in software engineering: static tests, unit tests, integrations tests, end-to-end tests, A/B tests, stress tests, smoke tests, and user acceptance tests, to name a few.

If one is starting out, it can be a bit intimidating to see such a huge list. To make matters even worse, when one person is talking about a specific test, it’s quite possible that another person might define that test differently.

In this blog post, we are going to talk a little about static, unit, and integration tests before diving into end-to-end testing.

Static testing

Lets take a look at a small code snippet of our imaginary calculator app:

1const add = (...operands) => {
2  let sum = 0;
3
4  operands.forEach((operand) => {
5    sum = sum + operand;
6  });
7
8  return sums;
9};

You’ve probably seen it already. The return variable needs to be sum instead of sums. While this example might be simple, other cases—such as accessing unknown objects’ properties error and mixing up variables—can be identified early on by static typing and linting tools such as ESLint, Typescript, and Flow. We group these into static tests.

Unit testing

Let’s now take a look at an actual test. Here, we wrote a small test to test the same add function we saw earlier:

1import { add } from "./add.js";
2
3test("Adding  function to return correct sum of numbers", () => {
4  expect(add(1, 1)).toEqual(2);
5  expect(add(2, 3)).toEqual(5);
6});

It’s totally fine if you’re not familiar with the syntax. We’ll interpret the code in simple language. We wrote a suite of tests for a single add function. We called the function with certain numbers and expected it to return the correct sum of those numbers. Although our app has other features such as subtraction and division, we are going to test them separately.

Such isolated testing is called unit testing. A unit, however, doesn’t always have to be a function. It could be a class, module, or even an object.

But what if we would like to test more than one such unit in tandem? That’s where Integrations tests come into play.

Integration testing

In these kinds of tests, we verify that more than one unit works harmoniously with each other. Unlike unit tests, integration tests take into account all the working parts of a front-end application, and most calls to backend APIs are mocked. With that setup, it takes relatively less effort to cover a lot of application code and tests run at reasonably fast speeds.

1describe("Add page of the app", () => {
2  it("Adds number correctly", () => {
3    // before the request goes out we need to set up spying
4    cy.server();
5    cy.route({
6      method: "POST",
7      url: "**/add/?operand1=23&operand2=10",
8      response: {
9        sum: "33",
10      },
11    }).as("getSumOfNumbersAPI");
12
13    cy.visit("/add-page");
14
15    // # Add the number for first operant
16    cy.findByLabelText("Number 1").type("23");
17
18    // # Add the number for second operand
19    cy.findByLabelText("Number 2").type("10");
20
21    // # Click calculate button
22    cy.findByText("Calculate").click();
23
24    // # Wait for API to get complete
25    cy.wait("@getSumOfNumbersAPI").should((xhr) => {
26      expect(xhr.status).to.equal(200);
27      const { sum } = xhr.response.body;
28
29      // * Verify we get the correct sum
30      cy.findByLabelText("Sum of number").should("have.value", sum);
31    });
32  });
33});

In the above example, we assumed we were relying on the API to give us the sum of two numbers instead of doing computation on the client side. We don’t have any underlying implementation details but relied on the final result—which is exactly how a user would see and use the application.

We found our inputs where we first entered the numbers and then clicked on the “Calculate” button. We then set up our mock API request at the beginning of our test. That way, when the request actually goes out, our testing framework will catch it and return the mock response.

Finally, the sum of the numbers is verified.

End to end tests

Contrary to all the types of tests above, end-to-end tests run the entire application. This means your client, API, database and third-party services are all included.

As a result, end-to-end testing gives us very high confidence that a critical user flow works efficiently. They are a bit expensive to set up and are a bit slow, and they also require a lot of initial setup. But these tests are closest to how a user would test the application.

1describe("Add page of the app", () => {
2  it("Adds numbers correctly", () => {
3    cy.visit("/add-page");
4
5    // # Add the number for first operant
6    cy.findByLabelText("Number 1").type("23");
7
8    // # Add the number for second operand
9    cy.findByLabelText("Number 2").type("10");
10
11    // # Click calculate button
12    cy.findByText("Calculate").click();
13
14    // # Wait for API to get complete
15    cy.wait("1000");
16
17    // * Verify we get the correct sum
18    cy.findByLabelText("Sum of number").should("have.value", "33");
19  });
20});

The next question we probably have is usually something like this: How many of these tests should we write?

Well, there isn’t a rule of thumb that states that an application should have x number of A tests and y number of B tests. There are no definitive percentages or ratios of tests, either. Instead, add confidence to your codebase by using a mix of several different types of tests.

"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds (@kentcdodds) March 23, 2018

Getting started with end-to-end testing

There are many tools that help you do end-to-end testing of web applications. Let’s directly dive into Cypress - a testing tool that aims to help you write faster, easier, and more reliable tests. It already has a bunch of assertions and other tools bundled together.

It’s easy to add Cypress to your existing project via npm or yarn.

1npm install cypress --save-dev

Once installed, be sure to add a script in your package.json for easy access to the Cypress dashboard.

1"scripts": {
2    "cypress": "cypress open"
3},

When you first run Cypress with the above npm command, it’s going to take a while to install a few prerequisites. Next, you will be presented with the Cypress dashboard which will list all the tests in your app. It’s going to show you some pre-written tests. Check them out to see if you like them and delete them later on.

If you noticed, when you ran the command earlier, a new folder was also created in your root project folder named cypress.

|cypress -|fixtures -|integrations -|plugins -|support

We will focus on the integrations folder, as that will be the folder where you will be writing your test. Now, before we move on to actually writing a test, let’s install another library which will help us write them from an actual user’s point of view : Testing library

1npm install @testing-library/cypress --save-dev

In order for Cypress to be aware of our newly installed "testing-library", we need to add the single command to cypress/support/commands.js:

1import "@testing-library/cypress/add-commands";

Now, we are ready to start writing our first end-to-end test!

To remove the friction of creating a functional app ourselves, let’s use the production-ready Mattermost app. To set up your Mattermost development environment, check out their amazing dev docs. The documentation is fairly pretty straightforward, but if you do run into problems, you can always head over to the community chat to get help.

Mattermost uses Cypress intensively to automate testing. Read more about how Mattermost uses Cypress.

Writing your first test

Now that you’re ready to write and run some tests, let’s open the mattermost-webapp in your favorite code editor. It’s a huge codebase because it’s a big and complex product, but give it some time and all of it will start to make sense. We are going to write some tests for Mattermost’s login page.

Next, we head to the e2e/cypress folder and in it the integrations folder. You can see multiple test folders inside integrations. Each folder represents either a feature or component of the app in testing. Find the folder signin_authentication and look for the login_spec.js file.

1describe("Login page", () => {
2  let config;
3  let testUser;
4
5  before(() => {
6    // Disable other auth options
7    const newSettings = {
8      Office365Settings: { Enable: false },
9      LdapSettings: { Enable: false },
10    };
11    cy.apiUpdateConfig(newSettings).then((data) => {
12      ({ config } = data);
13    });
14
15    // # Create new team and users
16    cy.apiInitSetup().then(({ user }) => {
17      testUser = user;
18
19      cy.apiLogout();
20      cy.visit("/login");
21    });
22  });
23
24  it("should render", () => {
25    // * Check that the login section is loaded
26    cy.get("#login_section").should("be.visible");
27
28    // * Check the title
29    cy.title().should("include", config.TeamSettings.SiteName);
30  });
31
32  it("should match elements, body", () => {
33    // * Check elements in the body
34    cy.get("#login_section").should("be.visible");
35    cy.get("#site_name").should("contain", config.TeamSettings.SiteName);
36    cy.get("#site_description").should(
37      "contain",
38      config.TeamSettings.CustomDescriptionText
39    );
40    cy.get("#loginId")
41      .should("be.visible")
42      .and(($loginTextbox) => {
43        const placeholder = $loginTextbox[0].placeholder;
44        expect(placeholder).to.match(/Email/);
45        expect(placeholder).to.match(/Username/);
46      });
47    cy.get("#loginPassword")
48      .should("be.visible")
49      .and("have.attr", "placeholder", "Password");
50    cy.get("#loginButton").should("be.visible").and("contain", "Sign in");
51    cy.get("#login_forgot").should("contain", "I forgot my password");
52  });
53
54  it("should match elements, footer", () => {
55    // * Check elements in the footer
56    cy.get("#footer_section").should("be.visible");
57    cy.get("#company_name").should("contain", "Mattermost");
58    cy.get("#copyright")
59      .should("contain", "© 2015-")
60      .and("contain", "Mattermost, Inc.");
61    cy.get("#about_link")
62      .should("contain", "About")
63      .and("have.attr", "href", config.SupportSettings.AboutLink);
64    cy.get("#privacy_link")
65      .should("contain", "Privacy")
66      .and("have.attr", "href", config.SupportSettings.PrivacyPolicyLink);
67    cy.get("#terms_link")
68      .should("contain", "Terms")
69      .and("have.attr", "href", config.SupportSettings.TermsOfServiceLink);
70    cy.get("#help_link")
71      .should("contain", "Help")
72      .and("have.attr", "href", config.SupportSettings.HelpLink);
73  });
74
75  it("should login then logout by test user", () => {
76    // # Enter username on Email or Username input box
77    cy.get("#loginId").should("be.visible").type(testUser.username);
78
79    // # Enter password on "Password" input box
80    cy.get("#loginPassword").should("be.visible").type(testUser.password);
81
82    // # Click "Sign in" button
83    cy.get("#loginButton").should("be.visible").click();
84
85    // * Check that the Signin button change with rotating icon and "Signing in..." text
86    cy.get("#loadingSpinner")
87      .should("be.visible")
88      .and("contain", "Signing in...");
89
90    // * Check that it login successfully and it redirects into the main channel page
91    cy.get("#channel_view").should("be.visible");
92
93    // # Click hamburger main menu button
94    cy.get("#sidebarHeaderDropdownButton").click();
95    cy.get("#logout").should("be.visible").click();
96
97    // * Check that it logout successfully and it redirects into the login page
98    cy.get("#login_section").should("be.visible");
99    cy.location("pathname").should("contain", "/login");
100  });
101});

If you take a quick look at it, you would see the usage of element IDs a lot. This was the first test written at Mattermost, when the team was just getting started with end-to-end testing. Many best practices the team uses now can be found in more recent test files. But somehow, this particular test was left unchanged, so it’s a good opportunity for us to submit an improved test as a pull request.

Each test specification is contained inside the describe block with the main heading of our test suite, which is followed by many it blocks which hold the individual test. You can also see use of before, which is designed to run at the beginning of your test. Without going into much detail, at this point in time it is sufficient for us to know that before creates a new test user and redirects to a login page. Clear out everything inside of the describe except for the before block. Add a new it block with the heading "should render all elements of the page".

1describe("Login page", () => {
2  let config;
3  let testUser;
4
5  before(() => {
6    // Disable other auth options
7    const newSettings = {
8      Office365Settings: { Enable: false },
9      LdapSettings: { Enable: false },
10    };
11    cy.apiUpdateConfig(newSettings).then((data) => {
12      ({ config } = data);
13    });
14
15    // # Create new team and users
16    cy.apiInitSetup().then(({ user }) => {
17      testUser = user;
18
19      cy.apiLogout();
20      cy.visit("/login");
21    });
22  });
23
24  it("should render all elements of the page", () => {
25    // start writing here
26  });
27});

The purpose of our first test is to check if all the elements are rendered in the page. To start off, we verify the URL matches that of the login page. Cypress gives us a command — cy.url — to get the current URL of the page:

1// * Verify URL is of login page
2cy.url().should("include", "/login");

Notice the use of should. It is the assertion that — when supplied with a suitable chainer and value — creates a test case which Cypress then verifies. In the above code, the line it translates to: A URL should include /login. Cypress runs this case and if it fails, the test is failed — and vice versa.

We can also verify the title of the document with the "cy.title" command. In the before block, we get access to the created teams config object, which contains the document title for the page.

1// * Verify title of the document is correct
2cy.title().should("include", config.TeamSettings.SiteName);

Now, we need to check if the page contains the fundamental elements required for login, e.g., the email/username field, the password field, and a submit button.

1// * Verify email/username field is present
2cy.findByPlaceholderText("Email or Username").should("exist").and("be.visible");
3
4// * Verify possword is present
5cy.findByPlaceholderText("Password").should("exist").and("be.visible");
6
7// * Verify sign in button is present
8cy.findByText("Sign in").should("exist").and("be.visible");

We are finding the input fields by placeholder and the submit button by text, similar to how an actual user would find them. To add multiple assertions, we can chain the and function. Specifically, in the above case, we are verifying if the element is visible to the user and it’s not scrolled off the screen.

We will now try to find out if the page has a forgotten password link and if it points to the correct URL:

1// * Verify forgot password link is present
2cy.findByText("I forgot my password.")
3  .should("exist")
4  .and("be.visible")
5  .parent()
6  .should("have.attr", "href", "/reset_password");

There’s something new we did in the above assertion; notice the use of parent. To answer that question, let’s look at the HTML structure of I forgot my password:

1<a href="/reset_password">
2  <span>I forgot my password.</span>
3</a>

Find by text would return us the span element — not the anchor element which actually contains the link. A good practice would have been to use it inside of the anchor itself. But here, due to translation reasons, the team had to use the above way. The parent command helps to go a level up from the span and then we can check if the correct URL exists on the parent <a>.

So far, this is the complete test we have written:

1it("should render all elements of the page", () => {
2  // * Verify URL is of login page
3  cy.url().should("include", "/login");
4
5  // * Verify title of the document is correct
6  cy.title().should("include", config.TeamSettings.SiteName);
7
8  // * Verify email/username field is present
9  cy.findByPlaceholderText("Email or Username")
10    .should("exist")
11    .and("be.visible");
12
13  // * Verify password is present
14  cy.findByPlaceholderText("Password").should("exist").and("be.visible");
15
16  // * Verify sign in button is present
17  cy.findByText("Sign in").should("exist").and("be.visible");
18
19  // * Verify forgot password link is present
20  cy.findByText("I forgot my password.")
21    .should("exist")
22    .and("be.visible")
23    .parent()
24    .should("have.attr", "href", "/reset_password");
25});

To run the test, you can open your terminal and execute the open cypress command inside of the e2e/ folder. Once the cypress dashboard opens, find the test file name and double-click to run tests:

1npm run cypress:open

If your test runs and passes, congratulations on writing an e2e test with Cypress! You can go ahead and write multiple scenarios for login and see your tests running.

Writing another test case

Let’s write another scenario where we type in wrong or non-existing user credentials to login.

Open up a new it block and give it a name “Should show error with invalid email/username and password”. Create a random username and password constant:

1it("Should show error with invalid email/username and password", () => {
2  const invalidEmail = `${Date.now()}-user`;
3  const invalidPassword = `${Date.now()}-password`;
4});

We have the information of an actual user and password from our before block. To be sure, let’s verify our generated credentials aren’t the same as the actual user’s.

1it("Should show error with invalid email/username and password", () => {
2  const invalidEmail = `${Date.now()}-user`;
3  const invalidPassword = `${Date.now()}-password`;
4
5  // # Lets verify generated email is not an actual email/username
6  expect(invalidEmail).to.not.equal(testUser.username);
7
8  // # Lets verify generated password is not an actual password
9  expect(invalidPassword).to.not.equal(testUser.password);
10});

The expect assertion comes from the Chai js library which is inbuilt in Cypress. Type in the invalid email/username and password in respective fields. Cypress allows you to type in the field value via the type command. It also gives you a command to click which you can use on the submit button.

1it("Should show error with invalid email/username and password", () => {
2  const invalidEmail = `${Date.now()}-user`;
3  const invalidPassword = `${Date.now()}-password`;
4
5  // # Lets verify generated email is not an actual email/username
6  expect(invalidEmail).to.not.equal(testUser.username);
7
8  // # Lets verify generated password is not an actual password
9  expect(invalidPassword).to.not.equal(testUser.password);
10
11  // # Enter invalid email/username in the email field
12  cy.findByPlaceholderText("Email or Username").clear().type(invalidEmail);
13
14  // # Enter invalid password in the password field
15  cy.findByPlaceholderText("Password").clear().type(invalidPassword);
16
17  // # Hit enter to login
18  cy.findByText("Sign in").click();
19});

Now, we just have to verify if we received the correct error message.

1it("Should show error with invalid email/username and password", () => {
2  const invalidEmail = `${Date.now()}-user`;
3  const invalidPassword = `${Date.now()}-password`;
4
5  // # Lets verify generated email is not an actual email/username
6  expect(invalidEmail).to.not.equal(testUser.username);
7
8  // # Lets verify generated password is not an actual password
9  expect(invalidPassword).to.not.equal(testUser.password);
10
11  // # Enter invalid email/username in the email field
12  cy.findByPlaceholderText("Email or Username").clear().type(invalidEmail);
13
14  // # Enter invalid password in the password field
15  cy.findByPlaceholderText("Password").clear().type(invalidPassword);
16
17  // # Hit enter to login
18  cy.findByText("Sign in").click();
19
20  // * Verify appropriate error message is displayed for incorrect email/username and password
21  cy.findByText("Enter a valid email or username and/or password.")
22    .should("exist")
23    .and("be.visible");
24});

And voila! You have covered another common test case.

If you would like to see the rest of the improvements I made, check out this pull request.

By now, you’ve seen how easy and enjoyable Cypress makes end-to-end testing. So go ahead and introduce some end-to-end tests in your application!