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
cy.wait
uses the intercept- Cached response
- Multiple matchers
- No overwriting interceptors
- Avoid Cypress commands inside the interceptor
- Sending different responses
- No overwriting interceptors (again)
- Single use intercept
- Get the number of times an intercept was matched
- Count intercepts (again)
- Set an alias dynamically
- Simulate network error
- Force 404
- Network idle
- Stub SSE resource
- new 🌟 Disable network caching
- More info
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 | describe('intercept', () => { |
It looks reasonable, it even shows the call in the Command Log - but does NOT pass.
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 root cause
The application is making the call to load the data right on startup:
1 | created() { |
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 | it('is registered too late', () => { |
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 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 | it('is registered too late', () => { |
The above test clearly shows that the intercept is registered too late.
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 | it('is taken by the wait', () => { |
This is different behavior from cy.route where we could wait on the interception, and the get it separately.
1 | it('is taken by the wait (unlike 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 | it('should use wait value', () => { |
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 | it('verifies the waited interception via .then(cb)', () => { |
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.
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 | it('does not have response', () => { |
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.
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.
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 | it('always gets the new 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 | beforeEach(resetDatabase) |
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 | beforeEach(resetDatabase) |
If we inspect the value yielded by cy.wait('@post')
it shows the object sent in the request
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 | beforeEach(() => { |
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 | beforeEach(resetDatabase) |
So far so good. The test passes.
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 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 | beforeEach(() => { |
The test 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 | cy.intercept('GET', '/todos', []).as('todos') // stub |
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 | beforeEach(() => { |
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 | beforeEach(() => { |
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 | context('start with zero todos', () => { |
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.
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 | it('tries to use cy.writeFile', () => { |
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 | it('saves it later', () => { |
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 | it('returns list with more items on page reload', () => { |
But of course this does not work - because as I have shown in the previous section, you cannot overwrite an intercept (yet).
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 | it('returns list with more items on page reload', () => { |
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 | context('overwrite interceptors', () => { |
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 | beforeEach(function resetIntercepts() { |
The main logic is inside the cy.intercept
call
1 | cy.intercept(method, url, req => { |
Notice the value is not hard-coded. Here is how we use cy.http
from our tests
1 | context('overwrite interceptors', () => { |
The tests work
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 | it('adds a todo to the initial ones', () => { |
Tip: add a log message to our cy.http
command for better experience.
1 | Cypress.Commands.add('http', (alias, method, url, response) => { |
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 | context('single use intercept', () => { |
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 | /** |
Let's see the test
1 | beforeEach(() => { |
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 | // Tip: you can still spy on "todos" intercept |
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 | const getAliasCount = (alias) => { |
Count intercepts (again)
We can count the number of times the intercept was called
1 | it('makes 3 calls', () => { |
If you are stubbing the network calls, use req.reply
1 | let count = 0 |
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 | // spy on the network calls |
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 | it('fetches every 30 seconds', () => { |
The Command Log shows the result - there were 4 times the intercept was matched, and 4 times the spy was called.
For stubbing, you can use the Sinon's callsFake
method and invoke the req.reply
from there
1 | cy.intercept('/favorite-fruits', |
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 | it('can set an alias depending on the request', () => { |
The test runs, and an alias is created. The test can wait on that 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 | it('fails because the API is not ready', () => { |
Hmm, a curious thing happens - you see multiple intercept messages in the Command Log!
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 | // when the app tries to load items |
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 | // define "regular" intercept stubs, make sure they respond |
If you really need some calls to go through, write the route matching logic yourself
1 | // similar to the deprecated cy.server({ force: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 | cy.intercept('GET', '/fruits-sse', (req) => { |
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 | cy.wait('@load') |
To stop the browser from sending request header If-None-Match
you can:
- in the interactive mode open DevTools and check "Disable cache" checkbox.
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 | cy.intercept('/...', (req) => { |
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.
- disable network cache using Chrome Debugger Protocol and built-in Cypress Automation
1 | // disable network caching using a Chrome Debugger Protocol command |
Tip: I wrote cypress-cdp plugin that makes the above commands much simpler
1 | cy.CDP('Network.setCacheDisabled', { |
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
- blog post Migrating .route() to .intercept() in Cypress
- blog post Difference between cy.route and cy.route2
- Highly recommended: the
cy.intercept
recipe has a lot of examples- spying on requests
- stubbing any request
- changing the response from the server
- intercepting static resources like HTML and CSS
- redirecting requests
- replying with different responses
- blog post Smart GraphQL Stubbing in Cypress
cy.intercept
documentation page- The blog post Testing loading spinner states with Cypress shows another way to control the network to assert the loading element.
- see how to implement "wait for network idle", "how to test if the network call has not been made" in Cypress Tips and Tricks