Cypress cy.intercept Problems

A few common cy.intercept gotchas and how to avoid them

Note: I am using code from testing-workshop-cypress to demonstrate these cy.intercept gotchas.

📚 Since this post has been published in 2020, Cypress team has resolved many of these issues. To learn how to effectively spy and stub network requests in your Cypress tests, enroll in my online course Cypress Network Testing Exercises. The course covers a lot of topis in its main 30 lessons plus multiple bonus lessons.

The intercept was registered too late

The problem

Let's say the application is making GET /todos call to load its data. We might write a test like this to spy on this call:

1
2
3
4
5
6
7
describe('intercept', () => {
it('is registered too late', () => {
cy.visit('/')
cy.intercept('/todos').as('todos')
cy.wait('@todos')
})
})

It looks reasonable, it even shows the call in the Command Log - but does NOT pass.

The intercept times out waiting

You can see the XHR call in the command log - and it looks like it should work, right? But if you open the "Routes" section, notice that our intercept was never counted.

The intercept was never counted

The root cause

The application is making the call to load the data right on startup:

1
2
3
created() {
this.$store.dispatch('loadTodos')
}

Thus, it makes the call right at the end of cy.visit command. By the time cy.intercept runs, the call is already in progress, and thus not intercepted. Cypress shows XHR calls by default in its Command Log, thus it has nothing to do with our intercept. I always thought NOT showing when cy.intercept happens in the Command Log was a user experience failure.

The solution

Make sure the network intercept is registered before the application makes the call.

1
2
3
4
5
it('is registered too late', () => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos')
})

In our case, we need to register the intercept before visiting the page. Once the page is loaded, the application fetches the todo items, and everything is working as expected.

The intercept works correctly

The bonus solution

You can overwrite Cypress commands and log a message to the Command Log. Unfortunately overwriting cy.intercept has a bug with aliases #9580, and thus we cannot always show when the intercept is registered. We can still do it case by case.

1
2
3
4
5
6
it('is registered too late', () => {
cy.visit('/')
cy.log('adding /todos intercept')
cy.intercept('/todos').as('todos')
cy.wait('@todos')
})

The above test clearly shows that the intercept is registered too late.

The log message shows the intercept is registered AFTER the application makes the call

Update: the alias bug #9580 has been fixed and released in Cypress v6.2.0

cy.wait uses the intercept

The problem

If you first wait on the intercept and then separately try to cy.get it to validate - well, the cy.get always resolves with null.

1
2
3
4
5
6
it('is taken by the wait', () => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos')
cy.get('@todos').should('not.be.null')
})

cy.get always yields null after cy.wait

This is different behavior from cy.route where we could wait on the interception, and the get it separately.

1
2
3
4
5
6
7
it('is taken by the wait (unlike cy.route)', () => {
cy.server()
cy.route('/todos').as('todos')
cy.visit('/')
cy.wait('@todos')
cy.get('@todos').should('not.be.null')
})

cy.wait plus cy.get works for cy.route

The workaround

Honestly, I feel this is a bug #9306 that we should fix shortly. But for now you can validate the interception immediately using the value yielded by cy.wait

1
2
3
4
5
it('should use wait value', () => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos').should('include.all.keys', ['request', 'response'])
})

You can validate the interception yielded by cy.wait

If you want to use multiple assertions over the interception, use the .should(cb) or .then(cb) assertions. The .then(cb) makes more sense here, since the interception value would never change after completing.

1
2
3
4
5
6
7
8
it('verifies the waited interception via .then(cb)', () => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos').then(interception => {
expect(interception).to.be.an('object')
expect(interception.request.url).to.match(/\/todos$/)
})
})

Update: the bug #9306 has been fixed and released in Cypress v6.2.0

Cached response

The problem

Let's inspect the interception object yielded by the cy.wait command.

The interception does not the response because the server replied that the data was not modified

We cannot validate the data sent by the server, because there is no data in the response. Instead the server tells the browser that the data loaded previously is still valid and has not been modified. Hmm, but we are not the browser - the cy.intercept runs in the proxy outside the browser. Thus we have nothing to test.

