Spy On A Complex Method Call

Checking if the window data layer method call was called with an expected argument.

Here is a question that came from (deprecated) Cypress Gitter chat channel. The user provided a repo with a reproducible test that tried to assert that window.dataLayer.push method was called with an object with the field event: lead. This is just one particular call among hundreds of calls the application is making to track its various events.

We are trying to confirm this particular call amongst many others

The user is struggling to write the correct test and asked me to help.

The original test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
describe('testin datalayer', () => {
before(() => {
cy.intercept('POST', '**mule/customer/clicktocall').as('clicktocall')
cy.visit('https://flsit.vtr.lla.digital/')
cy.wait(5000)
})
it('wait & assert', () => {
cy.window().then((win) => {
cy.get('lla-floating-button').click()
cy.get('lla-click-to-call-form input').eq(0).type('111111111')
cy.get('lla-click-to-call-form input').eq(1).type('9872819281')

cy.get('lla-button.llad-contact-info-form__button button').click()
cy.wait('@clicktocall')

cy.wait(10000)
// find the index of the argument that corresponds to this event
// cy.wrap('@open').should((res) => {
// const index = res.args.findIndex((i) => i[0].event == 'lead');
// cy.log(index);
// });
const data = cy.spy(win.dataLayer, 'push').as('dataL')
cy.log(data.args)
cy.get('@dataL').then((res) => {
cy.log(res.args)
})
})
})
})

A few observations about this test right away:

  • the base URL is hardcoded in the test, which is an anti-pattern. I prefer setting the base URL in the cypress.json file, watch the video How to correctly use the baseUrl to visit a site in Cypress
  • there are several hard-coded cy.wait(ms) commands, probably to let the page load the tracking library and create the window.dataLayer object. We can specifically wait for the window.dataLayer to exist, avoiding the hard-coded waits

Updated test

Let's start by moving the base URL to the cypress.json file and removing the cy.wait(ms) commands. Instead we will wait for the window object to have a property dataLayer with the method push. See the inline code comments for the full explanation and links to more documentation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('testing datalayer', () => {
before(() => {
cy.intercept('POST', '**mule/customer/clicktocall').as('clicktocall')
// cy.visit command yields the window object
// and cy.its command retries until the "dataLayer" property is found
// then we check if there is a method "push" on that object
// https://on.cypress.io/visit
// https://on.cypress.io/its
// https://glebbahmutov.com/cypress-examples/commands/assertions.html
cy.visit('/').its('dataLayer').should('respondTo', 'push')
})

it('wait & assert', () => {
cy.get('lla-floating-button').click()
cy.get('lla-click-to-call-form input').eq(0).type('111111111')
cy.get('lla-click-to-call-form input').eq(1).type('9872819281')

cy.get('lla-button.llad-contact-info-form__button button').click()
cy.wait('@clicktocall')
})
})

The updated test waits only until the window.dataLayer property is found

Spying

Ok, what about our spy? Sure, let's add a spy on the method push of the dataLayer object. The most interesting thing here is how to check if the spy function was called by the application. We have complex calls, and are only interested in the call where the first argument is an object with property event: "lead". Luckily, Chai-Sinon assertions included with Cypress provide a way to use a custom matcher to check the calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
describe('testing datalayer', () => {
before(() => {
cy.intercept('POST', '**mule/customer/clicktocall').as('clicktocall')
// cy.visit command yields the window object
// and cy.its command retries until the "dataLayer" property is found
// then we check if there is a method "push" on that object
// https://on.cypress.io/visit
// https://on.cypress.io/its
// https://glebbahmutov.com/cypress-examples/commands/assertions.html
cy.visit('/')
.its('dataLayer')
.should('respondTo', 'push')
.then((dataLayer) => {
cy.spy(dataLayer, 'push').as('dataL')
})
})

it('wait & assert', () => {
cy.get('lla-floating-button').click()
cy.get('lla-click-to-call-form input').eq(0).type('111111111')
cy.get('lla-click-to-call-form input').eq(1).type('9872819281')

cy.get('lla-button.llad-contact-info-form__button button').click()
cy.wait('@clicktocall')
// confirm there was a call that satisfies the custom predicate function
const isLead = (d) => d.event === 'lead'
cy.get('@dataL').should(
'have.been.calledWith',
Cypress.sinon.match(isLead, 'lead event'),
)
})
})

For more Chai-Sinon assertion examples, see my cy.stub, cy.spy, and cy.clock examples. For now, the test works, even if it is pretty noisy - there are lots of dataLayer.push calls!

The application does call dataLayer.push with the lead event

Cut the noise

We only want to be informed about dataLayer.push(lead) events, not every call. Unfortunately, the built-in Sinon mechanism to create a targeted spy cy.spy(dataLayer, 'push').withArgs({ event: 'lead' }).as('lead') expects the exact argument match, not part of the object. We could use the same match via predicate when creating a spy:

1
2
3
cy.spy(dataLayer, 'push')
.withArgs(Cypress.sinon.match(isLead))
.as('lead')

It does simplify the assertion

1
cy.get('@lead').should('have.been.calledOnce')

Unfortunately, this still records other calls to dataLayer.push method ☚ī¸

The Cypress Command Log still shows all spy calls

We really need to avoid using cy.spy for calls we are not interested in. We can do this by constructing a "plain" Sinon.js stub function avoiding the cy.stub or cy.spy command to avoid logging every call. Here is the entire test, including printing the event data to the Command Log.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
describe('testing datalayer', () => {
const isLead = (d) => d.event === 'lead'

before(() => {
cy.intercept('POST', '**mule/customer/clicktocall').as('clicktocall')
// cy.visit command yields the window object
// and cy.its command retries until the "dataLayer" property is found
// then we check if there is a method "push" on that object
// https://on.cypress.io/visit
// https://on.cypress.io/its
// https://glebbahmutov.com/cypress-examples/commands/assertions.html
cy.visit('/')
.its('dataLayer')
.should('respondTo', 'push')
.then((dataLayer) => {
// we need to call the real method on the dataLayer object
const realPush = dataLayer.push.bind(dataLayer)
// and our stub function to be able to check it later
const leadStub = cy.stub().as('lead')
// use "plain" Sinon stub to replace dataLayer.push method
Cypress.sinon.stub(dataLayer, 'push').callsFake((...args) => {
// if this is a lead event, call the Cypress stub
if (isLead(args[0])) {
leadStub(...args)
}
// and always call the real dataLayer.push
return realPush(...args)
})
})
})

it('wait & assert', () => {
cy.get('lla-floating-button').click()
cy.get('lla-click-to-call-form input').eq(0).type('111111111')
cy.get('lla-click-to-call-form input').eq(1).type('9872819281')

cy.get('lla-button.llad-contact-info-form__button button').click()
cy.wait('@clicktocall')
// confirm the cy.stub was called
cy.get('@lead')
.should('have.been.calledOnce')
// and grab its first call's arguments
.its('firstCall.args')
// and log them to Cypress Command Log
.then(JSON.stringify)
.then(cy.log)
})
})

The test is beautiful đŸĨ° and takes only four seconds 🏎 instead of 20+ seconds of the original test with hard-coded waits.

Logging only the method call we are interested in

See also