End-to-End (E2E) testing with Cypress

Frontend, Test, Cypress, End-to-end

End-to-end testing is a technique that tests the entire software product from beginning to end to ensure the application flow behaves as expected. It defines the product’s system dependencies and ensures all integrated pieces work together as expected.

Cypress is an end-to-end testing framework for web test automation. It enables front-end developers and test automation engineers to write automated web tests in JavaScript, the main language used for developing websites. The use of JavaScript makes Cypress automation especially attractive to a developer audience.

Features

Cypress comes fully baked, batteries included. Here is a list of things it can do that no other testing framework can:

Installing and configurations

We start by installing cypress npm install cypress --save-dev and npm install eslint-plugin-cypress -D. Inside our package.json we add these scripts:

"scripts": {
  "cypress:open": "cypress open",
  "test:e2e": "cypress run"
}

"eslintConfig": {
  "env": {
    "cypress/globals": true
  },
  "extends": "react-app",
  "plugins": [
    "cypress",
  ]
}

We can run cypress by using npm run cypress:open.

Inside our cypress.json configuration file we can add different options:

{
  "baseUrl": "http://localhost:3000"
}

And if we visit the URL:

describe("The Home Page", () => {
  it("successfully loads", () => {
    cy.visit("/");
  });
});

Check the other available options here.

We added the eslint plugin because otherwise it would complain that cy is not defined, which we could solve by adding as a comment /* global cy */ at the top of the file, but this is better.

Seeding Data

To test various page states - like an empty view, or a pagination view, you’d need to seed the server so that this state can be tested.

While there is a lot more to this strategy, you generally have three ways to facilitate this with Cypress:

If you’re running node.js on your server, you might add a before or beforeEach hook that executes an npm task.

describe("The Home Page", () => {
  beforeEach(() => {
    // reset and seed the database prior to every test
    cy.exec("npm run db:reset && npm run db:seed");
  });

  it("successfully loads", () => {
    cy.visit("/");
  });
});

Instead of just executing a system command, you may want more flexibility and could expose a series of routes only when running in a test environment.

For instance, you could compose several requests together to tell your server exactly the state you want to create.

describe("The Home Page", () => {
  beforeEach(() => {
    // reset and seed the database prior to every test
    cy.exec("npm run db:reset && npm run db:seed");

    // seed a post in the DB that we control from our tests
    cy.request("POST", "/test/seed/post", {
      title: "First Post",
      authorId: 1,
      body: "...",
    });

    // seed a user in the DB that we can control from our tests
    cy.request("POST", "/test/seed/user", { name: "Jane" })
      .its("body")
      .as("currentUser");
  });

  it("successfully loads", () => {
    // this.currentUser will now point to the response
    // body of the cy.request() that we could use
    // to log in or work with in some way

    cy.visit("/");
  });
});

Taking a quick look into Cypress with a real world example

Let’s test a login workflow with an API:

// loginForm
import React from "react";
import Togglable from "./Togglable.js";
import PropTypes from "prop-types";

export default function LoginForm({ handleSubmit, ...props }) {
  return (
    <Togglable buttonLabel="Show Login">
      <form onSubmit={handleSubmit}>
        <div>
          <input
            type="text"
            value={props.username}
            name="Username"
            placeholder="Username"
            onChange={props.handleUsernameChange}
          />
        </div>
        <div>
          <input
            type="password"
            value={props.password}
            name="Password"
            placeholder="Password"
            onChange={props.handlePasswordChange}
          />
        </div>
        <button id="form-login-button">Login</button>
      </form>
    </Togglable>
  );
}

And once logged in, you get access to this noteForm where we can create notes:

// noteForm.js
import React, { useRef, useState } from "react";
import Togglable from "./Togglable.js";