We can check the caching by using the following test:

1
2
3
4
5
6
7
8
9
10
11
it('does not have response', () => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos')
.its('response')
.should('deep.include', {
statusCode: 304,
statusMessage: 'Not Modified',
body: ''
})
})

The response is cached

This is where it gets a little tricky - since this caching depends on the DevTools setting. If for example, you set the browser DevTools Network tab to disable caching you get the test that passes when the DevTools is closed, and fails when the DevTools is open.

Caching makes the test pass or fail

The solution

The server will always return the actual data if the server cannot determine what the client has already. The server determines the data "cache key" in this case by looking at the if-none-match request header sent by the web application.

The request headers sent by the client

We need to delete this header on the outgoing request. Let's do it using our cy.intercept. Now we can extend the test to validate the response.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('always gets the new data', () => {
cy.intercept('/todos', req => {
delete req.headers['if-none-match']
}).as('todos')
cy.visit('/')
cy.wait('@todos')
.its('response')
.should('deep.include', {
statusCode: 200,
statusMessage: 'OK'
})
.and('have.property', 'body') // yields the "response.body"
.then(body => {
// since we do not know the number of items
// just check if it is an array
expect(body).to.be.an('array')
})
})

The client always receives fresh copy of the server data

Multiple matchers

The problem

The command cy.intercept can match requests using a substring, a minimatch, or a regular expression. By default, it intercepts requests matching any HTTP method. Thus when you define several intercepts, it is easy to get into the situation when multiple intercepts apply. In that case the first cy.wait(alias) "uses up" the intercept's response.

Consider the following test that adds a new todo.

1
2
3
4
5
6
7
8
9
beforeEach(resetDatabase)

beforeEach(() => {
cy.visit('/')
})

it('enters 1 todo', () => {
cy.get('.new-todo').type('Write a test{enter}')
})

We probably want to make sure the new todo sent to the server has the title "Write a test". Let's set up an intercept and validate the sent data.

1
2
3
4
5
6
7
8
9
10
11
beforeEach(resetDatabase)

beforeEach(() => {
cy.visit('/')
})

it('enters 1 todo', () => {
cy.intercept('POST', 'todos').as('post')
cy.get('.new-todo').type('Write a test{enter}')
cy.wait('@post')
})

If we inspect the value yielded by cy.wait('@post') it shows the object sent in the request

The new item sent to the server

Great, let's validate the new item. Since we do not control the random ID generation (of course we can control it, see the Cypress testing workshop how), we can just validate the title and completed properties.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
beforeEach(() => {
cy.visit('/')
})

it('enters 1 todo', () => {
cy.intercept('POST', 'todos').as('post')
cy.get('.new-todo').type('Write a test{enter}')
cy.wait('@post')
.its('request.body')
.should('deep.include', {
title: 'Write a test',
completed: false
})
})

Validating the item sent to the server

Great, everything works, what's the problem?

Imagine someone else comes along and changes the cy.visit and adds a wait for todo items to load - just like we did before. They do this to guarantee the application has loaded before adding new items.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
beforeEach(resetDatabase)

beforeEach(() => {
cy.intercept('/todos').as('todos')
cy.visit('/')
cy.wait('@todos')
})

it('enters 1 todo', () => {
cy.intercept('POST', '/todos').as('post')
cy.get('.new-todo').type('Write a test{enter}')
cy.wait('@post')
.its('request.body')
.should('deep.include', {
title: 'Write a test',
completed: false
})
})

So far so good. The test passes.

Waiting on the todos and validating the sent item

Notice a curious thing in the Command Log though - the XHR request to post the new item to the server has todos(2) badge.

The network request matches two intercepts

The POST /todos network call the application has made matches two intercepts, but the Command Log only prints the first intercept's alias.

In our test both intercepts only spied on the request. Now someone comes along and asks why do we need to reset the database before each test using resetDatabase utility method. It would be so simple to stub the initial GET /todos call and return an empty list of items.

