Diving into e2e testing with Cypress

May 7, 2019

A couple days ago, we published the introduction to e2e with Cypress which presumably left many questions without answers. As promised, here is the sequel you were waiting for, with a more complex and real-life use case to demonstrate the power of Cypress.

Hands on!

Let's continue working on the mimaflow-player. We are testing a different use case:

When the user performs a search with available results, he/she should be able to play and pause a song.

First, let's create a new file play.spec.js in the cypress/integration folder for the new test.

If you remember from the previous post, we needed to request an OAuth2 token to perform searches against the Spotify API. For this test, and every other further test that requires authentication, we need to repeat the request to obtain the token. Instead of repeting those lines of code for each and every test, we can refactor it using commands.

Commands

Commands are thought for extending (or even overriding) the Cypress API to apply custom logic. You can add a new command or overwrite an existing one with Cypress.Commands.[add,overwrite]. In this case, we want to add a custom command that runs the code to obtain an OAuth2 token. This is as simple as modifying the file cypress/support/commands.js by adding the following content:

Cypress.Commands.add("getToken", () => {
  Cypress.log({
    name: "getTokenFromSpotifyAPI"
  });
  const auth =
    "Basic MTE5Y2Y4MTI2MWVlNGFjNWI3YjQ5ZTA5M2M4ZmNlNzI6ZWEzZGRlMmMwMDdlNGVkYTliNGFkYTc1M2I0MzViYTM=";

  const options = {
    method: "POST",
    url: "https://accounts.spotify.com/api/token",
    form: true,
    body: {
      grant_type: "client_credentials"
    },
    headers: {
      Authorization: auth
    }
  };

  cy.request(options).then(resp => {
    localStorage.setItem("token", resp.body.access_token);
    return resp.body.access_token;
  });
});

Next time Cypress runs, it will register a new command getToken() that can be used from tests. Therefore, the previous search songs test can now use this new command, see line 3:

describe("Search test suite", () => {
  it("should search songs", () => {
    cy.getToken(); // cleaner and more readable
    cy.visit("http://localhost:3000");
    cy.get("#search-term")
      .focus()
      .type("take on me")
      .should("have.value", "take on me");
    cy.get("button[type=submit]").click();
    cy.get(".total-tracks")
      .should("be.visible")
      .and("contain", "10 results");
    cy.contains("10 results");
  });
});

One could even override the cy.visit() command to incorporate that logic so you wouldn't need to make sure every test runs the cy.getToken() command before visiting the page. The following code snippet shows how to do it:

Cypress.Commands.overwrite("visit", (originalFn, url, options) => {
  cy.getToken().then(() => originalFn(url, options));
});

But in this case, cy.visit must not include the cy.getToken command, as we will see later when we cover data mocking.

Now, let's go back to the new test. These are the steps to validate that a user can play and pause songs:

  1. Get OAuth2 token
  2. Search some text
  3. Play the first song displaying a play icon
  4. Wait 2 seconds
  5. Pause the song that was playing

The tricky part of this test is how to check if the song is playing. Our component creates an Audio object when the user clicks on the play icon with the url to the preview of the song. The following code snippet shows the function triggered when the play icon is clicked:

playTrack = track => {
  const audioTrack = new Audio(track.preview_url);
  audioTrack.play();
  this.setState({ audioTrack, nowPlaying: track.id, isPlaying: true });
};

We need to mock the Audio constructor so we can validate later if the audio is playing. Cypress makes your life easy by providing stubbing out of the box.

First, the stub must be created before the function playTrack is called.

The command cy.window(), which yields the window object, together with cy.stub() provide the necessary to stub the Audio constructor:

cy.window().then(win => {
  cy.stub(win, "Audio").callsFake(url => {
    const aud = new Audio(url);
    audio = aud;
    return aud;
  });
});

The window object needs to be yielded because all commands in Cypress run asyncrhonously. Trying to run the cy.stub() on its own would give an undesired output. The command cy.stub() creates the stub and with callsFake we enhance the behavior of the method by storing the audio object in a separated variable so we can check later if this audio is playing.

This is the test implementation with the stub:

let audio;

const stubAudio = () => {
  cy.window().then(win => {
    cy.stub(win, "Audio").callsFake(url => {
      const aud = new Audio(url);
      audio = aud;
      return aud;
    });
  });
};

