Close A Random Popup Using MutationObserver

Hide a randomly appearing modal element during Cypress test.

Imagine you are testing your site, but there is an annoying popup element that can randomly appear and break the Cypress test.

cypress/e2e/misc/random-popup.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { LoginPage } from '@support/pages/login.page'

it('fills the login form', () => {
cy.visit('/')
// slowly type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')
})

The popup blocks the input fields

What can we do to prevent this?

🎓 This blog post summarizes a lesson from my paid course Testing The Swag Store.

Deterministic modal

If the popup always appears, and we simply do not know the precise timing, we could always wait for it and close using the normal Cypress commands.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('waits for the modal to click close', () => {
cy.visit('/')
// if the modal always appears, let's just wait for it
// and click the "close modal" element
cy.get('#close-modal').click()
// then type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')
})

Did you see that tiny flash? That was the modal element appearing and then disappearing. We can see it better by hovering or clicking on the "CLICK" command in the Command Log column

The popup close button was clicked by Cypress

Non-deterministic modal

What if the modal appears only sometimes? We cannot use cy.get('#close-modal').click() because it will fail if the modal never exists. We could wait for N seconds and then check.

1
2
3
4
5
6
7
8
9
10
11
cy.wait(5000)
cy.get('#close-modal')
// disable the built-in existence assertion
.should(Cypress._.noop)
.then($el => {
// only click on the close button
// if the popup element exists
if ($el.length) {
cy.wrap($el).click()
}
})

Tip: I have disabled the built-in element existence assertion using .should(Cypress._.noop) assertion. For more, watch the video Built-in Existence Assertion.

Unfortunately the above approach slows down your tests.

Use MutationObserver

Let's use a solution that does not rely on Cypress commands. Instead, let's insert a MutationObserver and use "regular" JavaScript to hide the popup element as soon as it appears. The popup is attached right to the <body> element of the page.

Inspecting the popup HTML

As soon as the element with id=modal is added to the body we can set its display state to hide it.

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
import { LoginPage } from '@support/pages/login.page'

it('closes a random popup', () => {
// visit the login page "/"
// https://on.cypress.io/visit
// which yields the window object
// create a new MutationObserver object to observe
// child list changes on the "window.document.body" node
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
cy.visit('/').then((window) => {
const body = window.document.body
const callback = (mutations: MutationRecord[]) => {
// if you see a new Node with id "modal" has been added
// set its style to "display: none" to hide it
if (mutations.length && mutations[0].addedNodes.length) {
// @ts-ignore TS2339
if (mutations[0].addedNodes[0].id === 'modal') {
console.log('added modal')
// @ts-ignore TS2339
mutations[0].addedNodes[0].style.display = 'none'
}
}
}
const observer = new MutationObserver(callback)
observer.observe(body, { childList: true })
})
// slowly type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')
})

Here is the test in action

The element is hidden even before it appears on the screen. Nice! If the random is never created by the application, no worries, the test just continues.

Every window

If the test goes from page to page, a new window object is created each time. You can inject your code into every window by subscribing to the window:load event:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cy.on('window:load', (window) => {
const body = window.document.body
const callback = (mutations: MutationRecord[]) => {
// if you see a new Node with id "modal" has been added
// set its style to "display: none" to hide it
if (mutations.length && mutations[0].addedNodes.length) {
// @ts-ignore TS2339
if (mutations[0].addedNodes[0].id === 'modal') {
console.log('added modal')
// @ts-ignore TS2339
mutations[0].addedNodes[0].style.display = 'none'
}
}
}
const observer = new MutationObserver(callback)
observer.observe(body, { childList: true })
})
cy.visit('/')

Confirm the popup was handled