The changed test is below - it simply uses cy.intercept('/todos', []).as('todos').

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
beforeEach(() => {
cy.intercept('/todos', []).as('todos')
cy.visit('/')
cy.wait('@todos')
})

it('enters 1 todo', () => {
cy.intercept('POST', '/todos').as('post')
cy.get('.new-todo').type('Write a test{enter}')
cy.wait('@post')
.its('request.body')
.should('deep.include', {
title: 'Write a test',
completed: false
})
})

The test fails.

The test now fails

The root cause

If there are multiple matching interceptors, the first intercept that stubs the request stops any further processing. Thus our POST /todos network request gets intercepted by overly broad first * /todos intercept.

The solution

Use specific intercepts and make sure the stubs are not "breaking" any listeners defined afterwards. Change the first intercept to only work with GET /todos call.

1
2
3
cy.intercept('GET', '/todos', []).as('todos') // stub
...
cy.intercept('POST', '/todos').as('post') // spy

The test using specific intercepts works

Update: I have opened issue #9588 to provide better labels to the intercepted requests.

No overwriting interceptors

Let's imagine the most common scenario - every test starts with zero items. We stab the GET /todos request and visit the site in a beforeEach hook.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
beforeEach(() => {
cy.intercept('GET', '/todos', []) // start with zero todos
cy.visit('/')
})

it('adds a todo', () => {
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo').should('have.length', 1)
})

it('completes todo', () => {
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo')
.should('have.length', 1)
.first()
.find('.toggle')
.click()
cy.contains('.todo', 'write test').should('have.class', 'completed')
})

Several tests that start with zero items using network stub

Now you want to confirm the application displays the initial list of todos correctly. Hmm, you would like to stub the initial GET /todos call in the new test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
beforeEach(() => {
cy.intercept('GET', '/todos', []) // start with zero todos
cy.visit('/')
})

it('adds a todo', () => {
...
})

it('completes todo', () => {
...
})

it('shows the initial todos', () => {
// hmm overwrite the intercept?
cy.intercept('GET', '/todos', { fixture: 'two-items.json' })
cy.visit('/')
cy.get('.todo').should('have.length', 2)
})

Attempting to overwrite the intercept fails

Unfortunately the test fails. The first intercept cy.intercept('GET', '/todos', []) still executes, our the second intercept never has a chance.

We have a tracker issue #9302 to solve this problem somehow. It is complex - changing an intercept from stub to spy, or from spy to stub is tricky.

The solution

Separate the suites of tests to avoid using the beforeEach hook that sets up the unwanted intercept.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
context('start with zero todos', () => {
beforeEach(() => {
cy.intercept('GET', '/todos', [])
cy.visit('/')
})

it('adds a todo', () => {
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo').should('have.length', 1)
})

it('completes todo', () => {
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo')
.should('have.length', 1)
.first()
.find('.toggle')
.click()
cy.contains('.todo', 'write test').should('have.class', 'completed')
})
})

context('start with two items', () => {
it('shows the initial todos', () => {
// hmm overwrite the intercept?
cy.intercept('GET', '/todos', { fixture: 'two-items.json' })
cy.visit('/')
cy.get('.todo').should('have.length', 2)
})
})

We have completely separated the suite of tests that should all start with zero items from the suite of tests that should start with two items. Now the intercepts never "compete". After all - this is just JavaScript and you can compose the test commands and the hooks in any way you want.

Separate suites of tests to avoid competing intercepts

Avoid Cypress commands inside the interceptor

You cannot use Cypress commands inside the interceptor callback, since it breaks the deterministic command chaining. The following test fails

1
2
3
4
5
6
7
8
9
it('tries to use cy.writeFile', () => {
cy.visit('/')
cy.intercept('POST', '/todos', req => {
console.log('POST /todo', req)
cy.writeFile('posted.json', JSON.stringify(req.body, null, 2))
})

cy.get('.new-todo').type('an example{enter}')
})

Calling cy.writeFile inside the interceptor