describe("Player tests", () => {
  it("should be able to play and pause a song", () => {
    cy.getToken();
    cy.visit("/");
    stubAudio();
    cy.get("[data-cy=search-term]")
      .focus()
      .type("take on me")
      .should("have.value", "take on me");
    cy.get("[data-cy=search-btn]").click();
    cy.get(".fa-play-circle").should("have.length", 10);
    cy.get(".fa-play-circle")
      .filter(".show")
      .first()
      .click();
    cy.get(".fa-play-circle").should("have.length", 9);
    cy.get(".fa-pause-circle").should("have.length", 1);
    cy.wait(2000);
    //validate song is playing
    cy.get(".fa-pause-circle")
      .filter(".show")
      .first()
      .click();
  });

Next, we need to validate if the song is playing after clicking on the play icon. Now that the audio is stored in a separate variable, it is as simple as checking if the currentTime property of the saved audio is greater than 0 (note that part of the code has been omitted to shorten the snippet):


//... other declarations omitted

const shouldPlaySong = () => {
  cy.wrap(null).should(() => {
    expect(audio.currentTime).to.be.above(0);
  });
};

describe("Player tests", () => {
  it("should be able to play and pause a song", () => {
    //... other commands omitted
    cy.wait(2000);
    shouldPlaySong();   //validate song is playing
    //... other commands omitted
  }
}

Note that it is not possible to write the expect statement after the wait command because, as we already mentioned, Cypress commands run asynchronously. Writing the expect statement as an imperative order would end up in the error: TypeError: Cannot read property 'currentTime' of undefined. That is why we need to use the command cy.wrap() which yields whatever we pass as a parameter. In this case, we pass a null value because the purpose of using wrap is to let Cypress handle the asynchronous assertion and we are ignoring the yielded value.

Fixtures

Another interesting feature of Cypress are fixtures. A fixture is a data file (json, image, etc) that can be loaded into your test to mock your data so you do not need to rely on the availability of third party resources, for instance, the Spotify API.

In this example, the list of songs displayed after clicking the Search button could be loaded from a JSON instead of calling the Spotify API. This would avoid the logic to obtain the token and having to put sensitive data in the test files (we will talk about this later).

Cypress provides a cy.server() and cy.route() commands that allow you to mock API requests in your tests using fixtures. For instance:

cy.server(); //1

cy.route("GET", "https://api.spotify.com/v1/search**", "fx:songs.json"); //2

The code snippet above shows:

  1. Starts a server to serve mock data
  2. Whenever the application makes a GET request against the provided url, the content of the songs.json file (existing in the fixtures folder) will be loaded.

Unfortunately, this will not work in this example as the application uses fetch to interact with the API which is not currently supported by Cypress.

Wut!?

throw-computer-bin

Don't panic yet!

In the meantime, there are workarounds as using a polyfill or, instead of using cy.route(), stubbing the window.fetch operation, which can do the work just as fine.

Configuration

In the previous tests, the command cy.visit() always received the absolute url of the application. If those tests were to be run in a different environment or if the application is running in a different port, the url must be changed manually on each and every test, which is not very good.

The base url of the application can be easily configured in a baseUrl variable defined in the cypress.json file:

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

Which value can be easily overridden using an environment variable CYPRESS_BASE_URL. This way, the cy.visit command can be used with relative paths within the tests:

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

There are other several options that can be configured in the cypress.json file, including an env property where environment variables can be defined. These variables can be read inside the tests with Cypress.env(name).

But, what if you want to have a different configuration file per environment and not just using environment variables?

Cypress does not support this out of the box but one can easily do it with a plugin.

Plugins

The file index.js automatically generated in the cypress/plugins directory when Cypress was run for the first time, looks as follows:

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
};

The Cypress config can be altered if this method returns a different object than config or a modified version. For instance, the following snippet will force the baseUrl of each test to http://idontexist.com:

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  config.baseUrl = "http://idontexist.com";
  return config;
};

We can use a plugin to return the configuration from a config file based on the environment. First, the environment is specified as an env variable environment. It can be set in the cypress.json file, in the package.json, by command line, etc.

Next, let's create a new config folder under the cypress directory and create a file for the development environment development.json. This file must have the same structure as a cypress.json file. Then, we can modify the implementation of the plugin to return the content of the configuration file based on the environment variable:

const fs = require("fs-extra");
const path = require("path");

const getConfigurationFile = env => {
  const pathToConfigFile = path.resolve(".", "cypress/config", `${env}.json`);

  return fs.readJson(pathToConfigFile);
};

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  config.env = config.env || {};
  // you could extract only specific variables
  // and rename them if necessary

  const environment = config.env.environment || "development";

  return getConfigurationFile(environment);
};

We can return a promise because, as seen before, Cypress will wait until the configuration file promise is resolved. Now, we can have different configuration opitons for each environment. This can be extended as much as needed but always remember to return the modified configuration object to make it work!

Conclusion

The aim of this post was to demonstrate more complex use cases that could be more similar to real applications. Cypress is an evolving tool so there are features still on progress that when included, will make this framework very powerful to all our applications. Even with some limitations, Cypress is a great testing tool that can conver most of uses cases (if you forget other browsers support is in progress... ).

About the author: Sandra Gómez

Full-stack engineer at mimacom, although recently has a tight relationship with microservices... Now searching for a spot within the hipster frontend technologies.

Comments
Join us