Note: I am using code from testing-workshop-cypress to demonstrate these cy.intercept gotchas.
- 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
- Set an alias dynamically
- 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) => { |
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:
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