The root cause

The interceptor is triggered by the application's code, while other Cypress commands are already running. Cypress has no idea how to chain the cy.writeFile command - should it be after the currently running command? Or later? This leads to non-deterministic and unpredictable tests. Thus this is disallowed.

The solution

Instead save the body of the request in a variable, wait for the network call to happen, and then write the data to a file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it('saves it later', () => {
let body

cy.visit('/')
cy.intercept('POST', '/todos', req => {
console.log('POST /todo', req)
body = req.body
}).as('post')

cy.get('.new-todo')
.type('an example{enter}')
.wait('@post')
.then(() => {
// this callback executes AFTER the "cy.wait" command above
// thus by now the "body" variable has been set and we can
// write the contents to the file
cy.writeFile('posted.json', JSON.stringify(body, null, 2))
})
})

Watch the video below for details

Sending different responses

Imagine we stub the loading items from the server. Initially the server returns two items. Then the user adds another item (we stub the POST request too), and when the page reloads, we should receive three items. How would we return different responses for the same GET /todos call? At first we could try to overwrite the intercept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
it('returns list with more items on page reload', () => {
// we start with 2 items in the list
cy.intercept('GET', '/todos', twoItems)
cy.visit('/')
cy.get('.todo').should('have.length', 2)

// now we add the third item
const item = {
title: 'Third item',
completed: false,
id: 101
}
// the server replies with the posted item
cy.intercept('POST', '/todos', item).as('post')
cy.get('.new-todo').type(item.title + '{enter}')
cy.wait('@post')

// when the page reloads we expect the server to send 3 items
const threeItems = Cypress._.cloneDeep(twoItems).concat(item)
cy.intercept('GET', '/todos', threeItems)
cy.reload()
cy.get('.todo').should('have.length', 3)
})

But of course this does not work - because as I have shown in the previous section, you cannot overwrite an intercept (yet).

The first intercept still returns the two items

The root cause

The first interceptor responds to both GET /todos calls. The cy.intercept('GET', '/todos', threeItems) intercept was never called, as I highlighted in the screenshot.

The solution

Use JavaScript code to return different responses from a single GET /todos intercept. For example, you can place all prepared responses into an array, and then use Array.prototype.shift to return and remove the first item.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it('returns list with more items on page reload', () => {
const item = {
title: 'Third item',
completed: false,
id: 101
}
// we start with 2 items in the list
// when the page reloads we expect the server to send 3 items
const threeItems = Cypress._.cloneDeep(twoItems).concat(item)
const replies = [twoItems, threeItems]

// return a different response from the same intercept
cy.intercept('GET', '/todos', req => req.reply(replies.shift()))
cy.visit('/')
cy.get('.todo').should('have.length', 2)

// the server replies with the posted item
cy.intercept('POST', '/todos', item).as('post')
cy.get('.new-todo').type(item.title + '{enter}')
cy.wait('@post')

cy.reload()
cy.get('.todo').should('have.length', 3)
})

The same intercept returning different responses

No overwriting interceptors (again)

Let's get back to overwriting the interceptors again. We want to overwrite the GET /todos response from a particular test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
context('overwrite interceptors', () => {
beforeEach(() => {
cy.intercept('GET', '/todos', []).as('todos')
})

it('adds a todo', () => {
cy.visit('/')
...
})

it('completes todo', () => {
cy.visit('/')
...
})

// DOES NOT WORK
it('shows the initial todos', () => {
// overwrite the previous response with the new one
cy.intercept('GET', '/todos', { fixture: 'two-items.json' })
cy.visit('/')
cy.get('.todo').should('have.length', 2)
})
})

The last test "shows the initial todos" fails, because its intercept is never executed. See the previous section No overwriting interceptors for the details and a solution.

The solution

We cannot replace the intercept again, but we can extend the logic in setting the intercept to use JavaScript code to implement the overwrite. Let's create a new Cypress command cy.http that will stub the network call with a response, but that response will come from an object of intercepts. Any test that wants to return something else should just replace the value in that object.

