Cypress tests tips and tricks

Tomáš Trojčák
10 min readJan 12, 2020

--

Bug fixing, that’s every developer’s nightmare. Even if there is high unit test code coverage, there still occurs new bugs, due to broken functionality, that has worked before. This happens a lot, if there are a very lot of business dependencies on the page, which can’t tested by unit tests, because the code is not one unit, but more units, that are depended each other. In this case, the integration or e2e UI tests are very useful to lock the UI functionality and behavior into the test. This article provides useful practical informations and basic knowledge of cypress testing is expected.

Cypress testing in general

Pros:

  • easy to learn
  • very good documentation on cypress page
  • easy and fast to write
  • no break on test, when code is refactored until the UI and DOM elements stays the same
  • easy to debug

Cons:

  • does not supports internet explorer browser testing
  • slow to execute in comparison to unit test
  • sometimes, they fall into an error, as en element is not found
  • consumes more CPU, when run locally, and will cause visual studio slow down

Why to create?

It is worth to create e2e tests not even for happy path, but to test most of the possible combinations on UI. This will ensure that Imagine you have table, where one row data depends on another row data. In that case it is worth to create combination of:

  • fill 1st row as valid -> fill second row as valid -> delete first row -> fill first row as invalid

…and so on. If you write the tests functions properly, the combinations of user interactions are easy and fast to implement
For e2e test use real back end API calls, for integrations tests mocks are optional. I prefer to use real BE API calls in any case, because the app is tested from end to end properly, and the test can detect more gaps when real data are used.
Which users to use as test users depends on each project. Whether to use standard test users, and each test will create and modify data on test environment and developers using test environment will be affected with this data, or use copy of test users only for testing purposes. Or you can have pure test environment, which is mirror of the development environment.

Examples

Before each

Before each is used the same way, as in unit tests. Use it to set up own timeouts, or load aliases for routes.
Note: you can use aliases also for DOM elements, but remember, if the DOM element gets re-rendered (eg table row), the alias stops working and the cy.get() selector needs to be used again to get the DOM element.

Cypress types letters very fast, to simulate user typing speed, use

Cypress.Commands.overwrite('type', (originalFn, subject, string, options) =>
originalFn(
subject,
string,
Object.assign({}, options, { delay: 100 })
)
);

To slow down the whole test process for debugging or presentation purposes, insert delay for each cypress interaction command:

for (const command of [
'visit',
'click',
'trigger',
'type',
'clear',
'reload',
'contains'
]) {
Cypress.Commands.overwrite(command, (originalFn, ...args) => {
const origVal = originalFn(...args);

return new Promise(resolve => {
setTimeout(() => {
resolve(origVal);
}, commandDelay);
});
});
}

Correct timeout can prevent flaky tests, so you don’t need to worry, that test will fall into timeouts. Default cypress network timeout is 30s but wait command has timeout 5s. To match these two timeouts, overwrite the wait timeout.

for (const command of ['wait', 'url']) {
Cypress.Commands.overwrite(
command,
(originalFn, subject, string, options) =>
originalFn(
subject,
string,
Object.assign({}, options, { timeout: 60000})
)
);
}

How to properly call APIs

For api call, always use aliases and alwaiz wrap them into wait command, which returns promise, when the api call is done. Otherwise without wait command the cypress will just fire the API call and continue test execution immediately. Probably the test will do an assertion later (the ‘should’ command has 4s timeout) and if the assertion depends on the result of the fired api call, and the call is not done yet, the test will fail.
Aliases can be also loaded in before each:

cy.route({
method: 'POST',
url: '**/v1/prices/upload'
}).as('uploadPrices');
cy.route({
url: '*/v1/prices/initialData/*'
}).as('getInitialData');
cy.route({
url: '*/v1/permissions/*'
}).as('getPermissions');

Note: the get API does not need to have the method property filled. The correct test, to call the API should looks like this (example is from page initalization):

cy.wait('@getPermissions').then(() => {
cy.wait('@getInitialData').then(() => {
assertButtonsStatesAfterPageInit(); //assert UI, how it should looks like after page is loaded. Test elements enabled/disabled, visible/hidden...
});
});
Update: you don't need to deep insert and resolve promises. Cypress will just stops the code execution, until promise is resolved. So the code above will work the same way, and looks simple:
cy.wait('@getPermissions');
cy.wait('@getInitialData');
assertButtonsStatesAfterPageInit(); //assert UI, how it should looks like after page is loaded. Test elements enabled/disabled, visible/hidden...

Default wait timeout is 5s, but we have override this for our test to 30s, see the Before each section. Another way, how to set timeout is to set it in wait options:

cy.wait('@getPermissions', { timeout: 45000}).then(() => {....})

If you let wait timeout set to default, every API call, that takes more, than 5s, will cause error in test execution.

Mock API response

To mock API response, simply create json file with response values, put it into fixtures folder, which is inside cypress tests folder and then create route alias, which returns mocked response.

cy.route(
'GET',
'*/v1/myUrl/myEntity/2346755',
'fixture:mockedResponse.json',
).as('myUrlAlias');

