typescriptcypress

How to ask Cypress to run a check-wait loop until a DOM element has changed?


I recently asked this question about how to detect DOM changes that result from a page operation. I received an excellent answer based on the as() device to capture state.

Here is a working test based on that answer:

visitCountryPage();

// Captures the state of the trip ideas as a static var
getTripIdeas().as('initialValues', { type: 'static' });

// Opens the filter menu
cy
    .get('#sharedTrips .filtersContainer button')
    .first()
    .click();

// Now choose a filter option, so the ideas change
cy
    .get('#sharedTrips .filtersContainer label[for="experiencesrelax"]')
    .click()
    // @todo I wonder if we can do better than this wait?
    .wait(3000);

// Capture the ideas again
getTripIdeas().as('finalValues', { type: 'static' });

cy.get('@initialValues').then(initial => {
    cy.get('@finalValues')
        .invoke('toArray') // change JQuery<HTMLElement> to HTMLElement[]
        .should('not.deep.eq', initial.toArray())
});

A quick run-through of what is happening:

Readers may also wish to see how I retrieve trip ideas, which is what changes on the click of a specific menu option:

/**
 * Grabs a grid of items (e.g. a set of cards) based on a CSS expression
 */
export function getTripIdeas(): Cypress.Chainable<JQuery<HTMLElement>> {
    return getGridElements(
        '#sharedTrips section .container div:last-child .card-layout div.column'
    );
}

/**
 * General CSS chainable function
 */
function getGridElements(cssSelector: string): Cypress.Chainable<JQuery<HTMLElement>> {
    return cy
        .get('body')
        .find(cssSelector);
}

Now eagle-eyed readers will see that I have used a wait() device to let an AJAX operation do its work before the "after" capture is done. But of the little that I have learned about Cypress, it seems it is considered more idiomatic to let Cypress do wait-checking. In other words, we say to Cypress: "I am expecting this change to happen, please wait up to {long wait} for it to happen". This approach allows us to set a long timeout (e.g. 15 seconds) and succeed an assertion quickly (e.g. in three seconds).

Now if this was PHP/Selenium I'd use a wait device like so:

protected function waitUntil(callable $callback, string $reason, $timeout = 10): void
{
    $now = microtime(true);
    do {
        usleep(500 * 1000); // 0.5sec
        $withinTimeout = (microtime(true) - $now) < $timeout;
        $passedTest = $callback($this->getDriver());
    } while ($withinTimeout && !$passedTest);

    if (!$passedTest) {
        throw new RuntimeException(
            sprintf('Failed to wait for %s', $reason)
        );
    }
}

And then I call it with something like this:

// Wait for the suggestions to populate
$this->waitUntil(
    function(RemoteWebDriver $driver) {
        try {
            // Ideally we'd make these CSS expressions a bit more specific
            $ideas = $driver->findElements(WebDriverBy::cssSelector(
                '#sharedTrips section .container div:last-child .card-layout div.column'
            ));
            // On first load there is currently 8
            return count($ideas) > 4;
        } catch (NoSuchElementException $e) {
            return false;
        }
    },
    'trip ideas to populate'
);

(Note that here I am merely illustrating how I might do a repeated DOM check in a synchronous language. For clarity, this is not an example of checking that the DOM element contents have changed).

This makes a lot more sense to my non-Promise-aligned brain: keep testing something in the DOM every 0.5 sec until it succeeds or times-out. Can something similar be done in Cypress so that tests are made robust without excessive fixed sleeps?


Solution

  • The WebDriver code is performing a different test to the Cypress code. If you just want to count elements before and after the filter action, it's a whole lot easier.

    This is my filter simulation. I'm removing one of the <li> elements 5 seconds after clicking the button.

    <body>
      <ul>
        <li id="1">one</li>
        <li id="2">two</li>
        <li id="3">three</li>
      </ul>
      <button onclick="filter()">filter</button>
      <script>
        function filter() {
          setTimeout(() => {
            // simulate a filter
            const li = document.getElementById('2')
            li.remove()  
          }, 5000)
        }
      </script>
    </body>
    

    The test is just:

    it('waits for the element count to change', () => {
      cy.get('li').should('have.length', 3)
      cy.contains('button', 'filter').click()
      cy.get('li', {timeout:6000}).should('have.length', 2)
    })
    

    enter image description here

    This is really nice and clean when compared to the Selenium code you posted.

    All you need to do is specify the state of the page before and after the action, there's no need for the waitUntil loop since that is built into the Cypress runner.

    To illustrate, change the the final assertion to a callback and log the element length to dev console.

    cy.get('li', {timeout:6000})
      .should($els => {
        console.log('count: ', $els.length)
        expect($els.length).to.eq(2)
      })
    

    The dev console shows 188 calls to the callback, passing on the last one

    enter image description here


    Using a production DB copy as the test fixture

    When your test system is a copy of the production DB, you can't guarantee the contents of specific elements.

    In the example above the element count can be anything, so you want to compare initial count and final count.

    cy.get('li')
      .then($initial => {
        const initialCount = $initial.length
    
        cy.contains('button', 'filter').click()
    
        cy.get('li')
          .its('length', {timeout:6000})
          .should('be.lessThan', initialCount)
      })
    

    enter image description here


    Checking element references

    Since there was some confusion about what was getting tested in my previous answer, here is a better example.

    cy.get('li').eq(1)               // 2nd item
      .then($initial => {
    
        cy.contains('button', 'filter').click()
    
        cy.get('li')
         .eq(1, {timeout:6000})
         .should($final => {
            const initialElement = $initial.get(0)
            const finalElement = $final.get(0)
            const elementHasChanged = initialElement !== finalElement
            assert(elementHasChanged, 'element has changed')
         })
      })
    

    enter image description here

    If I comment out the click() the test fails (i.e ensuring this doesn't give a false positive).

    enter image description here

    Testing the complete element list:

    cy.get('li')
      .then($initial => {
    
        cy.contains('button', 'filter').click()
    
        cy.get('li', {timeout:6000})
          .should($final => {
            const initialElements = $initial.toArray()
            const finalElements = $final.toArray()
            expect(initialElements).to.not.deep.eq(finalElements)
         })
      })