Listen To The Application Events From Cypress Tests

How the Cypress tests can receive the DOM and jQuery events sent by the application.

Often the web application uses events that flow from one part of the application to another. You might need to observe these events from the end-to-end tests to confirm the application sends them. This blog post shows how to receive the custom DOM events and jQuery events. In every case, it is important to set up the listener before the application sends the event.

Observe the DOM event sent to the document

If you prefer watching the explanation, check out the video Listen To The Application Dispatching Events To The Document. You can find the source code in the repo bahmutov/listen-to-custom-event.

Imagine the application is sending a custom DOM event to the document object

1
2
3
4
5
6
7
8
console.log('sending DOM event loading to the document')

const loadingEvent = new CustomEvent('loading', {
detail: {
message: 'Loading...',
},
})
document.dispatchEvent(loadingEvent)

We can receive the same event from the Cypress test by subscribing

cypress/integration/spec.js
1
2
3
4
5
6
7
8
it('sends an event to the document', () => {
cy.visit('/')
cy.document().then((doc) => {
doc.addEventListener('loading', cy.stub().as('loading'))
})
// on load the app should have sent an event
cy.get('@loading').should('have.been.calledOnce')
})

Loading event confirmed

We are getting the document object using cy.document just to call its method addEventListener. We can invoke the method right away using the .invoke command. We can also confirm the details in the event object.

1
2
3
4
5
6
7
8
9
10
11
it('sends an event to the document', () => {
cy.visit('/')
cy.document().invoke('addEventListener', 'loading', cy.stub().as('loading'))
// on load the app should have sent an event
cy.get('@loading')
.should('have.been.calledOnce')
.its('firstCall.args.0.detail')
.should('deep.equal', {
message: 'Loading...',
})
})

Refactored test using cy.invoke

In the tests above we still might have a race condition; we call the cy.document()... addEventListener after the cy.visit command. By that time, the application might have fired the event already. The safest way to listen to the event sent at the application's startup is to register them before the application loads. We cannot simply move addEventListener before cy.visit

1
2
3
// ⛔️ INCORRECT, WILL NOT WORK
cy.document().invoke('addEventListener', 'loading', cy.stub().as('loading'))
cy.visit('/')

Cannot use cy.document before cy.visit

Every time cy.visit runs, it creates a new document, while our stub was attached to the previous document instance. We really need to listen to the document object created by the cy.visit command. Luckily, there is onBeforeLoad or 'window:before:load' callbacks - they run between creating a new window and document objects, and the application code.

1
2
3
4
5
6
// ✅ THE RIGHT WAY TO PREPARE FOR THE EVENT ON LOAD
cy.visit('/', {
onBeforeLoad(win) {
win.document.addEventListener('loading', cy.stub().as('loading'))
},
})

Observe the DOM event sent to an element

The application might send event to a specific element.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ref = React.createRef()

const onButtonClick = () => {
console.log('sending DOM event loading to the ref element')

const myEvent = new CustomEvent('clicked', {
detail: {
message: 'Button clicked',
},
})
ref.current.dispatchEvent(myEvent)
}

<input data-cy="ref" type="text" ref={ref} />
<button onClick={() => onButtonClick()}>Send event to the input</button>

We can listen to the events sent to the input element by using jQuery on method - because we get the jQuery object from the cy.get and the cy.contains commands.

1
2
3
4
5
6
it('sends an event to the ref component', () => {
cy.visit('/')
cy.get('[data-cy=ref]').invoke('on', 'clicked', cy.stub().as('clicked'))
cy.get('button').click().click()
cy.get('@clicked').should('have.been.calledTwice')
})

Confirm the events send to the element

You can watch the explanation in the video Testing DOM Events Sent to ref.current Element By React App.

Observe the jQuery events

What if the application is sending custom jQuery events? You can receive these events but you have to be careful: you must use the same jQuery instance that sends them. From the test, you must get the reference to the jQuery instance running inside the application, not the jQuery instance bundled with Cypress under Cypress.$ property.

If you prefer watching the explanation, check out the video Test The Custom jQuery Events Using Cypress. You can find the source code in the repo bahmutov/jquery-custom-events-example.

Imagine our application is including jQuery on the page

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script
src="https://code.jquery.com/jquery-3.6.0.slim.min.js"
integrity="sha256-u7e5khyithlIdTpu22PHhENmPcRdFiHRjhAuHcs05RI="
crossorigin="anonymous"
></script>
<!-- example https://learn.jquery.com/events/introduction-to-custom-events/ -->
<div class="room" id="kitchen">
<h2>Kitchen</h2>
<div class="lightbulb off">💡</div>
</div>
<div class="room" id="bedroom">
<h2>Bedroom</h2>
<div class="lightbulb off">💡</div>
</div>
<button id="master_switch">Master switch</button>
<script src="app.js"></script>

The application sends custom jQuery events in the app.js in response to the user clicks.

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$('.lightbulb')
.on('light:on', function (event) {
$(this).removeClass('off').addClass('on')
})
.on('light:off', function (event) {
$(this).removeClass('on').addClass('off')
})

$('#master_switch').on('click', function () {
const lightbulbs = $('.lightbulb')

// trigger custom global event
$('body').trigger('lights:toggle')

// Check if any lightbulbs are on
if (lightbulbs.is('.on')) {
lightbulbs.trigger('light:off')
} else {
lightbulbs.trigger('light:on')
}
})

Let's confirm the application triggers events like lights:toggle. We will get the jQuery from the application's window object, then we can get the document, wrap it in the jQuery object, and register a stub.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
it('triggers custom event', () => {
cy.visit('index.html')
cy.window().then((win) => {
cy.document().then((doc) => {
win.$(doc).on('lights:toggle', cy.stub().as('toggle'))
})
})

cy.get('#master_switch').click().click().click()
cy.get('@toggle').should('have.been.calledThrice')
})

Receiving custom jQuery events

Great, it works. We can simplify the test. The command cy.visit yields the window object, thus we do not need to call cy.window.

1
2
3
4
5
cy.visit('index.html').then((win) => {
cy.document().then((doc) => {
win.$(doc).on('lights:toggle', cy.stub().as('toggle'))
})
})

We are only interested in the win.$ property, thus we can use .its command to get the window property.

1
2
3
4
5
6
7
cy.visit('index.html')
.its('$')
.then(($) => {
cy.document().then((doc) => {
$(doc).on('lights:toggle', cy.stub().as('toggle'))
})
})

We can also shorten getting the document object just to wrap it in the jQuery function.

1
2
3
4
5
6
7
8
9
cy.visit('index.html')
.its('$')
.then(($) => {
cy.document()
.then($)
.then(($doc) => {
$doc.on('lights:toggle', cy.stub().as('toggle'))
})
})

Finally, we get the $doc object just to invoke a method on. We can use the .invoke command to shorten it.

1
2
3
4
5
6
7
cy.visit('index.html')
.its('$')
.then(($) => {
cy.document()
.then($)
.invoke('on', 'lights:toggle', cy.stub().as('toggle'))
})

Short and sweet.

See also