event-handlingcypresse2e-testing

Cypress: How to handle waiting for elements with click handlers


The code I’m testing has a lot of <a> tag elements that render with an href tag but shortly after page load are given some click event that does something different (such as opening a modal). The href is a fallback and the intended behavior is in the click event.

Cypress is often too fast for the page’s javascript and clicks the element before the event has been added to it. This causes the page to navigate to the default href, rather than triggering the behavior I want to test. Here’s an example, where I use a timeout to simulate the slow-loading JS:

<a id="reveal_cats" href="https://http.cat" >
  Show me cats!
</a>

<div id="cats_div" style="display: none;">
  Cats!
</div>

<script>
  // Using a timeout to simulate a slow-loading JS file
  // that adds a click handler
  setTimeout(
    () => {
      console.log("Handler is added")
      $("#reveal_cats").on("click", function(e) {
        e.preventDefault();
        $("#cats_div").show()
      })
    }, 3000
  )
</script>
it("fails because the click handler isn't loaded yet", () => {
  cy.contains("a", "Show me cats!").click()
  // This fails because the event handler isn't loaded yet
  // so instead we've navigated to http.cat
  cy.get("#cats_div").should("be.visible")
})
it("passes, but uses an undesirably long hardcoded wait", () => {
  cy.contains("a", "Show me cats!").click()
  cy.wait(5000)
  cy.get("#cats_div").should("be.visible")
})

How can I get Cypress to wait for the handler to be loaded?

My first guess was to use an intercept to wait for the JS file that adds the handler, but the filenames are randomly generated, so they’re not reliable.

My second guess was to try to assert for event handlers on the element, but I don’t see a standard assertion for that. I thought maybe I could look for listeners in a then() block using the jQuery utility, but I don’t believe that will retry properly if the event isn’t found on the first try.

The only solution I’ve found so far that works is hardcoded waits…


Solution

  • The easiest way to wait for the click handler is to use the cypress-cdp plugin.

    This is the test for your example page:

    import 'cypress-cdp'
    
    it('waiting for click event handler on link element', () => {
      cy.visit('cats.html')
    
      cy.get('#cats_div').should('not.be.visible')    // evaluates immediately
    
      cy.hasEventListeners('#reveal_cats', { type: 'click' })
      cy.get('#reveal_cats').click()
    
      cy.get('#cats_div', {timeout: 100})  // setting really short timeout for demo 
        .should('be.visible')
        .and('contain', 'Cats!')
    })
    

    enter image description here


    Composing a chained version

    cy.hasEventListeners() is a parent command, so it's not designed to use chaining like:

    cy.get().find().hasEventListeners()
    

    Internally it uses cy.get() which makes it a parent command (one that starts a chain).

    // plugin source
    Cypress.Commands.add('hasEventListeners', (selector, options = {}) => {
      ...
      cy.get(selector, { log: false })
    

    Using within()

    You could apply chaining using .within() and the internal call to cy.hasEventListeners() will be restricted to the previous chained subject.

    cy.get('x').find('y')   // "root" is now 'y'
      .within(() => {
        cy.hasEventListeners('#reveal_cats', { type: 'click' })
      })
    

    Using a wrapper command

    Or you could compose a new command that wraps the plugin command

    Cypress.Commands.add('hasEventListeners2', 
      {prevSubject: true}, 
      (subject, options) => {
        const selector = subject.selector
        cy.hasEventListeners(selector, options)
      }
    )
    
    cy.get('div').find('#reveal_cats').hasEventListeners2({ type: 'click' })