Diving into e2e testing with Cypress
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:
- Get OAuth2 token
- Search some text
- Play the first song displaying a play icon
- Wait 2 seconds
- 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:
- Starts a server to serve mock data
- 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!?
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... ).