Many many moons ago I wrote a blog post When Can The Test Click?. It was part of a series of blog posts that explain why an end-to-end test might be flaky. The main reason in my opinion that causes a test to sometimes not work, is that the test runner does not know when the application is ready to receive test commands, like click. There might be many reasons why that is the case, but one of the hardest to reliably solve is knowing when the application has finished its initial load. Is the application ready to receive the user click? Or does it need extra 100ms to finish loading and starting its JavaScript code? I have discussed a possible solution in the blog post When Has The App Loaded. In this blog post, I will show another solution that goes directly to the heart of the problem of "missing" clicks - checking if the application has attached its event listeners to the button before clicking on it.
The problem
Let's take an application that responds to a button click event.
1 | <body> |
1 | const btn = document.querySelector('#one') |
The above application registers the event handler synchronously. When the cy.visit
command finishes, the application is ready to go.
1 | it('clicks on the button', () => { |
The test reliably passes.
But what if the application delays its load? What if it attaches the "click" event listener by one second? What if the application is slowly loading chunks of its code? While the user sees the page elements, they are not ready to process any events, and the test fails.
1 | const btn = document.querySelector('#one') |
The same test now fails - because the "click" even gets lost. The test runner clicks before the application starts listening.
The diagnosis
A good way to determine if the test acts before the application is ready to respond is to add cy.wait commands. You can wait for some external observable event, or simply wait N milliseconds (I know, I know, but this is temporary).
1 | it('clicks on the button', () => { |
The test now reliably passes
How can we avoid the hard-coded wait, and instead click after the application is ready to receive the "click" event?
Event listeners
If you open the DevTools and look at the button DOM element, the tab "Event Listeners" shows the currently attached ... event listeners.
This "click" event listener is attached to the element by the application code after it is done initializing.
cypress-cdp
Hmm, if only our test could wait for this event listener to exist before clicking... Well, let's check! If the DevTools panel can query an element to show its event listeners, we can do the same thing from Cypress using Chrome Debugger Protocol. After all, Cypress has this connection already and even exposes it to the test code, as I show in my blog post Cypress vs Other Test Runners. In general, this looks like
1 | Cypress.automation('remote:debugger:protocol', { |
Because Cypress.automation
is a very low-level primitive, it eagerly returns a Promise, which your code needs to cy.wrap(Promise)
to wait to resolve. But since I am a nice person, I wrote a little NPM plugin that exposes the Chrome Debugger Protocol via a Cypress custom command cy.CDP
. You can find the source code in the repo bahmutov/cypress-cdp.
1 | $ npm i -D cypress-cdp |
In your support or spec file, import the cypress-cdp
and you will get the cy.CDP
command. Let's use it to wait for the event listeners to be attached. First, we need to find the internal browser object ID for the button we want to click. We can ask the Runtime
object to evaluate
the expression in the application's iframe:
1 | import 'cypress-cdp' |
The command cy.CDP
has DOM snapshots and checks the attached assertions, so it is meant to be retry-able. You can see the button's internal object ID printed in the Command Log.
Now that we have the ID, let's query the event listeners - and because the cy.CDP
command retries the assertions that follow it, we can check the returned event listeners array. If there are no event listeners (we could also check their types), then we can throw an error to re-query the element.
1 | it('clicks on the button when there is an event handler', () => { |
Does it work? Let's see:
Beautiful!
Tip: you can find the full list of Chrome Debugger Protocol commands here.
Update 1: example application
Just as I wrote this blog post, I hit a real-world use case for waiting for the "click" event listener to be attached, read my blog post Solve The First Click.