My AHA moments when testing with Cypress
Nowadays writing automated tests for web apps is usually the only shield against failures we can have. It protects against introducing production bugs in a codebase and make a long-term maintenance easier.
In this article I'm going to describe some of my AHA moments I had when testing my application with Cypress (for the sake of samples I'm using the cypress-testing-library as well, but it's completely optional), including how to test drag and drop interactions.
The background
Before I go into the details. I'm going to give a little background. I was developing E2E tests for interfaces using the DragsterJS library, in order to make sure that code refactoring won't break the existing feature implementation.
DragsterJS is a tiny drag'n'drop library that allows to implement dragging and dropping behaviour to any element on a website or a page. It works well with React, Vue, Angular and other libraries.
Sample drag and drop application
For the sake of this article I'm not going to present the full implementation of feature I've been working on, but rather simpler version of it. It will focus on moving elements from one region to another one. Let's start with some very basic HTML code structure:
<div id="container-0">
<div class="dragster-region">
<div class="dragster-block" data-testid="drag-target">1.1</div>
<div class="dragster-block">1.2</div>
<div class="dragster-block">1.3</div>
<div class="dragster-block">1.4</div>
</div>
<div class="dragster-region">
<div class="dragster-block" data-testid="drop-target">2.1</div>
<div class="dragster-block">2.2</div>
<div class="dragster-block">2.3</div>
<div class="dragster-block">2.4</div>
</div>
</div>
We've defined a container with 2 regions where draggable elements are placed - they have the .dragster-block
class added.
Assuming that you have added the DragsterJS library to your codebase (either by linking to a file in your HTML code or importing it into a module), we're going to create a new instance of DragsterJS object:
const dragster = Dragster({
regionSelector: '.dragster-region',
elementSelector: '.dragster-block',
});
If you correctly implement the library then you should be able to have the following outcome:
Let's go to the description of my AHA moments while developing Cypress tests. There will a few of them.
First AHA moment: cy.get('.selector').then()
When writing Cypress tests it's really important to understand that it's not recommended to keep selectors output in a variable, like here: const element = cy.get('.selector')
. If you really need to refer to a specific element, then you should use a chaining method called then()
which acts similarly to JavaScript promises, but it's not a real JS promise, rather a so-called chainable function.
The output of the chainable function is a jQuery object wrapping the element, that's why it's a good practice to prefix param names with $
in order to get a clear understanding of what type of object is stored in a variable. See an example:
cy.get('.selector').then($element => {
// $element is a jQuery element
});
What was not clear to me at the time of writing E2E tests for drag and drop interactions was that the object has not Cypress methods in it. I was expecting that I will get jQuery methods and Cypress methods attached to the object, hence I could use a following code: $element.trigger('mousedown')
.
Unfortunatelly, ended up with no action on a specific element. No mousedown
event occurred on an element that was supposed to handle it. It occurred, that in order to make it working I had to wrap the variable with cy.wrap()
in order to make the mousedown
event firing in tested elements.
Finally it should look like the following: cy.wrap($element).trigger('mousedown')
and then everything started working as expected. The element got selected as ready to be moved to another place on a page. That was the first AHA moment
To be honest, it is explained in the docs, but hidden so well, that if you don't know what to look for, then you might get a bit frustrated.
Second AHA moment: trigger('mousemove', x, y)
As soon as I was able to mark an element as one to move, then I wanted to specify a location of a drop region. My first idea was to get a position of drop region by using the following code:
cy.get('[data-testid="drag-target"]').then($draggable => {
cy.get('[data-testid="drop-target"]').then($droppable => {
const droppableRect = $droppable[0].getBoundingClientRect();
cy.wrap($draggable).trigger('mousedown');
cy.wrap($draggable).trigger('mousemove', droppableRect.x, droppableRect.y, {
force: true
});
});
});
It turned out it was not working well. But before I explain why, let's take a look on how did I try to get element position. I was extracting native DOM element from a jQuery object: $droppable[0]
, then I invoked the getBoundingClientRect()
function on a DOM element to retrieve it coordinates.
Then I used the coordinates to move an element over a drop target. I was expecting that trigger
will move a draggable element over the drop target, but it was moved onto a completely different element. Not the one I was expecting.
I tried to find out why it's not working and then it became clear to me, that trigger('mousemove', x, y)
moves an element in relation to an initial position of a draggable element. When I realised that, the solution was simple:
const droppableRect = $droppable[0].getBoundingClientRect();
const draggableRect = $draggable[0].getBoundingClientRect();
cy.wrap($draggable).trigger('mousedown');
cy.wrap($draggable).trigger(
'mousemove',
droppableRect.x - draggableRect.x,
droppableRect.y - draggableRect.y + 10,
{
force: true,
},
);
Thanks to that the drop effect started working as expected. One more thing to mention is that I need to { force: true }
the event in order to make it firing in the parent .dragster-block
element where all the event listeners where set up. Without it, if you have more complex structure, then the strcture presented in this article, the event won't get correctly propagated onto a target element.
Sample Cypress test for drag and drop with DragsterJS
It the code sample below you can see how some of my end-to-end tests for drag'n'drop features were written, as an example.
it('drags and drops an element from one region to another', () => {
const draggableSelector = '1.1';
cy.findByText(draggableSelector).then(($draggable) => {
cy.wrap($draggable).trigger('mousedown');
cy.wrap($draggable).trigger('mousemove', 300, 10, {
force: true,
});
cy.get('.dragster-drop-placeholder').should('exist');
cy.wrap($draggable).trigger('mouseup');
cy.findByText(draggableSelector)
.parent(DRAGGABLE_SELECTOR)
.next()
.should('contain.text', '2.1');
});
});
If you want to see more examples of how to test drag'n'drop you can check the DragsterJS library repository on Github.
The third AHA moment: use cy.intercept()
to block third-party requests
Adding third-party scripts to a web application or a website is a common practice. For instance, Google Analytics script is a third-party script which used to track different events and user behaviours of a web application or a website. On the image below you can see an example of such behaviour. Modal and backdrop aare covering an interface of an app.
Sometimes, such scripts behave unpredictably making your end-to-end tests flaky (these are tests that can pass or fail without doing any changes to a codebase across multiple retry attempts). It can be a script loading cookie consent popup or some web survey, that covers an user interface of a project you're developing. It can be frustrating.
I had several attempts to fix it. One of them was to use non-standard timeout values for the tests checking that third-party library, like here: cy.get('.cookie-popup', { timeout: 20000 })
. That piece of code was trying to find an element with .cookie-popup
CSS class attached within 20 seconds (the default timeout value is 5 seconds = 5000ms).
That approach solved the issue partially. The tests were failing more rarely, but still they kept on failing from time to time.
I did some further investigation and I've realized that, in order to make the third-party functionality working, the script has to be loaded into my web application from an endpoint known to me. If I know the endpoint and there's the cy.intercept()
to catch all the requests to a specific domain, then it became obvious that in order to make the tests stable I have to intercept such requests and mock the behaviour of an endpoint.
In my case, I've overwritten the cy.visit
method and there I'm intercepting the requests and I'm returning 503
network status code, which means: Service unavailable, if there's the skipThirdParty
option provided. Otherwise, do not block requests to the endpoint.
Cypress.Commands.overwrite('visit', (originalVisit, url, options) => {
let originalVisitOptions = options;
let shouldSkipOneTrust = false;
if (options && Object.prototype.hasOwnProperty.call(options, 'skipThirdParty')) {
const { skipThirdParty, ...rest } = options;
shouldSkipThirdParty = skipThirdParty;
originalVisitOptions = rest;
}
cy.intercept('GET', 'https://<third-party-url>/**', (req) => {
if (shouldSkipThirdParty) {
return req.reply({
statusCode: 503,
});
}
return req.reply();
});
originalVisit(url, originalVisitOptions);
if (!shouldSkipThirdParty) {
// test the third party lib behaviour in your app/website
}
});
At the end when, the intercepting part is handled properly the function decides whether to perform a test of a third-party lib functionality, or not.
Thanks to that approach:
- I was able to increase tests stability,
- I was able to increase tests execution performance,
- I was able to control the app flow in cases when I don't want a third-party lib's features to be tested.
Summary
Cypress is a great tool that helps automating E2E tests greatly, even though it's not supporting all the browsers on the market (I'm looking on you Safari!). Cypress runner supports (at the moment of writing the article):
- Mozilla Firefox,
- Google Chrome,
- Microsoft Edge,
- Electron environment.
Especially, testing drag'n'drop was tricky for me for a very long time. Fortunately, I've managed to find a proper way of testing the behaviour. It enabled me to refactor the codebase of DragsterJS library, when upgrading the code to use TypeScript.
I hope you enjoyed reading this article. If you have any questions or comments do not hesitate to post them in the comments to this article.