QA How-To
Cypress iframe handling: Examples
Use practical Cypress iframe handling examples for forms, TypeScript helpers, nested frames, editors, uploads, network failures, reloads, and CI tests.
27 min read | 2,321 words
TL;DR
A reusable Cypress iframe handling example is `cy.get('iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap)`. The recipes below extend that same-origin pattern to typed helpers, forms, nested frames, uploads, network stubs, document reloads, and CI diagnostics.
Key Takeaways
- Start each same-origin example by waiting for contentDocument.body to be non-empty and wrapping the body.
- Choose a local helper for one spec and a typed custom command only when the access pattern is reused across specs.
- Reacquire the iframe body after submit, reload, navigation, or any action that can replace its document.
- For nested frames, unwrap and validate each document boundary separately.
- Register cy.intercept before a framed action and assert both the request contract and rendered outcome.
- Cypress commands such as selectFile work normally after the same-origin body is wrapped.
- For cross-origin provider frames, automate the owned integration and use documented sandbox paths instead of private DOM selectors.
A useful Cypress iframe handling example must do more than find a button in a toy frame. It should wait for asynchronous document loading, keep Cypress retryability, survive a frame reload, and make the cross-origin limit explicit.
The baseline for a same-origin frame is short: query the iframe, access 0.contentDocument.body, assert that the body is not empty, and wrap it. After that, Cypress commands work against the wrapped document body. There is no Selenium-style frame switch and there is no need for an invented cy.switchToIframe() API.
This cookbook builds that baseline into runnable TypeScript patterns. Replace the sample routes, selectors, and application endpoints with contracts from your product. Each example states what it proves and when to use a different testing layer.
TL;DR
cy.get<HTMLIFrameElement>('[data-cy="profile-frame"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('[name="displayName"]')
.type('Avery QA')
| Need | Recipe in this guide |
|---|---|
| One basic interaction | Direct chain in section 2 |
| Several actions in one frame | Local getIframeBody() helper in section 3 |
| Reuse across specs | Typed custom command in section 4 |
| Several or nested frames | Sections 5 and 6 |
| API success and failure | Section 7 |
| Editor and file upload | Section 8 |
| Reloaded frame document | Section 9 |
| Third-party cross-origin embed | Section 10 |
1. Cypress iframe handling example setup
Use an application page you control. These recipes assume /iframe-lab renders frames from the same origin as the outer page:
<iframe
data-cy="profile-frame"
title="Profile editor"
src="/embedded/profile"
></iframe>
The inner route contains stable product-owned selectors:
<form data-cy="profile-form">
<label>
Display name
<input name="displayName" />
</label>
<button type="submit">Save profile</button>
<p data-cy="profile-result" role="status"></p>
</form>
Cypress configuration can remain ordinary:
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
supportFile: 'cypress/support/e2e.ts',
},
})
This setup is runnable when those application routes exist. Cypress APIs do not create an iframe or bypass an origin boundary. The frame src and embedded markup are application fixtures, and the test owns only the way it observes them.
Before adopting an example, confirm new URL(frame.src).origin matches window.location.origin after redirects. Same hostname text is not enough if the scheme or port differs. Give every frame a unique data-cy selector and a meaningful title, which helps both automation and accessibility.
Keep the lab route in a non-production test fixture or use a real owned feature. A controlled fixture is excellent for verifying the helper itself, while feature specs should prove actual user behavior.
2. Basic form Cypress iframe example
The direct chain is best when a spec interacts with one frame once:
describe('embedded profile editor', () => {
it('saves a display name', () => {
cy.visit('/iframe-lab')
cy.get<HTMLIFrameElement>('[data-cy="profile-frame"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.within(() => {
cy.get('[name="displayName"]').type('Avery QA')
cy.contains('button', 'Save profile').click()
cy.get('[data-cy="profile-result"]')
.should('have.text', 'Profile saved')
})
})
})
The important chain has four responsibilities. cy.get() waits for the outer iframe. .its() retrieves the raw frame body's property. .should('not.be.empty') waits for asynchronous rendering. .then(cy.wrap) restores a Cypress subject that supports .within(), .find(), actions, and assertions.
Use .within() for a stable group of queries. Its callback scopes Cypress queries to the yielded body. Avoid chaining a new subject-dependent action from the result of .within(); treat the callback as the readable boundary and start another body query for later work.
A visible "Profile saved" status proves more than a click. If saving also updates the parent page, assert that integration after the inner assertion:
cy.contains('[data-cy="last-profile-update"]', 'Avery QA')
.should('be.visible')
This direct example is preferable to a custom abstraction when only one or two specs use a frame. Readers can see the origin access and readiness condition without opening the support folder.
If the form submission navigates the iframe, end the inner chain after the click and use a fresh iframe body query for the result. Section 9 shows that pattern.
3. Local getIframeBody helper example
A local function removes repetition without adding a global cy command:
const getIframeBody = (
selector: string,
): Cypress.Chainable<JQuery<HTMLBodyElement>> => {
return cy
.get<HTMLIFrameElement>(selector)
.its('0.contentDocument.body')
.should('not.be.empty')
.then((body) => cy.wrap(body))
}
describe('profile iframe validation', () => {
beforeEach(() => {
cy.visit('/iframe-lab')
})
it('shows required validation', () => {
getIframeBody('[data-cy="profile-frame"]').within(() => {
cy.contains('button', 'Save profile').click()
cy.contains('[role="alert"]', 'Display name is required')
.should('be.visible')
})
})
it('trims an accepted display name', () => {
getIframeBody('[data-cy="profile-frame"]').within(() => {
cy.get('[name="displayName"]').type(' Avery QA ')
cy.contains('button', 'Save profile').click()
cy.get('[data-cy="profile-result"]')
.should('contain.text', 'Avery QA')
})
})
})
A local helper is a strong default. It is close to the calling tests, has no name collision risk, and can evolve with the feature. The return type tells TypeScript and readers that it yields a jQuery collection containing an HTML body.
Do not pass a jQuery iframe captured long before the helper runs. Query by a current selector so Cypress can retry the outer element. Do not assign the return to a normal variable and expect the body synchronously because Cypress chains are queued.
If the feature has a specific ready signal, extend the local helper with that domain knowledge:
const getReadyProfileFrame = () => {
return getIframeBody('[data-cy="profile-frame"]')
.find('[data-cy="profile-form"]')
.should('be.visible')
.then(($form) => cy.wrap($form).closest('body'))
}
Usually it is clearer to let the helper yield the body and put the ready assertion in the test. Choose based on whether readiness is part of every caller's contract.
4. Typed custom command example
Promote the local helper when several feature areas need the same mechanical operation. Register it once:
// cypress/support/commands.ts
Cypress.Commands.add(
'getIframeBody',
(
selector: string,
options?: Partial<Cypress.Timeoutable>,
): Cypress.Chainable<JQuery<HTMLBodyElement>> => {
return cy
.get<HTMLIFrameElement>(selector, options)
.its('0.contentDocument.body')
.should('not.be.empty')
.then((body) => cy.wrap(body))
},
)
Declare it:
// cypress/support/index.d.ts
declare global {
namespace Cypress {
interface Chainable {
getIframeBody(
selector: string,
options?: Partial<Timeoutable>,
): Chainable<JQuery<HTMLBodyElement>>
}
}
}
export {}
Import the registration from cypress/support/e2e.ts:
import './commands'
Then call the command in any end-to-end spec:
cy.getIframeBody('[data-cy="profile-frame"]', {
timeout: 15_000,
}).within(() => {
cy.get('[name="displayName"]').should('be.enabled')
})
The optional timeout is applied to the outer cy.get(). Keep a normal default unless evidence shows this one frame has a documented longer initialization target. Avoid adding options for every possible behavior before a caller needs them.
Do not name this command iframe if the project also installs a plugin that registers cy.iframe(). Explicit naming prevents ambiguity. The community plugin can be convenient, but the built-in DOM pattern is sufficient for same-origin access. If you already use the plugin, pin and review it like any dependency instead of mixing two commands with similar semantics.
See Cypress custom command examples for declaration merging, subject contracts, and focused command tests.
5. Select the correct frame when several exist
A page may embed preview, help, analytics, and consent frames. Select the owned frame by a stable attribute:
cy.visit('/invoice/INV-1042')
cy.getIframeBody('[data-cy="invoice-preview-frame"]').within(() => {
cy.contains('h1', 'Invoice INV-1042').should('be.visible')
cy.get('[data-cy="invoice-total"]').should('have.text', '$125.00')
})
cy.getIframeBody('[data-cy="invoice-help-frame"]').within(() => {
cy.contains('How totals are calculated').should('be.visible')
})
If your only contract is title, an attribute selector can be readable:
cy.getIframeBody('iframe[title="Invoice preview"]')
.find('[data-cy="invoice-total"]')
.should('have.text', '$125.00')
Avoid cy.get('iframe').eq(0) unless order is explicitly required. A tag manager, consent platform, or responsive feature can inject a new frame ahead of yours. The test could access the wrong document or fail with a misleading origin error.
When the page renders one frame per data row, scope the lookup to the owning card:
cy.contains('[data-cy="report-card"]', 'Weekly quality report')
.find<HTMLIFrameElement>('iframe[title="Report preview"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('h1')
.should('have.text', 'Weekly quality report')
This example ties the frame to a business key instead of a global index. It also demonstrates that the body access can start from a scoped Cypress query, not only a global custom command.
6. Nested iframe Cypress example
Nested same-origin frames need separate unwrap operations. The outer frame's body contains the inner iframe:
const unwrapFrame = (
$frame: JQuery<HTMLIFrameElement>,
): Cypress.Chainable<JQuery<HTMLBodyElement>> => {
return cy
.wrap($frame)
.its('0.contentDocument.body')
.should('not.be.empty')
.then((body) => cy.wrap(body))
}
cy.visit('/editor-shell')
cy.get<HTMLIFrameElement>('[data-cy="workspace-frame"]')
.then(unwrapFrame)
.find<HTMLIFrameElement>('[data-cy="document-frame"]')
.should('be.visible')
.then(unwrapFrame)
.within(() => {
cy.get('body[contenteditable="true"]')
.click()
.type('Nested frame content')
cy.contains('button', 'Save').click()
})
The first .then(unwrapFrame) receives the jQuery collection yielded by cy.get(). The inner .find() is now scoped to the outer body. The second call unwraps the inner document.
Validate origin at every level. An owned outer shell can embed a cross-origin vendor frame, and access will stop at that inner boundary. Do not assume that reaching the outer body makes every descendant document readable.
Give each layer a distinct failure message through nearby assertions:
cy.getIframeBody('[data-cy="workspace-frame"]')
.find('[data-cy="workspace-ready"]')
.should('have.attr', 'data-state', 'ready')
Then start the nested traversal. This makes a workspace initialization failure different from a missing editor body.
Consider testing the inner route directly for most editor behavior. One nested end-to-end path can prove shell integration, while direct-route or component tests cover validation and formatting faster.
7. Network success and failure examples inside a frame
Register a route before visiting or before the framed action that starts it:
cy.intercept(
{
method: 'PUT',
pathname: '/api/profiles/current',
},
{
statusCode: 200,
body: { id: 'profile-1', displayName: 'Avery QA' },
},
).as('saveProfile')
cy.visit('/iframe-lab')
cy.getIframeBody('[data-cy="profile-frame"]').within(() => {
cy.get('[name="displayName"]').type('Avery QA')
cy.contains('button', 'Save profile').click()
})
cy.wait('@saveProfile').then(({ request, response }) => {
expect(request.body).to.deep.include({ displayName: 'Avery QA' })
expect(response?.statusCode).to.equal(200)
})
cy.getIframeBody('[data-cy="profile-frame"]')
.contains('[role="status"]', 'Profile saved')
.should('be.visible')
A failure recipe verifies recovery rather than only HTTP status:
cy.intercept('PUT', '/api/profiles/current', {
statusCode: 503,
body: { code: 'PROFILE_TEMPORARILY_UNAVAILABLE' },
}).as('saveProfileFailure')
cy.visit('/iframe-lab')
cy.getIframeBody('[data-cy="profile-frame"]').within(() => {
cy.get('[name="displayName"]').type('Avery QA')
cy.contains('button', 'Save profile').click()
})
cy.wait('@saveProfileFailure')
cy.getIframeBody('[data-cy="profile-frame"]').within(() => {
cy.contains('[role="alert"]', 'Could not save your profile')
.should('be.visible')
cy.get('[name="displayName"]').should('have.value', 'Avery QA')
cy.contains('button', 'Try again').should('be.enabled')
})
These endpoints and messages belong to the sample application, not Cypress. Match your documented contract. Narrow matching prevents an initialization request from satisfying the wrong alias. The Cypress cy.intercept guide has additional request mutation and error patterns.
Use at least one unstubbed path to prove that deployed frontend and backend versions work together. Stubs create deterministic UI coverage, not full integration proof.
8. Rich editor and file upload examples
A same-origin rich editor often exposes a contenteditable body:
cy.visit('/campaign/new')
cy.getIframeBody('[data-cy="email-editor-frame"]').within(() => {
cy.get('body[contenteditable="true"]')
.should('be.visible')
.click()
.type('Quality report is ready for review.')
})
cy.contains('button', 'Save draft').click()
cy.getIframeBody('[data-cy="email-preview-frame"]')
.contains('Quality report is ready for review.')
.should('be.visible')
First use user-level commands. If the editor library requires a documented API for test setup, isolate that as setup and keep one browser interaction path. Avoid directly setting innerHTML because it skips input events, sanitization, selection behavior, and user-observable validation.
File selection works after the iframe body is wrapped:
cy.visit('/documents')
cy.getIframeBody('[data-cy="upload-frame"]').within(() => {
cy.get('input[type="file"]').selectFile(
'cypress/fixtures/sample-resume.pdf',
)
cy.contains('[data-cy="selected-file"]', 'sample-resume.pdf')
.should('be.visible')
cy.contains('button', 'Upload').click()
})
cy.contains('[data-cy="document-row"]', 'sample-resume.pdf')
.should('be.visible')
The fixture must exist in the repository and the application must accept that type. Use small synthetic files with no personal data. For drag-and-drop controls, selectFile() also supports a documented drag-drop action, but use it only when the product control expects that behavior.
Check upload failures, maximum size, unsupported type, cancellation, and parent-page status at appropriate layers. A browser spec should protect the main user path and one critical recovery path, while validation logic can have focused component or unit coverage.
9. Reloaded or navigated iframe example
Submitting a form can navigate the iframe to a confirmation document. Do not continue from a body captured before navigation:
cy.visit('/booking')
cy.getIframeBody('[data-cy="booking-frame"]').within(() => {
cy.get('[name="date"]').type('2026-08-20')
cy.contains('button', 'Continue').click()
})
cy.get<HTMLIFrameElement>('[data-cy="booking-frame"]')
.should(($frame) => {
const src = $frame[0].contentWindow?.location.pathname
expect(src).to.equal('/embedded/booking/confirm')
})
cy.getIframeBody('[data-cy="booking-frame"]').within(() => {
cy.contains('h1', 'Confirm booking').should('be.visible')
cy.contains('button', 'Confirm').click()
})
cy.contains('[data-cy="booking-status"]', 'Booking confirmed')
.should('be.visible')
Reading contentWindow.location is allowed only for a same-origin frame. The callback assertion retries, so it can observe the navigation. If the destination can include a generated ID, assert a stable path pattern rather than an exact full URL.
Another option is to wait for the navigation request or a known confirmation element, then reacquire the body. The core rule is the same: a state-changing action ends the old chain.
If the frame reloads in place without changing URL, wait for an application-ready marker that changes by state. Add a unique step label or status attribute if the UI provides no observable transition. Testability is a product feature, and an explicit readiness signal helps users, monitoring, and automation.
Never retry the submit click until confirmation appears. Repeated clicks can create duplicate bookings. Retry queries and assertions, not side effects.
10. Cross-origin iframe integration example
Suppose the page embeds a provider-hosted payment frame. Direct descendant queries are blocked. Test the owned outer contract:
cy.intercept('POST', '/api/checkout/payment-session').as('paymentSession')
cy.visit('/checkout')
cy.wait('@paymentSession').then(({ request, response }) => {
expect(request.body).to.have.keys(['cartId', 'currency'])
expect(response?.statusCode).to.equal(201)
})
cy.get('iframe[title="Secure payment fields"]')
.should('be.visible')
.and('have.attr', 'src')
.and('include', 'payments.example.test')
cy.contains('[data-cy="payment-help"]', 'Payment details are processed securely')
.should('be.visible')
Then drive provider success and decline through supported sandbox data or a documented application test endpoint. Assert what your application owns:
cy.intercept('GET', '/api/checkout/payment-status/*', {
statusCode: 200,
body: { status: 'declined', reason: 'card_declined' },
}).as('paymentDeclined')
cy.contains('button', 'Refresh payment status').click()
cy.wait('@paymentDeclined')
cy.contains('[role="alert"]', 'Payment was declined')
.should('be.visible')
cy.contains('button', 'Try another payment method')
.should('be.enabled')
This is not a claim that a stub verifies the provider. It verifies your decline-state integration. Maintain a small provider sandbox journey separately according to the vendor's supported automation policy.
Do not place cy.origin() around an embedded frame query. It is for top-level cross-origin navigation, as explained in Cypress cy.origin examples. Do not disable web security for the whole suite as a shortcut.
11. Cypress iframe handling example review matrix
Use this matrix when selecting a recipe:
| Review question | If yes | If no |
|---|---|---|
| Is every frame level same-origin? | Use body unwrapping | Test the owned boundary |
| Is access repeated only in one spec? | Keep a local helper | Consider a typed global command |
| Can the action replace the document? | Reacquire the body | A scoped .within() block can be sufficient |
| Is a request part of readiness? | Alias it and assert UI too | Wait for a stable DOM-ready signal |
| Does the provider own the inner DOM? | Use its sandbox contract | Use owned selectors |
| Does CI run under another hostname? | Verify frame allowlists and headers | Reproduce browser and viewport differences |
A copy-ready example is only the starting point. Replace selectors and routes, document the frame's origin and ownership, and add diagnostics for the failure modes your environment can produce. Run the helper against a small controlled fixture so a refactor does not break dozens of feature specs with the same opaque error.
Keep actions at the end of chains when re-rendering is possible. Query the body again after each step transition. Use Cypress assertions instead of loops or fixed waits. If an example needs a very large timeout, investigate the frame response and readiness state first.
Interview Questions and Answers
Q: What is the shortest correct same-origin iframe pattern?
Query the iframe, access 0.contentDocument.body, assert that the body is not empty, and call cy.wrap(). Continue with .find() or .within() on the wrapped body.
Q: When should the helper be local instead of a custom command?
Keep it local when one feature or spec uses it. Promote it when several independent specs share the exact same access contract and a typed global command improves readability.
Q: Why should you reacquire the body after a submit?
The submit can navigate or re-render the iframe document, detaching the previous body and descendants. A fresh query observes the current document and regains Cypress retryability.
Q: How do you test a request from inside an iframe?
Register cy.intercept() before the action, alias the narrow route, perform the framed action, and wait for the alias. Assert both safe request fields and the resulting UI state.
Q: Can you upload a file inside an iframe?
Yes, for a same-origin frame. Wrap the frame body, query the file input, and use Cypress .selectFile() with a repository fixture or buffer supported by the documented API.
Q: What should a cross-origin iframe test prove?
It should prove the owned embed configuration, loading and error behavior, callback handling, and backend integration. A provider sandbox should cover a small supported end-to-end path without depending on private vendor markup.
Common Mistakes
- Copying an iframe snippet without confirming scheme, host, and port.
- Calling an invented
cy.switchToIframe()method that Cypress does not provide. - Querying descendants before the body is populated.
- Saving a body subject across form submission or frame navigation.
- Turning a one-spec helper into an unnecessary global command.
- Registering
cy.intercept()after the frame has already sent the request. - Waiting on a wildcard alias that matches several calls.
- Using a fixed sleep for frame initialization.
- Selecting the first iframe on a page with vendor and consent frames.
- Mutating a rich editor's HTML and claiming user input is covered.
- Uploading real resumes, identity documents, or customer data in CI.
- Using
cy.origin()as if it switched into a cross-origin embedded frame. - Disabling browser security suite-wide.
- Asserting only a click or request, with no visible integration outcome.
Conclusion
The core Cypress iframe handling example is intentionally small: obtain a same-origin body, wait until it is usable, wrap it, and use normal Cypress commands. The production-quality variations come from choosing stable selectors, adding a real readiness signal, reacquiring after navigation, and asserting the parent integration.
Start with the direct form recipe, promote it to a typed helper only when reuse is real, and add network or nested-frame logic one boundary at a time. For third-party frames, keep the browser security model intact and test the parts your application owns.
Interview Questions and Answers
Show a same-origin Cypress iframe example.
I query the iframe, use `.its('0.contentDocument.body')`, assert `.should('not.be.empty')`, and pass the body to `cy.wrap()`. I then use `.within()` or `.find()` for the framed controls and assert a business result.
Why is a local iframe helper often better than a custom command?
A local helper keeps scope and ownership clear when only one feature uses the pattern. It avoids adding another global command and can include feature-specific selectors without pretending to be a universal API.
How do you type a getIframeBody custom command?
I declare it on `Cypress.Chainable` with a string selector and a return type of `Chainable<JQuery<HTMLBodyElement>>`. The implementation returns the Cypress chain that wraps the non-empty body.
How do you handle a frame that navigates after clicking Continue?
I end the chain after the click, wait for a same-origin URL or a new ready marker, and query the iframe body again. I never keep using descendants from the previous document.
How do you avoid selecting the wrong iframe?
I use a product-owned `data-cy`, meaningful title, or scope the frame to a container identified by a business key. I avoid index selection unless iframe order is itself a requirement.
How do you test a failed API call inside an iframe?
I register a narrow `cy.intercept()` response before the action, trigger the request from the framed UI, wait on its alias, and assert the error, preserved input, and available recovery action.
Why should tests not set a rich editor's innerHTML directly?
Direct mutation bypasses input events, selection behavior, sanitization, and validation. I first use user-level input or a documented editor testing API, then verify the saved or previewed result.
What is wrong with using cy.origin for a vendor iframe?
`cy.origin()` changes command execution for top-level cross-origin navigation, not an embedded document. It cannot give the parent context direct access to the vendor frame DOM.
Frequently Asked Questions
What is a working Cypress iframe example?
Use `cy.get('iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap)`, then query descendants from the wrapped body. This works when the iframe content is same-origin.
How do I create a Cypress iframe TypeScript helper?
Return a `Cypress.Chainable<JQuery<HTMLBodyElement>>` from a function or custom command that queries the iframe, reads its body, waits for content, and wraps it. Add declaration merging when you register a global custom command.
Can I use within with a Cypress iframe?
Yes. Once the same-origin body is wrapped, call `.within()` to scope a stable group of inner queries. Reacquire the body after actions that navigate or replace the frame document.
How do I test nested iframes with Cypress?
Unwrap the outer body, find the inner iframe within that body, and unwrap the inner document separately. Every iframe in the traversal must be readable under the browser same-origin policy.
Does selectFile work inside an iframe?
It works for a file input inside a same-origin frame after you wrap the frame body. Use synthetic fixtures and assert both the selected filename and the application's upload result.
Why does my iframe test pass locally but fail in CI?
Common causes include a different environment origin, frame allowlist, CSP or frame headers, proxy behavior, browser version, viewport, and slower resource loading. Collect the resolved frame URL and network evidence before increasing timeouts.
How should I test a Stripe-like cross-origin iframe?
Test your embed configuration, application callbacks, error recovery, and backend status or webhook contracts. Use the provider's documented sandbox for a focused real flow and avoid private selectors inside the hosted frame.