Test that an API call does NOT happen in Cypress

Viewed 110

I've implemented API data caching in my app so that if data is already present it is not re-fetched.

I can intercept the initial fetch

cy.intercept('**/api/things').as('api');
cy.visit('/things')                      
cy.wait('@api')                         // passes

To test the cache is working I'd like to explicitly test the opposite.

How can I modify the cy.wait() behavior similar to the way .should('not.exist') modifies cy.get() to allow the negative logic to pass?

// data is cached from first route, how do I assert no call occurs?
cy.visit('/things2')                      
cy.wait('@api')                    
  .should('not.have.been.called')   // fails with "no calls were made"

Minimal reproducible example

<body>
  <script>
    setTimeout(() => 
      fetch('https://jsonplaceholder.typicode.com/todos/1')
    }, 300)
  </script>
</body>

Since we test a negative, it's useful to first make the test fail. Serve the above HTML and use it to confirm the test fails, then remove the fetch() and the test should pass.

3 Answers

The add-on package cypress-if can change default command behavior.

cy.get(selector)
  .if('exist').log('exists')
  .else().log('does.not.exist')

Assume your API calls are made within 1 second of the action that would trigger them - the cy.visit().

cy.visit('/things2')
cy.wait('@alias', {timeout:1100})
  .if(result => {
    expect(result.name).to.eq('CypressError')    // confirm error was thrown
  })  

You will need to overwrite the cy.wait() command to check for chained .if() command.

Cypress.Commands.overwrite('wait', (waitFn, subject, selector, options) => {

  // Standard behavior for numeric waits
  if (typeof selector === 'number') {
    return waitFn(subject, selector, options)
  }

  // Modified alias wait with following if()
  if (cy.state('current').attributes.next?.attributes.name === 'if') {
    return waitFn(subject, selector, options).then((pass) => pass, (fail) => fail)
  }

  // Standard alias wait
  return waitFn(subject, selector, options)
})

As yet only cy.get() and cy.contains() are overwritten by default.


Custom Command for same logic

If the if() syntax doesn't feel right, the same logic can be used in a custom command

Cypress.Commands.add('maybeWaitAlias', (selector, options) => {
  const waitFn = Cypress.Commands._commands.wait.fn

  // waitFn returns a Promise
  // which Cypress resolves to the `pass` or `fail` values
  // depending on which callback is invoked

  return waitFn(cy.currentSubject(), selector, options)
    .then((pass) => pass, (fail) => fail)

  // by returning the `pass` or `fail` value
  // we are stopping the "normal" test failure mechanism
  // and allowing downstream commands to deal with the outcome
})

cy.visit('/things2')
cy.maybeWaitAlias('@alias', {timeout:1000})
  .should(result => {
    expect(result.name).to.eq('CypressError')    // confirm error was thrown
  }) 

I also tried cy.spy() but with a hard cy.wait() to avoid any latency in the app after the route change occurs.

const spy = cy.spy()
cy.intercept('**/api/things', spy)

cy.visit('/things2')
cy.wait(2000)
  .then(() => expect(spy).not.to.have.been.called)

Running in a burn test of 100 iterations, this seems to be ok, but there is still a chance of flaky test with this approach, IMO.

A better way would be to poll the spy recursively:

const spy = cy.spy()
cy.intercept('**/api/things', spy)

cy.visit('/things2')

const waitForSpy = (spy, options, start = Date.now()) => {
  const {timeout, interval = 30} = options;

  if (spy.callCount > 0) {
    return cy.wrap(spy.lastCall)    
  }

  if ((Date.now() - start) > timeout) {
    return cy.wrap(null)
  }

  return cy.wait(interval, {log:false})
    .then(() => waitForSpy(spy, {timeout, interval}, start))
}

waitForSpy(spy, {timeout:2000})
  .should('eq', null)

A neat little trick I learned from Gleb's Network course.

You will want use cy.spy() with your intercept and use cy.get() on the alias to be able to check no calls were made.

// initial fetch
cy.intercept('**/api/things').as('api');
cy.visit('/things')                      
cy.wait('@api')

cy.intercept('METHOD', '**/api/things', cy.spy().as('apiNotCalled'))
// trigger the fetch again but will not send since data is cached
cy.get('@apiNotCalled').should('not.been.called')
Related