When we first started working on the end-to-end tests, we used Selenium with Facebook composer library and ChromeDriver. This technology has been around for a decade but hasn’t been changing much lately. We therefore looked around to check what other people are using nowadays. It turns out that Cypress is a new thing in the field of end-to-end testing – and what could be better for a software engineer than playing with a new tool?
Cypress already has very good documentation available at https://docs.cypress.io so I’m not going to repeat it here, but I want to share with you some tricks I discovered while working on this project.
1. Asynchronous
Everything is asynchronous in Cypress. For example, when you get an element with cy.get('yourcoolselector')
you’re thinking of it as $('yourcoolselector')
, but in fact it’s an iterative process when trying to find this element from DOM. In other words, Cypress waits for a selector within a certain timeout.
It’s most likely that you’re going to interact with DOM, so your best friend is cy.get()
. It serves two purposes: to ensure that such an element exists on the page, and to get access to it and its children.
cy.get('#wpbody-content .ai1wm-container .ai1wm-row')
.first()
.find('.ai1wm-left')
.should('be.visible')
2. Aliases instead of variables
Since Cypress chains are asynchronous and they wait for each other, you can’t store an element object into a variable. Instead, you need to use aliases for that.
Let’s assume you have a button on a page and you’re going to perform a series of tests with it. Of course, you respect DRY principle and don’t want to copy and paste the same code. Here’s what your code will look like:
describe('create backup button', function() {
beforeEach(() => {
cy.get('a#ai1wm-create-backup').as('button')
})
it('Create backup button should be visible, has right text and attributes', function() {
cy.get('@button')
.should('be.visible')
.should('have.attr', 'href', '#')
.should('have.class', 'ai1wm-button-green')
.should('contain', 'Create backup')
.children('i.ai1wm-icon-export')
.should('be.empty')
})
it('Clicking the Create backup button should trigger the export', function() {
cy.get('@button').click()
// Export test logic comes here
})
})
3. Direct application access
Unlike Selenium or any other web driver tool, Cypress runs in the same browser window, allowing it to get access to the application DOM, JS and data using window and document objects. You can subscribe to the events happening in the app and react to them in your Cypress integration tests.
Another cool thing is debuggability; you can easily put the debugger
command in your test and open a web inspector in the Cypress Chrome window to inspect the context or run the test step by step.
it('File triggers file dialog', function() {
cy.get('@button').click()
const clickEventHandlerMock = cy.spy()
cy.get('#ai1wm-select-file').then(input => {
input.on('click', clickEventHandlerMock)
cy.get('.ai1wm-dropdown-menu.ai1wm-import-providers li:first')
.click()
.then(() => {
expect(clickEventHandlerMock).to.be.calledOnce
})
})
})
4. baseUrl
Cypress provides a neat API for you. For example, to navigate to your application URL you use cypress.visit('https://mycoolapp.local/home')
Of course, you don’t want to specify the absolute URL every time. To avoid doing so, there’s a baseUrl
parameter that you can specify in your cypress.json file like so: "baseUrl": http://wordpress
It allows you to write just cypress.visit('/home')
instead.
Now let’s look at how to organize your tests. After installation, Cypress creates a folder named cypress
in your root. Inside, you can find integration
, support
, fixtures
and some other service folders.
You need to place all the tests you create into the integration folder. You can then freely organize the structure of your tests into a subfolders tree to reflect your app functionality sections.
5. SetUps and TearDowns
Define your test prerequisites (for example, user should be logged in as admin before test is executed) using before and beforeEach, and clean up the state using after and afterEach.
before(() => {
cy.activate()
cy.login()
cy.checkPermissions()
})
beforeEach(() => {
cy.visit('/wp-admin/admin.php?page=ai1wm_import')
cy.get('.ai1wm-button-group.ai1wm-button-import').as('button')
})
afterEach(() => {
cy.deleteAllBackups()
})
after(() => {
cy.deactivate()
cy.logout()
})
6. Commands
These cy-calls are user-defined commands. How can you define those? Very easily: the support
folder has a file called commands.js
in which you can put all your reusable helper methods. Here’s what login
command looks like:
Cypress.Commands.add('login', function (options = {}) {
cy.visit('/wp-login.php')
cy.fixture('user').then((user) => {
cy.get('#loginform').within(function() {
cy.get('#user_login').clear().type(user.login)
cy.get('#user_pass').clear().type(user.password)
cy.root().submit()
})
cy.url().should('include', '/wp-admin')
})
})
7. WaitForResponse
If you’ve ever used Selenium or any other web driver-based end-to-end testing framework, you might have used something like waitForSelector, which tells orders to wait for an element to show up and then to make an action. With Cypress, instead of waitForSelector, we have a better thing called waitForResponse.
What this means is that we listen for a network request and when it’s processed, check for an update on the page.
In the following example, we submit a feedback form and wait for an AJAX call. A confirmation message is then shown as a result:
it('The form displays a success message when all details are input and server returns success', function() {
cy.fillInFeedbackForm()
cy.server()
cy.route(
'POST',
'http://wordpress/wp-admin/admin-ajax.php?action=ai1wm_feedback',
'{"errors":[]}',
).as('feedback')
cy.get('#ai1wm-feedback-submit').click()
cy.wait('@feedback')
cy.get('.ai1wm-feedback .ai1wm-message.ai1wm-success-message p').should(
'have.text',
'Thanks for submitting your feedback!',
)
cy.get('@form').should('not.exist')
})
In order to create a network request listener, we first need to define a route. It could be an exact URL match or a wildcard. In the example above, we’re waiting for a POST sent to http://wordpress/wp-admin/admin-ajax.php?action=ai1wm_feedback
. The third parameter is a stubbed response. If this argument is passed, Cypress will hijack the network request and substitute it with a stub, which allows us to test frontend separately from the backend. All the cy.route
requires cy.server
to be called first – and as you may have noticed, route uses aliases in the same way as a DOM element does with cy.get
.
8. No hover
Not everything can be done with Cypress. The most annoying thing is that Cypress doesn’t have such a simple thing as hover: https://docs.cypress.io/api/commands/hover.html#Workarounds
This means even a simple test case like this can turn into a nightmare: “Hovering over a row, displays a message about adding a label”. The workaround offered by Cypress is to call .show()
on the hidden element. However, it makes no sense to show the hidden element and then check whether it’s visible.
Another limitation we encountered is related to the fact that we’re working in a “sandbox”. In other words, there’s no such thing as being able to choose a file from your computer to upload, or to drag and drop a file from Finder in order to upload it. These cases are to be considered based on the details of your implementation. For example, every drag and drop file upload solution has a hidden file input. It could be set as a blob and the change event could be triggered on this input. Here’s a command we came up with for a file upload:
Cypress.Commands.add('uploadFile', (element, eq, fixturePath, mimeType, filename) => {
cy.get(element).eq(eq)
.then((subject) => {
cy.fixture(fixturePath, 'base64').then((base64String) => {
Cypress.Blob.base64StringToBlob(base64String, mimeType)
.then(function (blob) {
var testfile = new File([blob], filename, { type: mimeType }),
dataTransfer = new DataTransfer(),
fileInput = subject[0]
dataTransfer.items.add(testfile)
fileInput.files = dataTransfer.files
cy.wrap(subject).trigger('change', { force: true })
})
})
})
})