Tip: I will store the object with intercepts in Cypress.config() object and will reset it before every test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
beforeEach(function resetIntercepts() {
Cypress.config('intercepts', {})
})

Cypress.Commands.add('http', (alias, method, url, response) => {
const key = `${alias}-${method}-${url}`
const intercepts = Cypress.config('intercepts')

if (key in intercepts) {
intercepts[key] = response
} else {
intercepts[key] = response
cy.intercept(method, url, req => {
return req.reply(intercepts[key])
}).as(alias)
}
})

The main logic is inside the cy.intercept call

1
2
3
cy.intercept(method, url, req => {
return req.reply(intercepts[key])
}).as(alias)

Notice the value is not hard-coded. Here is how we use cy.http from our tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
context('overwrite interceptors', () => {
beforeEach(function resetIntercepts() {
Cypress.config('intercepts', {})
})

Cypress.Commands.add('http', (alias, method, url, response) => {
const key = `${alias}-${method}-${url}`
const intercepts = Cypress.config('intercepts')

if (key in intercepts) {
intercepts[key] = response
} else {
intercepts[key] = response
cy.intercept(method, url, req => {
return req.reply(intercepts[key])
}).as(alias)
}
})

beforeEach(() => {
cy.http('todos', 'GET', '/todos', [])
})

it('adds a todo', () => {
cy.visit('/')
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo').should('have.length', 1)
})

it('completes todo', () => {
cy.visit('/')
cy.get('.new-todo').type('write test{enter}')
cy.get('.todo')
.should('have.length', 1)
.first()
.find('.toggle')
.click()
cy.contains('.todo', 'write test').should('have.class', 'completed')
})

it('shows the initial todos', () => {
// overwrite the previous response with the new one
cy.http('todos', 'GET', '/todos', { fixture: 'two-items.json' })
cy.visit('/')
cy.get('.todo').should('have.length', 2)
})
})

The tests work

Changing the response in the same intercept

Notice we still have to call the cy.http before the cy.visit, thus cy.visit cannot be placed in the beforeEach hook. Still, overwriting is very convenient for tests that expect different responses, like adding an item.

1
2
3
4
5
6
7
8
9
10
11
12
it('adds a todo to the initial ones', () => {
// the application starts with two items
cy.http('todos', 'GET', '/todos', { fixture: 'two-items.json' })
cy.visit('/')
cy.get('.todo').should('have.length', 2)
cy.get('.new-todo').type('third item{enter}')

// now the server should return 3 items
cy.http('todos', 'GET', '/todos', { fixture: 'three-items.json' })
cy.reload()
cy.get('.todo').should('have.length', 3)
})

Tip: add a log message to our cy.http command for better experience.

1
2
3
4
5
Cypress.Commands.add('http', (alias, method, url, response) => {
const key = `${alias}-${method}-${url}`
cy.log(`HTTP ${key}`)
...
})

Changing the response in the same intercept in the same test

Single use intercept

This is similar to overwriting interceptors. Imagine you want to stub a network call once, but then allow other calls to go through to the server. Just intercepting with a static response would not work, as that intercept would work forever.

Here is the test that does not work.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
context('single use intercept', () => {
beforeEach(() => {
// let's reset the server to always have 2 todos
resetDatabaseTo('two-items.json')
})

it('stubs the first load (does not work)', () => {
// this test wants to have no todos at first
cy.intercept('GET', '/todos', []).as('todos')
cy.visit('/')
cy.wait('@todos')
cy.get('.todo-list li').should('have.length', 0)

cy.log('adding an item')
cy.get('.new-todo').type('new todo{enter}')
cy.contains('li.todo', 'new todo').should('be.visible')

// reload and expect to see the new todo again
cy.reload()
cy.contains('li.todo', 'new todo').should('be.visible')
})
})

The item disappears on reload

Notice how the new item appears in the UI, we also see it being sent to the server using POST /todos call. Yet, the intercept "todos" again returns the empty list of todos on page reload.

The solution

