Working with iframes in Cypress

February 12, 2020

•

By Gleb Bahmutov

Cypress has a ... difficulty working with iframes. Mostly because all built-in cy DOM traversal commands do hard stop the moment they hit #document node inside the iframe.

iframe when it sees a Cypress command (re-enactment)

If your web application uses iframes, then working with elements in those iframes requires your own custom code. In this blog post I will show how to interact with DOM elements inside an iframe (even if the iframe is served from another domain), how to spy on window.fetch requests that the iframe makes, and even how to stub XHR requests from the iframe.

Note: you can find the source code for this blog post in the recipe "Working with iframes" located in the repository cypress-example-recipes.

Application with iframe

Let's take a static HTML page and embed an iframe. Here is the full source code.

<body>
  <style>
    iframe {
      width: 90%;
      height: 100%;
    }
  </style>
  <h1>XHR in iframe</h1>
  <iframe src="https://jsonplaceholder.cypress.io/" 
          data-cy="the-frame"></iframe>
</body>

Tip: we will be using data-cy attribute to find the iframe following our Best Practices for selecting elements guide.

Let's write the first test in spec file cypress/integration/first-spec.js that visits the page.

it('gets the post', () => {
  cy.visit('index.html').contains('XHR in iframe')
  cy.get('iframe')
})

The test passes, and we can see the loaded iframe.

iframe is displayed

If we manually click the button "Try it", the iframe does indeed fetch the first post.

When the user clicks "Try It" button, the result is displayed below

Clicking on a button inside the iframe

Let's try writing the test commands to find the "Try it" button and then to click it. That button is located inside the body element of the document of the iframe element. Let's write a helper function to get to the body element.

const getIframeDocument = () => {
  return cy
  .get('iframe[data-cy="the-frame"]')
  // Cypress yields jQuery element, which has the real
  // DOM element under property "0".
  // From the real DOM iframe element we can get
  // the "document" element, it is stored in "contentDocument" property
  // Cypress "its" command can access deep properties using dot notation
  // https://on.cypress.io/its
  .its('0.contentDocument').should('exist')
}

const getIframeBody = () => {
  // get the document
  return getIframeDocument()
  // automatically retries until body is loaded
  .its('body').should('not.be.undefined')
  // wraps "body" DOM element to allow
  // chaining more Cypress commands, like ".find(...)"
  .then(cy.wrap)
}

it('gets the post', () => {
  cy.visit('index.html')
  getIframeBody().find('#run-button').should('have.text', 'Try it').click()
  getIframeBody().find('#result').should('include.text', '"delectus aut autem"')
})

Unfortunately, the test fails - the contentDocument element never changes from null.

Cypress test fails to access the iframe's document