Note: cypress currently does not supports the mutation of real response. So you can’t get real BE response, then modify some values and continue test. How ever, there are some workarounds using xhook script. See here.

Debugging

To debug the test easier, maintain this recommendations:

  • use cy.log(‘log message’) often. Wrap each section in
cy.log('table test START') 
//test table here
cy.log('table row test START')
//test table row here
cy.log('table row test END')
cy.log('table test END')

If you have loop in your test, add index to the cy.log so you will know, which iteration has failed.
Dont mix assertions with events in functions.

export function assertButtonsAfterPageInit() {
cy.log('assertButtonsAfterPageInit START');
cy.get('[data-cy=input_1]').should('not.be.enabled');
cy.get('[data-cy=button_1]').should('be.enabled');
cy.get('[data-cy=button_1]').click() //DONT CLICK THIS IN THIS FUNCTION
cy.log('assertButtonsAfterPageInit END');
}

The function is then harder to reuse, because there can be case, that you need to do the assertion, but not the click event. Also try to avoid parametrized functions, as they are more complex, and harder to debug and read the test code with parametrized functions.
To stop the cypress code execution use debugger; keyword anywhere in the code. The browser will then stop the code execution on that line, and you can debug the cypress code step by step.

Debugging the performance of cypress test

Lets imagine situation, where the E2E tests are running a very long time, like more than hour and you need to cut the running time. As the cypress itself is running “as fast as possible”, the only option is to refactor the test. But how to find, what to refactor? Which part of testing takes the most of the time? What about to let the cypress log test parts duration? See, how to create duration logger. The output will be logs in cypress time console (in the browser window)
We can reuse mine cy.log recommendation (see above the Debuggin section). I strongly recommend to put as much as possible pairs cy.log(“my section START”) cy.log(“my section END”) in each test section of at the beginning and end of function. Then we can appent to these logs timestamps (by ovewriting the cy.log command) and calculate duration between each START+END pair. The code block below assumes, that you use log messages witch START/END at the end of the message like:

export function submitData() {
cy.log('submitData START');
cy.get('[data-cy=submit_btn]').should('be.enabled').click({ force: true });
cy.wait('@updateData').then(() => {
cy.url().should('include', 'datalist');
});
cy.log('submitData END');
}

Call the DurationLogger.addTimestampToLog() at the beginning of the monitored test (or in beforeEach(….)) and DurationLogger.processDuration() at the end (or in afterEach(…))

How to test file upload using cypress

Cypress does not supports the windows window events and interaction. There fore it can’t open the ‘select file’ window and interact with it. There is workaround for this, where file will be injected into the drop zone or file input. Install this package to use this workaround: https://www.npmjs.com/package/cypress-file-upload.
Example:

const fileName = './Price-list.xlsx';      cy.fixture(fileName, 'base64').then(fileContent => {
cy.get('[data-cy="file-input"]').upload(
{
fileContent,
fileName,
mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
encoding: 'base64'
},
{ subjectType: 'input' }
);
cy.wait('@uploadMyFile') //alias for post API
.its('status')
.should('be.ok');
});

The library supports some file types, where it can auto detect file upload options. In unsupported files, the mimeType and encoding needs to be set manually.

Update:

There was update regarding file upload in cypress.

In commands.ts in your cypress tests folder import

import 'cypress-file-upload';

Then you can use the attachFile function:

cy.fixture(fileName, 'base64').then(fileContent => {
cy.get('[data-cy="file-input"]').attachFile(
{
filePath: '/path',
fileContent,
fileName,
mimeType: mimeType,
encoding: encoding,
},
{ subjectType: 'input' },
)
})

2nd option:

it’s possible to create custom command for file upload, to avoid using third party libs. In your command.ts file, which is within your test folder add:

usage is simple:

cy.get('[data-cy=upload_button_input]')
.attach_file('./food.jpg', 'image/jpg')
.trigger('change', { force: true });

Code coverage

Cypress is providing the option to calculate the code coverage and even show, which part of the code is not covered. But I was not able to get it working properly (code instrumentation library wont work). See more about code coverage here.

code coverage output

Useful tips

  • use as less as possible selectors like find, parent….because if html changes, test will fail (you know it from css styling for sure ;). Rather insert attributes data-cy into html elements
  • create as much reusable functions as possible. It helps to read the test code and build new test cases easily using that generic function. Eg create assertion function, which checks the final state of the form. Call this assertion function meaningful after each test event. Like submit should not be enabled, if all required fields are not filled. Fill first field -> call the assertion function. Fill second field -> call the assertion function…
  • If you have wrapped anchor in table cell on UI, cypress clicks exactly in the middle of the cell if you are trying to click on element, where the link is not clickable. Therefore rather put data-cy attribute directly on anchor tag and click this anchor tag.
  • Before each button click do should(‘be.enabled’) assertion. This will prevent cypress to click on button, which is not enabled yet(eg if you are going to click on button, which should go into enabled state after previous action)
  • as the Html page is rendered differently, then angular template, look on the page code to create selectors. Typical case is mat-table
  • You can have your e2e cypress tests run locally, which also builds your project, so you can use localhost for debugging at the same time. Useful when refactoring code: the test will run on code change again and again.
  • when typing any value in any field in test, use unique value. The value is shown in log and can be used to debug failed test.