We want to intercept only once. We do not need to even write a custom command, a simple function would do:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Intercept the first matching request and send the response object.
* Do nothing on the second and other requests.
* @param {string} method HTTP method to intercept
* @param {string} url URL to intercept
* @param {any} response Response to send back on the first request
*/
const interceptOnce = (method, url, response) => {
// I am using "count" to show how easy you can implement
// different responses for different interceptors
let count = 0
return cy.intercept(method, url, req => {
count += 1
if (count < 2) {
req.reply(response)
} else {
// do nothing
}
})
}

Let's see the test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
beforeEach(() => {
// let's reset the server to always have 2 todos
resetDatabaseTo('two-items.json')
})

it('stubs the first load and does nothing after that', () => {
// this test wants to have no todos at first
interceptOnce('GET', '/todos', []).as('todos')
cy.visit('/')
cy.wait('@todos')
cy.get('.todo-list li').should('have.length', 0)

cy.log('adding an item')
cy.get('.new-todo').type('new todo{enter}')
cy.contains('li.todo', 'new todo').should('be.visible')

// reload and expect to see the new todo again
cy.reload()
cy.contains('li.todo', 'new todo').should('be.visible')
// since we reset the database with 2 todos, plus entered a new todo
// thus the total number of items should be 3
cy.get('.todo-list li').should('have.length', 3)
})

Nice! And since the intercept continues spying on the requests, even if it does not stub them, we can get the last call and confirm other details.

1
2
3
4
5
6
7
8
9
10
11
// Tip: you can still spy on "todos" intercept
// for example let's validate the server response has the new item
// at index 2 and it has the title and completed properties
cy.get('@todos')
.its('response.body')
.should('have.length', 3)
.its('2')
.should('include', {
title: 'new todo',
completed: false
})

The intercept once in action

Get number of times an intercept was matched

Sometimes you want to know / assert the number of times an intercept was matched. You can see this information in the "Routes" table in the Command Log, how can you confirm it from the test?

Note: for now, using cy.state('routes') is an implementation detail, and might change in the future, but you could do the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const getAliasCount = (alias) => {
// implementation details, use at your own risk
const testRoutes = cy.state('routes')
const aliasRoute = Cypress._.find(testRoutes, { alias })

if (!aliasRoute) {
return
}

return Cypress._.keys(aliasRoute.requests || {}).length
}

it('confirms the number of times an intercept was called', () => {
cy.intercept('/users?_limit=3').as('users3')
cy.intercept('/users?_limit=5').as('users5')

cy.get('#load-users').click().click()
cy.wait('@users3')

// to avoid clicking too quickly, add small pauses
cy.get('#load-five-users').click()
.wait(20).click()
.wait(20).click()
.wait(20).click()

cy.wait('@users5')
.then(() => {
const users3 = getAliasCount('users3')
const users5 = getAliasCount('users5')

cy.wrap({
users3,
users5,
}).should('deep.equal', {
users3: 2,
users5: 4,
})
})
})

Asserting the number of times an intercept was used

Count intercepts (again)

We can count the number of times the intercept was called

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('makes 3 calls', () => {
let count = 0
cy.intercept('/favorite-fruits', () => {
// we are not changing the request or response here
// just counting the matched calls
count += 1
})
cy.visit('/fruits.html')
// ensure the fruits are loaded
cy.get('.favorite-fruits li').should('have.length', 5)

cy.reload()
cy.get('.favorite-fruits li').should('have.length', 5)

cy.reload()
cy.get('.favorite-fruits li').should('have.length', 5)
.then(() => {
// by now the count should have been updated
expect(count, 'network calls to fetch fruits').to.equal(3)
})
})

If you are stubbing the network calls, use req.reply

1
2
3
4
5
let count = 0
cy.intercept('/favorite-fruits', (req) => {
count += 1
req.reply({ fixture: 'fruits.json' })
})

There is an alternative approach to getting the number of times a network intercept was invoked - by using cy.spy. If we pass a created spy as route handler, it allows the network call to continue unchanged, yet we can get the count from that spy later