Our problem is that our test runs under the domain localhost (you can see it in the browser's url), while the button and the iframe itself come from the domain jsonplaceholder.cypress.io. Browsers do not allow JavaScript from one domain to access elements in another domain - that would be a huge security hole. Thus we need to tell our browser running the tests to allow such access–after all, this is our test, we control the application and know that the 3rd party iframe it embeds is safe to use.

To enable cross-domain iframe access, I will set the property chromeWebSecurity to false in the file cypress.json and re-run the test.

{
  "chromeWebSecurity": false
}

The test passes!

Clicking on the button inside the iframe and asserting the UI updates

Slow-loading frames

Before we proceed, I would like to confirm that our code works even if the 3rd party iframe is slow to load. I will switch Cypress that by default uses Electron browser to run the tests in Chrome browser.

Once Chrome runs the test (under the test user profile Cypress creates), I open the Chrome extensions store and install the URL Throttler extension. I enable this extension and add https://jsonplaceholder.cypress.io/ URL to be slowed down by 2 seconds.

URL Throttler slowing down iframe's load

Notice how the test now takes longer than 2 seconds - because the iframe is delayed by the extension.

Loading the iframe is delayed by 2 seconds using the URL Throttler extension (the yellow snail icon)

Tip: you can include a Chrome extension in your repository and install it automatically - for more details, read our "How to load the React DevTools extension in Cypress" blog post.

Our test automatically waits for the frame to load using built-in command retries.

// in getIframeDocument()
cy
  .get('iframe[data-cy="the-frame"]')
  .its('0.contentDocument')
  // above "its" command will be retried until
  // content document property exists

// in getIframeBody()
getIframeDocument()
  // automatically retries until body is loaded
  .its('body').should('not.be.undefined')

While this works, I have to note that only the last command its('body') is retried, which can lead to failing tests. For example, a web application might include an iframe placeholder, that changes its body later - yet our code will not see the change, since it already has the contentDocument property and only retries getting the body. (I have seen this happening when using a Stripe credit card widget that has its own iframe element).

Thus to make the test code more robust and retry everything, we should merge all its commands into a single command:

const getIframeBody = () => {
  // get the iframe > document > body
  // and retry until the body element is not empty
  return cy
  .get('iframe[data-cy="the-frame"]')
  .its('0.contentDocument.body').should('not.be.empty')
  // wraps "body" DOM element to allow
  // chaining more Cypress commands, like ".find(...)"
  // https://on.cypress.io/wrap
  .then(cy.wrap)
}

it('gets the post using single its', () => {
  cy.visit('index.html')
  getIframeBody().find('#run-button').should('have.text', 'Try it').click()
  getIframeBody().find('#result').should('include.text', '"delectus aut autem"')
})

Nice.

Custom command

We will probably access the iframe's elements in multiple tests, so let's make the above utility function into a Cypress custom command inside the cypress/support/index.js file. The custom command will be available in all spec files automatically, since the support file is concatenated with each spec file.

// cypress/support/index.js
Cypress.Commands.add('getIframeBody', () => {
  // get the iframe > document > body
  // and retry until the body element is not empty
  return cy
  .get('iframe[data-cy="the-frame"]')
  .its('0.contentDocument.body').should('not.be.empty')
  // wraps "body" DOM element to allow
  // chaining more Cypress commands, like ".find(...)"
  // https://on.cypress.io/wrap
  .then(cy.wrap)
})
// cypress/integration/custom-command-spec.js
it('gets the post using custom command', () => {
  cy.visit('index.html')
  cy.getIframeBody()
    .find('#run-button').should('have.text', 'Try it').click()
  cy.getIframeBody()
    .find('#result').should('include.text', '"delectus aut autem"')
})

We can hide the details of each step inside cy.getIframeBody code by disabling internal commands' logging.

Cypress.Commands.add('getIframeBody', () => {
  // get the iframe > document > body
  // and retry until the body element is not empty
  cy.log('getIframeBody')

  return cy
  .get('iframe[data-cy="the-frame"]', { log: false })
  .its('0.contentDocument.body', { log: false }).should('not.be.empty')
  // wraps "body" DOM element to allow
  // chaining more Cypress commands, like ".find(...)"
  // https://on.cypress.io/wrap
  .then((body) => cy.wrap(body, { log: false }))
})

The Command Log in the left column looks much nicer now.

Custom command with a single log and an assertion

Spying on window.fetch

When the user or Cypress clicks the "Try it" button, the web application is making a fetch request to a REST API endpoint.

Ajax call from the iframe

We can inspect the response returned by the server by clicking on the request.

In this case, it is a JSON object representing a "todo" resource with certain keys and values. Let's confirm that the window.fetch method was called by the application with expected parameters. We can use the command cy.spy to spy on object's methods.

const getIframeWindow = () => {
  return cy
  .get('iframe[data-cy="the-frame"]')
  .its('0.contentWindow').should('exist')
}

it('spies on window.fetch method call', () => {
  cy.visit('index.html')

  getIframeWindow().then((win) => {
    cy.spy(win, 'fetch').as('fetch')
  })

  cy.getIframeBody().find('#run-button').should('have.text', 'Try it').click()
  cy.getIframeBody().find('#result').should('include.text', '"delectus aut autem"')

  // because the UI has already updated, we know the fetch has happened
  // so we can use "cy.get" to retrieve it without waiting
  // otherwise we would have used "cy.wait('@fetch')"
  cy.get('@fetch').should('have.been.calledOnce')
  // let's confirm the url argument
  .and('have.been.calledWith', 'https://jsonplaceholder.cypress.io/todos/1')
})

We get an object window from the iframe, then set up a method spy using cy.spy(win, 'fetch') and give it an alias as('fetch') to retrieve the calls that go through that method later. We can see the spies and when they were called in the Command Log, I marked them with green arrows in the screenshot below.

Cypress shows spies and stubs

Tip: we can move the utility function getIframeWindow into a custom command, similarly to how we created the cy.getIframeBody() command.

Ajax calls from iframe

Spying on method calls like window.fetch is fun, but let's take it one step further. Cypress can directly spy on and stub the application's network requests but only if the web app uses the XMLHttpRequest object and not window.fetch (we will fix this in #95). So if we want to directly observe or stub the app's network calls that an iframe makes, we need to:

  1. Replace window.fetch inside the iframe with XMLHttpRequest from the application's window–because that object has spying and stubbing extensions added by the Cypress Test Runner.
  2. Call cy.server and then use cy.route to observe network calls.

Copy XMLHttpRequest object

I am following the recipe "Stubbing window.fetch" from cypress-example-recipes to replace window.fetch with unfetch polyfill - and copying the XMLHttpRequest object into the iframe. Here is the utility code we need.

let polyfill

// grab fetch polyfill from remote URL, could be also from a local package
before(() => {
  const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'

  cy.request(polyfillUrl)
  .then((response) => {
    polyfill = response.body
  })
})

const getIframeWindow = () => {
  return cy
  .get('iframe[data-cy="the-frame"]')
  .its('0.contentWindow').should('exist')
}

const replaceIFrameFetchWithXhr = () => {
  // see recipe "Stubbing window.fetch" in
  // https://github.com/cypress-io/cypress-example-recipes
  getIframeWindow().then((iframeWindow) => {
    delete iframeWindow.fetch
    // since the application code does not ship with a polyfill
    // load a polyfilled "fetch" from the test
    iframeWindow.eval(polyfill)
    iframeWindow.fetch = iframeWindow.unfetch

    // BUT to be able to spy on XHR or stub XHR requests
    // from the iframe we need to copy OUR window.XMLHttpRequest into the iframe
    cy.window().then((appWindow) => {
      iframeWindow.XMLHttpRequest = appWindow.XMLHttpRequest
    })
  })
}

Spying on network call

Here is the first test - it spies on the network call, similarly to the window.fetch spy test above.

it('spies on XHR request', () => {
  cy.visit('index.html')

  replaceIFrameFetchWithXhr()
  // prepare to spy on XHR before clicking the button
  cy.server()
  cy.route('/todos/1').as('getTodo')

  cy.getIframeBody().find('#run-button')
    .should('have.text', 'Try it').click()

  // let's wait for XHR request to happen
  // for more examples, see recipe "XHR Assertions"
  // in repository https://github.com/cypress-io/cypress-example-recipes
  cy.wait('@getTodo').its('response.body').should('deep.equal', {
    completed: false,
    id: 1,
    title: 'delectus aut autem',
    userId: 1,
  })

  // and we can confirm the UI has updated correctly
  getIframeBody().find('#result')
    .should('include.text', '"delectus aut autem"')
})

Notice how we can wait for the network request to happen, and get the full access to the request and response objects that we can use in our assertions.

cy.wait('@getTodo').its('response.body').should('deep.equal', {
  completed: false,
  id: 1,
  title: 'delectus aut autem',
  userId: 1,
})

Tip: read the blog post "Asserting Network Calls from Cypress Tests" for more examples of assertions against the network calls.

Stubbing network call

Relying on 3rd party APIs is less that ideal. Let's replace that call to /todos/1 with our own stubbed response. The XMLHttpRequest object has been copied after the page loads and the iframe is ready, let's use it to return an object.

it('stubs XHR response', () => {
  cy.visit('index.html')

  replaceIFrameFetchWithXhr()
  // prepare to stub before clicking the button
  cy.server()
  cy.route('/todos/1', {
    completed: true,
    id: 1,
    title: 'write tests',
    userId: 101,
  }).as('getTodo')

  cy.getIframeBody().find('#run-button')
    .should('have.text', 'Try it').click()
  // and we can confirm the UI shows our stubbed response
  cy.getIframeBody().find('#result')
    .should('include.text', '"write tests"')
})

Nice, cy.route with an object argument stubs the matching network requests, and our assertions confirm that the iframe shows the text "write tests".

XHR stubbed response is shown in the results area

Bonus: cypress-iframe plugin

One of our users Keving Groat has written cypress-iframe plugin with custom commands simplifying working with elements inside an iframe. Install the plugin with npm install -D cypress-iframe then use the custom commands.

// the next comment line loads the custom commands from the plugin
// so that our editor understands "cy.frameLoaded" and "cy.iframe"
/// <reference types="cypress-iframe" />
import 'cypress-iframe'

describe('Recipe: blogs__iframes', () => {
  it('fetches post using iframes plugin', () => {
    cy.visit('index.html')
    cy.frameLoaded('[data-cy="the-frame"]')
    // after the frame has loaded, we can use "cy.iframe()"
    // to retrieve it
    cy.iframe().find('#run-button').should('have.text', 'Try it').click()
    cy.iframe().find('#result').should('include.text', '"delectus aut autem"')
  })
})
The passing test using cypress-iframe commands

Conclusion

iframes are annoying - I wish our Cypress team had enough time to tackle them once and for all. Yet, they are not a show-stopper - you just need to follow this blog post as a guide and look at the code in the recipe "Working with iframes" in the repository cypress-example-recipes to get around the obstacle.

Cypress test by-passing the iframe boundary (approximation)