10

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.

Blondie
  • 160
  • 9

3 Answers3

5

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)
TesterDick
  • 3,830
  • 5
  • 18
4

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
  }) 
Fody
  • 23,754
  • 3
  • 20
  • 37
2

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')
jjhelguero
  • 2,281
  • 5
  • 13