Installation and running tests
Installation
Install cypress as dev dependency
npm i -D cypress
Additionally install testing library tool and mysql2 (it overcomes an issue with mysql 8 credentials)
npm i -D @testing-library/cypress mysql2
Running tests
After installation go ahead and update your package’s scripts, by adding:
"scripts": {
...
// opens up cypress launchpad in a separate window as binary
"cypress:open": "cypress open",
// starts headless tests
"e2e": "cypress run"
},
For your first start I recommend using Cypress npm executable:
npx cypress open
From now on you should be able to start your tests in Cypress launchpad or in the terminal by:
npm run cypress:open
npm run e2e
Cypress config
Configuration file
It allows adding lots of custom configuration, which I encourage to read about in official docs. For this blog post I will focus on a few that are necessary for the exercise.
Environment variables
It is important, because it allows different files for each environment e.g. .env, .env.local, .env.test and so on. Differentiating configuration for development on local machine from starting tests within CI/CD on test server, is a piece of cake.
In order to use it dotenv package is required, I am using CRA, so it’s already available.
E.g. baseUrl of tested application, user/pass for login, db credentials:
## TESTING VARIABLES
# application URL, could be a domain of testbed e.g. http://bookstore.staging.com
E2E_BASE_URL=localhost:3000
# used to mark if tests are performed on localhost or testbed (e.g. determines if login page is used)
E2E_USE_TESTBED=true
# login credentials
E2E_USER="tester"
E2E_PASSWORD="password"
# db credentials
E2E_DB_HOST="localhost"
E2E_DB_PORT=3306
E2E_DB_NAME="bookstore"
E2E_DB_USER="user"
E2E_DB_PASS="password"
Now variables can be used inside cypress.config.ts:
import dotenv from "dotenv";
import { defineConfig } from "cypress";
import mysql, { ConnectionOptions } from "mysql2";
// predetermines environment usage - .env.local is gitignored, so in testing environment it will use .env as fallback
dotenv.config({ path: ".env.local" });
const baseUrl = process.env.E2E_BASE_URL;
// the connection strings for different databases could
// come from the Cypress configuration or from environment variables
const connection: ConnectionOptions = {
host: process.env.E2E_DB_HOST,
port: process.env.E2E_DB_PORT ? parseInt(process.env.E2E_DB_PORT, 10) : 3309,
user: process.env.E2E_DB_USER,
password: process.env.E2E_DB_PASS,
database: process.env.E2E_DB_NAME,
};
// querying the database from Node
function queryDB(connectionInfo: ConnectionOptions, query: string) {
const connection = mysql.createConnection(connectionInfo);
connection.connect();
return new Promise((resolve, reject) => {
connection.query(query, (error, results) => {
if (error) {
return reject(error);
}
connection.end();
return resolve(results);
});
});
}
/** Testing configuration - reads baseUrl from .env, ties .env with Cypress.env, registers queryDataBase task with connection setup via node server */
export default defineConfig({
e2e: {
baseUrl: baseUrl,
env: {
...process.env,
},
setupNodeEvents(on, config) {
on("task", {
queryDatabase({ query }) {
const connectionInfo = connection;
if (!connectionInfo) {
throw new Error(
`Do not have DB connection under name ${process.env.E2E_DB_NAME}`
);
}
return queryDB(connectionInfo, query);
},
});
return config;
},
},
});
Database connection
It is crucial for e2e testing to have connection with db, to be able to query and tear data between test cases, so they stay atomic. As you can see above I added node event, which triggers queryDB method. This method allows performing any mysql queries inside the tests.
Adding a task in such manner allows calling queries e.g.:
/**
* Selects all data from db table
* @param {string} tableName database table name.
*/
export const selectAll = (tableName: string) => {
return cy.task("queryDatabase", {
query: `SELECT *
FROM ${tableName}`,
});
};
Currently, I keep such helper methods inside ./cypress/e2e/utils/dbUtils.cy.ts file in project directory.
First test
All tests are kept in ./cypress/e2e, for example ./cypress/e2e/books/books.cy.ts.
I added utils catalog for database and selector utils (the latter could probably be implemented in form of cypress commands):
// utils/utils.cy.ts
/** Logs into application with credentials read from .env */
export const logIn = () => {
fillInTextField(/username/i, Cypress.env("E2E_USER"));
fillInTextField(/password/i, Cypress.env("E2E_PASSWORD"));
cy.findByRole("button", { name: /log in/i }).click();
};
/**
* Finds TextField by labelText and types in given argument.
* @param {regexp} label intended to have case-insensitive label text.
* @param {string} value input value to be typed-in.
*/
export const fillInTextField = (label: RegExp, value: string) => {
return cy.findByLabelText(label).type(value);
};
// utils/dbUtils.cy.ts
/**
* Removes all data from db table
* @param {string} name database table name.
*/
export const clearAll = (name: string) => {
return cy.task("queryDatabase", {
query: `TRUNCATE TABLE ${name}`,
});
};
/**
* Selects all data from db table
* @param {string} tableName database table name.
*/
export const selectAll = (tableName: string) => {
return cy.task("queryDatabase", {
query: `SELECT *
FROM ${tableName}`,
});
};
export interface QueryData {
/** Name of db table to be affected */
tableName: string;
/** data to be inserted, where key is table column and value is insert value */
data: Record<string, any>;
}
/**
* Adds row data into db table
* @param queryData consist of tableName and data object, where key corresponds to table column and value to insert value
*/
export const addEntry = (queryData: QueryData) => {
const { tableName, data } = queryData;
const columns = Object.keys(data).join();
const values = Object.keys(data)
.map((k) => `"${data[k]}"`)
.join();
return cy.task("queryDatabase", {
query: `INSERT INTO ${tableName}
(${columns})
VALUES (${values})`,
});
};
To keep things simple the tested application is a CRUD with books list, consisting id, title, author and number of pages. It has list view and separate view for adding a new book.
Inside our books.cy.ts we can add:
describe("Books", () => {
beforeEach(() => {
// this is dbUtility, which truncates given table, by name
clearAll(`books`);
});
it("checks db connection", () => {
const data: QueryData = {
tableName: "books",
data: {
id: 1,
title: "1984",
author: "George Orwell",
pages: "254",
},
};
// dbUtility
addEntry(data);
// dbUtility
selectAll(`books`).then((result: any[]) => {
expect(result.length).to.equal(1);
});
});
it("creates entry", () => {
// navigates to baseUrl
cy.visit("/");
// used when full e2e testbed is set up, on localhost it skips login
if (Cypress.env("E2E_USE_TESTBED") === "true") {
logIn();
}
// navigates to books view
cy.findByRole("link", {
name: /books/i,
}).click();
// within helps out with limitng search scope for element
cy.findByRole("main").within(() => {
cy.findByTestId("AddIcon").click();
});
// custom util for text fields
fillInTextField(/title/i, "Neuromancer");
fillInTextField(/author/i, "William Gibson");
fillInTextField(/pages/i, "304");
// submit data
cy.findByRole("button", { name: /save/i }).click();
// check if newly created book is present on the list
cy.findByText(/Neuromancer/i).should("exist");
});
});
Gitlab CI example configuration
Gitlab test stage (assuming docker usage) could be as simple as one below. This topic quite large, so I won’t go deeper than proposing an example.
Test:
stage: test
# cypress docker image, allowing to run cypress on a server
image: cypress/base:16.14.2
script:
# install dependencies
- npm ci --cache .npm --prefer-offline --progress=false --legacy-peer-deps
# start server in the background
- npm run start:e2e & npx wait-on http://localhost:3000
# run Cypress test
- npm run e2e
only:
refs:
- branches
artifacts:
expire_in: 1 day
The difficulties
Selectors
If your codebase has not been automatically tested before brace yourself for modifications in terms of accessibility, matching inputs with labels in a correct way and semantic errors, as it is crucial for element querying.
My advice to is mostly use findByRole(“given_role”, {name: /elementName/i}), as it resembles what User does when semantically searching, and it is implementation change resistant.
At the same time be open to using other methods when necessary - data-testid attribute or xpaths work every time, but have major downsides. First one has to be implicitly added to your components, second is very prone to changes, so any time DOM changes your tests will fail.
Async actions
Most of the time cypress works really well with async methods, but there are cases, which require some attention. For example if you send multiple requests, or retries which should all be awaited you have to program it yourself in your tests.
Variables and aliases
Due to asynchronous nature of Cypress, returning, reading and storing values from function calls seems counterintuitive. It’s well described inside official docs article tough.
For example accessing button text from beforeEach block has to be done as below:
beforeEach(() => {
// alias the $btn.text() as 'text'
cy.get('button').invoke('text').as('text')
})
it('has access to text', function() {
this.text // is now available
})
Tips
Add testing library extension to your browser, so it helps you with querying DOM elements. Remember it was designed for js-dom version, and it differs from cypress one, so for cypress every query will be in form of cy.findBy….
If possible use regexps for your searches.
Summary
As you can see setting up core mechanisms for e2e tests with Cypress is quite simple and powerful.
Now we are ready to enlarge test case base, coverage and improve code testability as well.
Further reading:
Last updated: October 15, 2022