Patterns

It seems easy to implement loops, or if else statements, but can be tricky at the and of the day. Here are some patterns, that are working

Dynamic data-cy attribute

Sometimes you need to generate the data-cy attributes dynamically. Use attribute binding in that case. Take in mind, that attribute value can not contains spaces.

[attr.data-cy]="'myDataObject.myType.' + type"

Get table rows count

Mind the scope. The rowsCount is only visible inside the Promise.

cy.get('[data-cy=my_table]').then($table => {
const rowsCount = $table.find('tbody').find('tr').length;
})

If you need to assert the count of the rows use have.length.eger

cy.get('[data-cy=my_table]')
.find('tbody')
.eq(0)
.find('tr')
.should('have.length', 1);

Table with expandable row

Notice the if statement, where the execution only continues, if there are any rows:
if ($table.find(‘tbody’).find(‘tr’).length > 0) {…

cy.get('[data-cy=my_table]').then($table => {
if ($table.find('tbody').find('tr').length > 0) {
const rows = cy
.get('[data-cy=my_table]')
.find('tbody')
.eq(0)
.find('tr.detail-row');//expandable row
rows.each(($row, index, $rows) => {
cy.log('my table expandable row assertion START' + index.toString());
for (const element of [
'[data-cy=table_input_1]',
'[data-cy=table_input_2]',
'[data-cy=table_input_3]',
'[data-cy=table_input_4]'
]) {
cy.get(element)
.eq(index)
.find('input')
.eq(0)
.should('be.enabled');
}
cy.get('[data-cy=table_input_5]')
.eq(index)
.should('be.enabled');
cy.get('[data-cy=table_input_6]')
.eq(index)
.should('be.enabled');
cy.log('my table expandable row assertion END');
});
}
});

Find element in table

Finds button in each table row and clicks it.

cy.get('[data-cy=my_table]').then($table => {
if ($table.find('[data-cy=delete_row_column]').length > 0) {
cy.log('try to delete my table row START');
cy.get('[data-cy=delete_row_column]').each($deleteColumn => {
$deleteColumn.find('button').click();
waitForUndo = true;//waits 3s, until it gets deleted
});
cy.log('try to delete my table row END');
}
});

If else statement

This execution only continues, if button is enabled. It want cause the test to stop due to error, if button is disabled.

cy.get('[data-cy=my_save_button]').then($button => {
if ($button.is(':enabled')) {
cy.get('[data-cy=my_save_button]').click();
}
});

If statement for element that might not even exists. If you try to cy.get element, that does not exists, the test will fail, even if the non existing element is expected. Solution is to get parrent and try to find child:

cy.get('#parent').then($element => {
if ($element.find('#desiredElement').length>0) {
....
}
});

If else fork based on element’s value:

cy.get('[data-cy=my_input]').then(input => {
if (Cypress.$(input).val() > 0) {
cy.get('[data-cy=my_input]').should('be.disabled');
} else {
cy.get('[data-cy=my_input]').should('be.enabled');
}
});

Check if element does not exists

This is a little bit tricky, as if you try to cy.get(‘my_element_should_not_exists’).should(‘not.exists’) non existable element, the test will fail. As I mentioned above in the “if” topic, non existable elements should be tested via its parents. So cy.get the parent and try to find non existable element.

cy.get('[data-cy=my_parent]')
.find('[data-cy=my_non_existable_element]')
.should('not.exist');

Routes

To wait for a API call and check its result, use

cy.wait('@myApiAlias').then((xhr) => {
expect(xhr.response.headers.status).to.eq('200');
});
//other option, that should also work cy.wait('@myApiAlias').then((xhr) => {
expect(xhr.status).to.eq(200);
});

You can use the debugger; key inserted into the .then(() =>…) statement and inspect the xhr response in console localy.

Some usefull getters

Get value

//angular reactive form input
cy.get('[data-cy=my_input]').should('have.value', '0.00');
//table <td data-cy="pl_table_name_col">my value</td>cy.get('[data-cy=pl_table_name_col]').should('contain', 'my value');

Working with checkboxes from material design

Mat-checkbox is rendered as block element with multiple childs. The easiest way to assert checkbox is to put data-cy attribute to mat-checkbox element, and then ask for class mat-checkbox-checked or mat-checkbox-disabled.

unchecked checkbox does not contains the mat-checkbox-checked class
//enabled checkbox
cy.get('[data-cy=accept_sp_fee]').should('not.have.class','mat-checkbox-disabled');
//unchecked checkbox
cy.get('[data-cy=accept_sp_fee]').should('not.have.class','mat-checkbox-checked');

--

--

Tomáš Trojčák
Tomáš Trojčák

Written by Tomáš Trojčák

Fullstack developer since 2007, Angular enthusiast since 2018

No responses yet