Why does a Cypress cy.request() to a redirecting URL yield 200 instead of 302?
answer
- Check what the option defaults to
- You are seeing the end of a chain
- A 3xx status is not a failure
- One option stops at the first response
- redirectedToUrl appears only when not following
basics
~20 sBecause followRedirect defaults to true, so Cypress follows the chain and yields the last response. Set followRedirect: false to stop at the first one; then status is 302 and redirectedToUrl holds the absolute Location target.
solid answer
~40 s`cy.request()`'s `followRedirect` option defaults to `true`, so Cypress follows the `Location` header until it reaches a non-redirect response and yields that one. A `302` is a `3xx` status, which `failOnStatusCode` also tolerates, so nothing warns you - the assertion just sees the `200` of whatever page you landed on. Pass `{ followRedirect: false }` and the command stops at the first response: `resp.status` is `302` and `resp.redirectedToUrl` gives the `Location` value resolved to an absolute URL. If you want to follow *and* audit, leave the default and read `resp.redirects`, which lists each hop as `'302: <url>'`. Note that following also carries cookies forward at every hop, so switching the option off changes which cookies end up on the browser.
code
javascript · 9 linesit('bounces a non-approver away from the approval route', () => {
cy.request({
url: '/reports/4821/approve',
followRedirect: false,
}).then((resp) => {
expect(resp.status).to.eq(302)
expect(resp.redirectedToUrl).to.eq('http://localhost:8080/sign-in')
})
})go deeper
Remember that Cypress follows redirects for you by default and hands you the final response. The followRedirect option is how you ask it to stop and show the 302.
Explain what changes when you switch the option off: status becomes 302, redirectedToUrl appears as an absolute URL, and later hops no longer run - so their cookies never arrive.
Be ready to spot the vacuous assertion this creates: an authorization check that passes because a redirect landed on a page that happened to return 200.
Own the standard for what an authorization test must prove - the status and the destination, not merely that some page came back - and get it written down for the suite.
## The default that causes the surprise `cy.request()` takes a `followRedirect` option and it defaults to **`true`**. When the server answers with a `302` and a `Location` header, Cypress resolves the new URL, makes the next request, and keeps going until it reaches a response that is not a redirect. What lands in your `.then()` callback is the **last** response in that chain, so `resp.status` is the `200` of the sign-in page, not the `302` that sent you there. Two things make this quietly dangerous rather than merely surprising: - A `302` is a `3xx` status, and `cy.request()` only fails on statuses outside `2xx` and `3xx` when `failOnStatusCode` is left at its default. So neither the redirect nor the follow trips any alarm. - If you were checking authorization — "an unapproved expense report must not be readable" — a redirect to a sign-in page that returns `200` makes an assertion like `expect(resp.status).to.eq(200)` pass for the wrong reason. ## Turning the redirect into something you can assert on Set `followRedirect: false`. Cypress then stops at the first response and adds a Cypress-specific property to it: - `resp.status` is the real redirect status, `302`. - `resp.redirectedToUrl` is the `Location` header **resolved against the request URL**, so you get an absolute URL to compare against rather than whatever relative fragment the server wrote. `redirectedToUrl` is only populated when `followRedirect` is `false` *and* the response actually carries a `Location` header; do not expect it on a followed chain. ## Seeing the hops you did follow If you want the redirects followed but also want to know what happened, leave `followRedirect: true` and read `resp.redirects`. Cypress records one entry per hop, formatted as the status code, a colon and the resolved URL — for example `'302: http://localhost:8080/sign-in'`. The property is only present when at least one redirect occurred, so guard for it rather than asserting on its length unconditionally. `resp.allRequestResponses` carries the individual responses along the way. | you want to | set | read | |---|---|---| | land on the final page | `followRedirect: true` (default) | `resp.status`, `resp.body` | | prove a redirect happened | `followRedirect: false` | `resp.status`, `resp.redirectedToUrl` | | follow, but audit the path | `followRedirect: true` | `resp.redirects`, `resp.allRequestResponses` | ## Cookies along the chain Following a redirect is not a naive re-request. At each hop Cypress sets any cookies the response carried onto the browser, then reads the cookie jar again for the next URL before making the next request. That is why a login-style form post followed by a redirect leaves the browser holding the session cookie afterwards. It is also why turning `followRedirect` off changes more than the status you observe: you stop after one response, so the cookies the later hops would have set never arrive. ## Applying it to an expense-report suite Suppose `/reports/4821/approve` should bounce anyone without the approver role to `/sign-in`. The useful test is: 1. Issue `cy.request({ url: '/reports/4821/approve', followRedirect: false })`. 2. Assert the status is `302` — with Chai's `expect(resp.status).to.eq(302)`. 3. Assert `resp.redirectedToUrl` ends in `/sign-in`, which proves *where* the server sent the caller. Written with the default, all three assertions collapse into "the sign-in page returned 200", which would also be true if approval had silently succeeded and rendered a confirmation. The option is narrow, but it is the difference between a test that checks an authorization rule and one that checks that some page came back. ## When following is the behaviour you want The default is right far more often than not, and turning it off everywhere is its own mistake. When a seeding step posts a form and the server answers `302 -> /reports/4821`, following the redirect is what lands the session cookie on the browser and confirms the destination exists. Switch the option off only when the redirect itself is the thing under assertion: - proving an unauthorised caller is bounced, and to where - proving a legacy expense URL still redirects to its replacement rather than 404ing - capturing the exact `Location` a server produces, before anything normalises it away Everywhere else, follow, and read `resp.redirects` if you need the path. ## Related knobs worth not confusing - `failOnStatusCode: false` stops the command failing on a `4xx`/`5xx`; it has nothing to do with redirect following, and you do not need it to observe a `302`. - `retryOnStatusCodeFailure` (default `false`) and `retryOnNetworkFailure` (default `true`) control Cypress's under-the-hood retries of the request itself, up to four attempts — again unrelated to redirects. - `headers` you pass are sent on the **initial** request only, not on subsequent requests in a followed chain, which is a real cause of "my auth header vanished" reports.
- How do you audit the hops when you still want Cypress to follow the redirects?Leave `followRedirect` at its default and read `resp.redirects` in the `.then()` callback. Cypress records one string per hop in the form `'302: <resolved url>'`, and `resp.allRequestResponses` holds the individual responses. `redirects` is only present when at least one redirect actually occurred, so guard before asserting.
- Does a 302 response fail a Cypress cy.request() when failOnStatusCode is left on?No. `failOnStatusCode` fails the command on statuses outside `2xx` and `3xx`, and a `302` is inside that range. That is precisely why a silently followed redirect raises no alarm; you have to opt out of following to observe it.
saying these in an interview costs you the question
- Assumes cy.request() never follows redirects by default
- Expects redirectedToUrl on a response whose chain was followed
- Adds failOnStatusCode: false to make a 302 observable
- Treats the 200 from the sign-in page as proof of authorization
- Thinks custom headers are resent on every hop of a chain