How do we know if the popup was hidden? Let's say the popup always appears. We can set a property on the window object to use the built-in retry-ability and check for it.

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
// enable only if the modal always appears during testing
it('closes a random popup with window property confirmation', () => {
// subscribe to the "window:load" event
// create a new MutationObserver object to observe
// child list changes on the "window.document.body" node
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
cy.on('window:load', (window) => {
const body = window.document.body
const callback = (mutations: MutationRecord[]) => {
// if you see a new Node with id "modal" has been added
// set its style to "display: none" to hide it
if (mutations.length && mutations[0].addedNodes.length) {
// @ts-ignore TS2339
if (mutations[0].addedNodes[0].id === 'modal') {
// @ts-ignore TS2339
window.popupHandled = true
// @ts-ignore TS2339
mutations[0].addedNodes[0].style.display = 'none'
}
}
}
const observer = new MutationObserver(callback)
observer.observe(body, { childList: true })
})

// visit the login page "/"
// https://on.cypress.io/visit
cy.visit('/')
// slowly type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')
// confirm the popup was added and hidden
cy.window().should('have.property', 'popupHandled', true)
})

The test checks the window.popupHandled property set by the mutation callback

Instead of adding a property to the window object, we can use a local object and add a property there:

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
// enable only if the modal always appears during testing
it('closes a random popup with window property confirmation', () => {
// subscribe to the "window:load" event
// create a new MutationObserver object to observe
// child list changes on the "window.document.body" node
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
const o = {
popupHandled: false,
}

cy.on('window:load', (window) => {
const body = window.document.body
const callback = (mutations: MutationRecord[]) => {
// if you see a new Node with id "modal" has been added
// set its style to "display: none" to hide it
if (mutations.length && mutations[0].addedNodes.length) {
// @ts-ignore TS2339
if (mutations[0].addedNodes[0].id === 'modal') {
o.popupHandled = true
// @ts-ignore TS2339
mutations[0].addedNodes[0].style.display = 'none'
}
}
}
const observer = new MutationObserver(callback)
observer.observe(body, { childList: true })
})

// visit the login page "/"
// https://on.cypress.io/visit
cy.visit('/')
// slowly type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')
// confirm the popup was added and hidden (retries)
cy.wrap(o).should('have.property', 'popupHandled', true)
})

The test checks the o.popupHandled property set by the mutation callback

Again, we use the retry-ability in the command and assertion:

1
2
3
4
const o = { ... }
// code sets a property inside object "o" at some point
// confirm the popup was added and hidden (retries)
cy.wrap(o).should('have.property', 'popupHandled', true)

Use a spy confirmation

Instead of the window or a local object, we can use a spy function to keep track of events and then confirm they have happened.

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
// enable only if the modal always appears during testing
it('closes a random popup with spy confirmation', () => {
// if we know the modal always appears, we can
// create a spy and give it an alias "modalClosed"
// https://on.cypress.io/spy
// https://on.cypress.io/as
const modalClosed = cy.spy().as('modalClosed')

// repeat the same setup as in the previous test
cy.visit('/').then((window) => {
const body = window.document.body
const callback = (mutations: MutationRecord[]) => {
if (mutations.length && mutations[0].addedNodes.length) {
// @ts-ignore TS2339
if (mutations[0].addedNodes[0].id === 'modal') {
console.log('added modal')
// @ts-ignore TS2339
mutations[0].addedNodes[0].style.display = 'none'
// after hiding the modal element using "display: none"
// call the spy function we created above
modalClosed()
}
}
}
const observer = new MutationObserver(callback)
observer.observe(body, { childList: true })
})

// slowly type the username and the password values
// "username" and "password"
LoginPage.getUsername().type('username', { delay: 200 })
LoginPage.getPassword().type('password', { delay: 100 })
// confirm the username and the password input elements
// have the expected values we typed
LoginPage.getUsername().should('have.value', 'username')
LoginPage.getPassword().should('have.value', 'password')

cy.log('**confirm the modal was hidden**')
// confirm the spy "modalClosed" was called once (retries)
cy.get('@modalClosed').should('have.been.calledOnce')
})

The const modalClosed = cy.spy().as('modalClosed') creates a spy modalClosed. It is just a function - but it keeps a record of every call made, thus we can use it later to confirm it was really called (and even check its arguments): cy.get('@modalClosed').should('have.been.calledOnce').

Here is the spy code in action:

I really like this approach since I can see the made calls in the Command Log.

Using cy.spy to check what the test has done

For more about cy.spy command see my Spies, Stubs & Clocks examples.

Happy testing 🥂