In Cypress, how do you test a link that opens in a new browser tab?
answer
- Cypress stays where it started
- no command switches tabs
- test the handoff, not the destination
- have.attr on target and href
- removeAttr, or cy.request the href
basics
~20 sCypress drives a single tab and has no tab-switching command, so assert the link rather than follow it: check its href and target attributes, request the URL directly with cy.request, or strip the target attribute so the click navigates the current tab.
solid answer
~40 sCypress runs inside one browser tab and ships no command that switches to another, so a `target="_blank"` link is covered by asserting what the app renders rather than by following it. The default move is an attribute assertion: `cy.contains('a', 'August 2026 statement').should('have.attr', 'target', '_blank').and('have.attr', 'href', '/statements/2026-08.pdf')`. To prove the destination actually responds, `cy.request()` that href and assert the status — no tab needed. When the page behind the link is yours and you want to exercise it in the browser, strip the attribute first with `.invoke('removeAttr', 'target')` (jQuery's `removeAttr`, reached through Cypress's `.invoke()`), then click, so the navigation happens in the tab Cypress already controls. If the app opens the tab from JavaScript instead, stub `window.open` in `cy.visit()`'s `onBeforeLoad` and assert the URL it was called with.
code
javascript · 10 linesit('hands the August statement off to the PDF viewer', () => {
cy.visit('/statements')
cy.contains('a', 'August 2026 statement')
.should('have.attr', 'target', '_blank')
.and('have.attr', 'href', '/statements/2026-08.pdf')
// prove the destination responds without leaving the tab
cy.request('/statements/2026-08.pdf').its('status').should('eq', 200)
})go deeper
Be ready to say plainly that Cypress has no tab-switching command and to show one attribute assertion on the link. Naming target and href is most of the answer.
Explain what each technique proves and what it silently gives up, especially that stripping the target attribute deletes the very behaviour under test.
Show how you would cover a statements page end to end without ever opening a tab: attribute assertions in the UI spec, a request against one href, and separate coverage for the destination.
Own the position that the app's contract ends at the URL it hands over, and be able to defend where the boundary of your suite sits when a third party renders the document.
## The ceiling behind the question Cypress drives **one browser and one tab** for the life of a spec. There is no `cy.switchToWindow()`, no tab handle, no window index, and no option on any command that means "follow the tab the browser just opened". When a bank statement page renders a link like ```html <a href="/statements/2026-08.pdf" target="_blank" rel="noopener">August 2026 statement</a> ``` and a Cypress test clicks it, the click genuinely fires and the browser genuinely opens a second tab in front of you. What does **not** change is where Cypress is pointing: the command queue keeps running against the statements page, `cy.url()` still reports `/statements`, and `cy.get()` still queries the original document. As of Cypress 16 this is documented as a *permanent* trade-off, not a gap waiting to be closed. ## Test the handoff, not the destination The useful reframe is that a `target="_blank"` link is a **contract your application renders**, and that contract is the part your suite owns. The bank statement app's job is to hand the browser the right URL with the right opening behaviour. What the third-party PDF viewer then does with that URL is the viewer's job, and driving somebody else's page is explicitly outside what Cypress is built for. So the default step is an attribute assertion: ```js cy.contains('a', 'August 2026 statement') .should('have.attr', 'target', '_blank') .and('have.attr', 'href', '/statements/2026-08.pdf') ``` `cy.contains()` finds the anchor by its text, and the two assertions pin both halves of the handoff. If the href is generated (a signed URL, a statement id), assert the shape rather than the literal — Chai's `match` against a regular expression works through `.should()` the same way. ## Three techniques, and what each actually proves | technique | proves | does not prove | |---|---|---| | `.should('have.attr', 'href', …)` plus `'target', '_blank'` | the app renders the right destination and opening behaviour | that the destination responds, or that a human sees anything | | `cy.request(href)` | the URL resolves and returns the status and content type you expect | that the link is reachable from the UI at all | | `.invoke('removeAttr', 'target')` then `.click()` | the destination page loads and behaves in a tab Cypress controls | that the link opens in a new tab — you just deleted that attribute | The three compose. A common shape for the statements page is: assert the attributes on every row, then `cy.request()` one href to prove the file is really served, and leave the viewer itself to its own coverage. `.invoke('removeAttr', 'target')` deserves a warning. `.invoke()` calls a method on the jQuery-wrapped subject, so this is jQuery's `removeAttr` mutating the real DOM of the application under test. The click that follows navigates the tab Cypress is already in, which is convenient — but you have changed the page before measuring it, and the test no longer says anything about new-tab behaviour. Pair it with a separate attribute assertion if both matter. ## When JavaScript, not markup, opens the tab Plenty of apps do not use an anchor at all; a Download button calls `window.open(url, '_blank')`. Nothing above helps, because there is no attribute to read. The seam is the app's own `window`: - `cy.visit()` accepts an `onBeforeLoad` callback that receives the application's `window` before its scripts run. - Replace the method there with `cy.stub(win, 'open').as('openViewer')`. - Click the button as a user would, then assert on the alias with Sinon's `calledWith`, exposed through Chai as `.should('have.been.calledWith', …)`. - Nothing opens, nothing steals focus, and you have asserted the exact URL the app intended to hand over. This is the highest-fidelity option for the JavaScript case, because it observes the real call site rather than a rendered attribute. ## When you genuinely need the second tab Sometimes the value of the test really does live on the other side — the viewer posts a message back, or a token minted in the first tab must be spent in the second. That is what the `@cypress/puppeteer` plugin exists for: it hands a Node-side handler a Puppeteer `browser` connected to the browser Cypress launched, so the handler can find the extra page and drive it. It is a genuine escape hatch, it runs on Chromium-family browsers only, and the code lives outside your spec. Reach for it when the cheaper seams above cannot reach the behaviour, not as the default answer to "the app opens a tab".
- After `.invoke('removeAttr', 'target')`, what have you stopped testing?You have mutated the application's DOM, so the click no longer proves anything about new-tab behaviour — you removed the attribute that produced it. Keep a separate `.should('have.attr', 'target', '_blank')` assertion for the contract, and treat the stripped-target click purely as a way to exercise the destination page in a tab Cypress controls.
- The PDF viewer behind the link is a third-party product. Should the Cypress test open it at all?Usually not. Driving a page you do not own couples your suite to somebody else's markup and release schedule, and scripting third-party sites is outside what Cypress is built for. Assert that your app hands over the right URL, use `cy.request()` to prove the file is served, and let the viewer have its own coverage.
It is the difference between checking that the courier was handed the right address and climbing into the van to watch the delivery.
saying these in an interview costs you the question
- Claims cy.visit() can switch Cypress to the newly opened tab
- Invents a Cypress switchToWindow or cy.tab command
- Says cy.window() returns the second tab's window object
- Insists no workaround is needed because tabs just work
- Asserts on the viewer's content Cypress never actually loaded