export default function NoteForm({ addNote, handleLogout }) {
  const [newNote, setNewNote] = useState("");
  const togglableRef = useRef();

  const handleChange = (event) => {
    setNewNote(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();

    const noteObject = {
      content: newNote,
      important: false,
    };

    addNote(noteObject);
    setNewNote("");
    togglableRef.current.toggleVisibility();
  };

  return (
    <Togglable buttonLabel="Show Create Note" ref={togglableRef}>
      <h3>Create a new note</h3>

      <form onSubmit={handleSubmit}>
        <input
          placeholder="Write your note content"
          value={newNote}
          onChange={handleChange}
        />
        <button type="submit">save</button>
      </form>
      <div>
        <button onClick={handleLogout}>Logout</button>
      </div>
    </Togglable>
  );
}

Inside the cypress folder, we create inside the integration folder a file with the name of the workflow we want to test, so we can call it cypress/integration/note_app.spec.js, the .spec lets cypress know its a test file it should run. Let’s go step by step into building the testing workflow.

We will start by cleaning our testing DB before starting the tests and then we send our credentials for login in:

describe("Note App", () => {
  beforeEach(() => {
    cy.visit("http://localhost:3000");

    cy.request("POST", "http://localhost:3001/api/testing/reset");

    const user = {
      name: "Miguel",
      username: "midudev",
      password: "lamidupassword",
    };

    cy.request("POST", "http://localhost:3001/api/users", user);
  });
});

Now let’s check what happens before we login:

describe("Note App", () => {
  beforeEach(() => {
    cy.visit("http://localhost:3000");

    cy.request("POST", "http://localhost:3001/api/testing/reset");

    const user = {
      name: "Miguel",
      username: "midudev",
      password: "lamidupassword",
    };

    cy.request("POST", "http://localhost:3001/api/users", user);
  });

  it("frontpage can be opened", () => {
    cy.contains("Notes");
  });

  it("login form can be opened", () => {
    cy.contains("Show Login").click();
  });

  it("user can login", () => {
    cy.contains("Show Login").click();
    cy.get('[placeholder="Username"]').type("midudev");
    cy.get('[placeholder="Password"]').last().type("lamidupassword");
    cy.get("#form-login-button").click();
    cy.contains("Create a new note");
  });

  it("login fails with wrong password", () => {
    cy.contains("Show Login").click();
    cy.get('[placeholder="Username"]').type("midudev");
    cy.get('[placeholder="Password"]').last().type("password-incorrecta");
    cy.get("#form-login-button").click();

    cy.get(".error")
      .should("contain", "Wrong credentials")
      .should("have.css", "color", "rgb(255, 0, 0)")
      .should("have.css", "border-style", "solid");
  });
});

Let’s login the user:

describe("when logged in", () => {
  beforeEach(() => {
    /* This is the same as below but unneffective
        cy.contains('Show Login').click()
        cy.get('[placeholder="Username"]').type("jesusdev")
        cy.get('[placeholder="Password"]').last().type('password123')
        cy.get('#form-login-button').click()
        */

    cy.request("POST", "http://localhost:3001/api/login", {
      username: "jesusdev",
      password: "password123",
    }).then((res) => {
      localStorage.setItem("loggedNoteAppUser", JSON.stringify(res.body));
      cy.visit("http://localhost:3000");
    });
  });
});

Now, what is wrong here? The documentation recommends to avoid using the UI to perform tasks like a login, if we can send the request directly to the API, the better.

To reuse this login you need to create a command, go to cypress/support/commands.js:

Cypress.Commands.add("login", ({ username, password }) => {
  cy.request("POST", "http://localhost:3001/api/login", {
    username: "jesusdev",
    password: "password123",
  }).then((res) => {
    localStorage.setItem("loggedNoteAppUser", JSON.stringify(res.body));
    cy.visit("http://localhost:3000");
  });
});

And now we can reuse this like:

describe("when logged in", () => {
  beforeEach(() => {
    cy.login();
  });
});

Another case is doing a HTTP request which needs to contain the token, we can add another command using the token we got inside our localStorage:

Cypress.Commands.add("createNote", ({ content, important }) => {
  cy.request({
    method: "POST",
    url: "http://localhost:3001/api/notes",
    body: { content, important },
    headers: {
      Authorization: `Bearer ${
        JSON.parse(localStorage.getItem("loggedNoteAppUser")).token
      }`,
    },
  });

  cy.visit("http://localhost:3000");
});

And to use it:

describe("when logged in", () => {
  beforeEach(() => {
    cy.login({ username: "midudev", password: "lamidupassword" });
  });

  it("a new note can be created", () => {
    const noteContent = "a note created by cypress";
    cy.contains("Show Create Note").click();
    cy.get("input").type(noteContent);
    cy.contains("save").click();
    cy.contains(noteContent);
  });

  describe("and a note exists", () => {
    beforeEach(() => {
      cy.createNote({
        content: "This is the first note",
        important: false,
      });

      cy.createNote({
        content: "This is the second note",
        important: false,
      });

      cy.createNote({
        content: "This is the third note",
        important: false,
      });
    });
  });
});

To store and reuse an element in the DOM with Cypress we can do the following:

cy.contains("This is the second note").as("theNote");

Which we can reuse it inside our describe and a note exists using the get and using as to declare the variable, in this case theNote which we can get by using cy.get('@theNote'):

describe("and a note exists", () => {
  beforeEach(() => {
    cy.createNote({
      content: "This is the first note",
      important: false,
    });

    cy.createNote({
      content: "This is the second note",
      important: false,
    });

    cy.createNote({
      content: "This is the third note",
      important: false,
    });
  });

  it.only("it can be made important", () => {
    cy.contains("This is the second note").as("theNote");

    cy.get("@theNote").contains("make important").click();
  });
});

cypress run runs it headless which means it will not show the GUI.

Some notes:

Conclusion

Today we learned how to test! This is a very valuable skill that will help us a lot moving forward, we learned react-testing-library and mock-service-worker or msw for shorts. We learned how the different queries, the firing events, how to handle async cases and how to test our API calls the correct way!

See you on the next post.

Sincerely,

Eng. Adrian Beria