1
2
3
4
// spy on the network calls
cy.intercept('...', cy.spy().as('net'))
// some time later
cy.get('@net').should('have.been.calledOnce')

Here is an application that fetches a list of fruits every 30 seconds. We can use a synthetic clock to speed up the test, as described in Testing periodic network requests with cy.intercept and cy.clock combination blog post. We can count the number of times the spy was called.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('fetches every 30 seconds', () => {
cy.clock()
// create a cy.spy() that will be called
// by the intercept. we later can use that spy
// to check the number of times it was called
cy.intercept('/favorite-fruits', cy.spy().as('reqForFruits'))

cy.visit('/fruits.html')
cy.get('@reqForFruits').should('have.been.calledOnce')

// application fetches fruits again 30 seconds later
cy.tick(30000)
cy.get('@reqForFruits').should('have.been.calledTwice')

cy.tick(30000)
cy.get('@reqForFruits').should('have.been.calledThrice')

cy.tick(30000)
// after that we should retrieve the call count property
cy.get('@reqForFruits').its('callCount').should('equal', 4)
})

The Command Log shows the result - there were 4 times the intercept was matched, and 4 times the spy was called.

Counting intercepts using spies

For stubbing, you can use the Sinon's callsFake method and invoke the req.reply from there

1
2
3
4
5
cy.intercept('/favorite-fruits',
cy.stub()
.callsFake(req => req.reply({ fixture: 'fruits.json' }))
.as('stubFruits')
)

Set an alias dynamically

In case of GraphQL, all requests go through a single endpoint. Thus it is important for the intercept to inspect the request first, before deciding what to do. A convenient way to deal with such requests is to set the alias dynamically. For example, let's inspect all GET /users requests, and set an alias for the request that limits the number of users to 5:

1
2
3
4
5
6
7
8
9
10
11
12
13
it('can set an alias depending on the request', () => {
cy.visit('/')
cy.intercept('GET', '/users', (req) => {
if (req.url.endsWith('/users?_limit=5')) {
req.alias = "load5"
}
})

cy.get('#load-users').click()
cy.get('#load-five-users').click()
cy.wait('@load5') // the second request created this alias dynamically
.its('response.body').should('have.length', 5)
})

The test runs, and an alias is created. The test can wait on that alias:

Waiting for dynamically created alias

Simulate network error

The problem

Imagine you are trying to check how your application handles a network communication error. Internet is unreliable, servers go down and come back up all the time. You want to make sure your web application does not blow up. You might try stubbing network request with forceNetworkError: true response.

1
2
3
4
5
6
7
8
9
10
11
it('fails because the API is not ready', () => {
cy.visit('/')
cy.intercept({
pathname: '/greeting',
},
{
forceNetworkError: true,
}).as('greeting')

cy.get('#get-api-response').click()
})

Hmm, a curious thing happens - you see multiple intercept messages in the Command Log!

Double intercept

Sometimes you see the intercept matched twice, some times even three times - but it was supposed to be a single Ajax call!

The root cause

This is the browser trying to be robust and retrying a failed network request. The browser sees a network error coming back from the Cypress proxy and retries the same request 1-2 times before giving up.

The solution

I prefer replying with a specific HTTP network status code rather than forceNetworkError: true in most situations. Thus I test how the application handles 404 or 500 errors rather than a generic "Server is not responding" network error.

1
2
3
4
5
6
7
8
9
10
11
12
13
// when the app tries to load items
// set it up to fail
cy.intercept(
{
method: 'GET',
pathname: '/todos'
},
{
body: 'test does not allow it',
statusCode: 404,
delayMs: 2000
}
)

The browser will not retry such call, since technically the server has responded. It is up to the application to retry on 404 status code.

force404

The problem

The deprecated cy.server had an option to block all non-stubbed routes, returning 404 status code instead. How do we recreate the same behavior using cy.intercept? If we do cy.intercept('*', { statusCode: 404 }) we will break all network requests.

The solution

The original cy.server and cy.route only dealt with XMLHttpRequest objects, thus we could limit ourselves to recreating the same behavior of JSON Ajax requests. Assuming your application's code is well-behaved and sets the correct accept header, we can define the last stub to catch all Ajax calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
// define "regular" intercept stubs, make sure they respond
// now let's stop all other Ajax application/json requests
cy.intercept({
headers: {
accept: 'application/json'
}
}, {
statusCode: 404
})

// let's try non-stubbed network call - it should fail
cy.get('#load-users').click()
cy.contains('#users', 'Not Found').should('be.visible')

If you really need some calls to go through, write the route matching logic yourself

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// similar to the deprecated cy.server({ force:404 })
// we want to stub all Ajax calls but GET /favorite-fruits
// now let's stop all other Ajax application/json requests
cy.intercept('*', (req) => {
if (req.method === 'GET' && req.url.endsWith('/favorite-fruits')) {
// let the request go to the server
return
}
if (req.headers.accept === 'application/json') {
req.reply({
statusCode: 404
})
}
})

With the full access to the request inside the route matcher function you can decide how to proceed.

Network idle

To wait for multiple network requests to finish and for the network to be idle, try using the cypress-network-idle plugin. Watch the videos introduction to Cypress-network-idle plugin and Prepare Intercept And Wait Using cypress-network-idle Plugin.

Stub SSE Resource

If you want to stub a server-side events resource, you can respond to the request with a text body that simply lists the events. Unfortunately, the events are not going to be spaced in time, but it still works. See the Cypress Network Testing Exercises bonus lesson 22 for details.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cy.intercept('GET', '/fruits-sse', (req) => {
req.reply({
headers: {
'Content-Type': 'text/event-stream',
},
body:
// important: the text should not have
// leading or trailing whitespace
// and the blank lines are important
'retry: 60000\n\n' +
'id: 0\n' +
'data: {"fruit":"Kiwi"}\n\n' +
'id: 1\n' +
'data: {"fruit":"Lemons"}\n\n' +
'id: 2\n' +
'data: {"fruit":"Mango"}\n\n' +
'event: end\n' +
'data: Stream closed\n',
})
}).as('sse')

Disable network caching

Cypress network interception happens outside the browser. But the browsers love to save bandwidth, so they cache the data internally. Often you see response headers like ETag: W/"960-cdSYN2pW398HtVmxLUpnD9XjLME" and request headers like If-None-Match: W/"960-cdSYN2pW398HtVmxLUpnD9XjLME" - these control the data caching. The server will look at the If-None-Match header value and respond with 304 Not Modified if the data hash is still the same as what the browser already has.

This creates problems during Cypress testing if you want to get the actual data, but the server sends an empty body with 304 response.

1
2
3
4
cy.wait('@load')
.its('responses.body')
// oops, you got ""
// because the server responds with 304

To stop the browser from sending request header If-None-Match you can:

  • in the interactive mode open DevTools and check "Disable cache" checkbox.

Disable network cache using browser DevTools

While the DevTools stay open, the browser will NOT use the cached data and will receive the full data from the server.

  • you can delete the request header If-None-Match inside the intcept
1
2
3
cy.intercept('/...', (req) => {
delete req.headers['If-None-Match']
})

The request automatically continues to the server and without it, the server has no idea what data the browser has, thus it is forced to send the full response.

1
2
3
4
5
6
7
8
9
10
11
12
// disable network caching using a Chrome Debugger Protocol command
// by using "cy.wrap" command we ensure that the promise returned
// by the Cypress.automation method resolves before proceeding
// to the next Cypress command
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Network.setCacheDisabled',
params: {
cacheDisabled: false,
},
}),
)

Tip: I wrote cypress-cdp plugin that makes the above commands much simpler

1
2
3
cy.CDP('Network.setCacheDisabled', {
cacheDisabled: true,
})

Cypress Network Testing Exercises Course

I show solutions to many problems shown in this blog post in my hands-on course Cypress Network Testing Exercises.

More info