Cypress: Targeting elements inside iframes

Cypress is an automated testing tool that allows you to write end-to-end tests for your app. It has a fantastically easy-to-use, jQuery-like syntax and is a powerful and delightful tool for your integration tests.

One major pain point when using Cypress is testing on elements within iframes. Unfortunately, Cypress can’t easily target elements within an iframe without a bit of extra work.

Worse, their documentation on doing so is a sad paragraph in their FAQ so you’ll wind up combing through loads of GitHub Issues (for extra reading this issue is the best you'll find).

This post outlines some lessons learned and some simple Cypress custom commands that make working with iframes a breeze.

Disable chromeWebSecurity

First, if your iframe uses cross-origin resources (ex. loading a script from a 3rd party website like Plaid or Stripe), you need to update your cypress.json and disable chromeWebSecurity.

{
  // ...Other settings
  "chromeWebSecurity": false
}

Without this change, your iframe assets will not load due to CORS (cross-origin resource sharing) issues.

Selecting iframes in Cypress

The simplest method to target elements within iframes is to simply use Cypress's then method. then yields the element from the previous command.

cy.get('iframe')
  .then(($iframe) => {
    const $body = $iframe.contents().find('body')

    cy.wrap($body)
      .find('input')
      .type('fake@email.com')
  })

The issue with this approach is it doesn't ensure that the content within the iframe is available yet. For example, this approach may test errors because the body element wasn't found when you attempted to run find('input').

To ensure that the iframe has loaded, we need a little more legwork:

cy
  .get('iframe')
  .should(iframe => expect(iframe.contents().find('body').to.exist)
  .then(iframe => cy.wrap(iframe.contents().find('body')))
  .within({}, $iframe => {
    cy.get('input').type('fake@email.com')
  })

The primary improvement is the should method. should ensures that the iframe has a body before we attempt to run commands on it. Additionally, instead of returning the iframe's body to work off of, we can scope subsequent calls within the body element using within.

Clean it up with a Cypress custom command

The code above it pretty gnarly. We can greatly DRY up the code by extracting it into a reusable Cypress command:

Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => {}) => {
    // For more info on targeting inside iframes refer to this GitHub issue:
    // https://github.com/cypress-io/cypress/issues/136
    cy.log('Getting iframe body')

    return cy
        .wrap($iframe)
        .should(iframe => expect(iframe.contents().find('body')).to.exist)
        .then(iframe => cy.wrap(iframe.contents().find('body')))
        .within({}, callback)
})

This command utilizes the previously found element, then ensures that the iframe has finished loading before scoping the iframe body for subsequent calls.

Usage:

cy.get('iframe').iframe(() => {
  // Targets the input within the iframe element
  cy.get('input').type('fake@email.com')
})

Clean code, baby!

Timeouts

Don’t be afraid to set pretty high timeouts when working with iframes. There’s a lot of room for high latency because:

  1. iframes load a separate HTML document, which takes time to parse, execute, and render.
  2. iframe assets don’t share the same cache as your app. This means every asset is downloaded as if were for the first time.
  3. iframes often load 3rd party scripts. In addition to downloading, parsing, and executing, this 3rd party script may take time to initialize dynamic content.
  4. The client’s network conditions may vary from you and your 3rd party’s servers.

When testing iframes loaded by 3rd party scripts, I often reach for timeouts like so:

cy.get('iframe').iframe(() => {
  cy.find('input', { timeout: 10 * 1000 })
    .type('fake@email.com')
})

The code above will look inside the iframe for the input element. If it finds the element, it will resolve immediately and move on to the type method. Otherwise, it will wait 10sec before erroring.

Note: Using timeout within Cypress commands is preferable to the generic cy.wait. Unlike timeout , cy.wait will pause execution for the entirety of the time regardless of whether the element is found or not. As a result, cy.wait tends to be much more brittle and cause intermittent failures.

// 🙅‍♂️ - Wait 10sec
cy.wait(10 * 1000)

// 🙆‍♂️ - Wait 10sec or less
cy.get('#el', { timeout: 10 * 1000 })

postMessage

When working with iframes, we sometimes use postMessage to facilitate communication between the iframe and our app.

One helpful use case is to notify the app that the iframe content has finished loading dynamic content and is ready to receive user input.

For example, you might use the postMessage API to send your app a ready event after a few seconds.

<!-- Inside the iframe -->
<script>
  setTimeout(() => {
    window.parent.postMessage({ code: 'Ready' }, e.origin)
  }, 3 * 1000)
</script>

// Inside your app
window.addEventListener('message', e => {
  var data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
  var code = data.code

  if (code === 'Ready') {
    alert('iframe is ready')
  }
})

If you use the postMessage API, using the command from the previous section may have a race condition prone to intermittent failures.

Our custom iframe Cypress command only checks that the iframe element is in the ready state, but that may not include dynamically rendered content.

If you have this issue, you can create another Cypress custom command isIFrameReady to listen for the Ready event sent from your iframe.html.

Cypress.Commands.add('isIFrameReady', () => {
  return cy.window().then({ timeout: 10 * 1000 }, window => {
    return new Cypress.Promise(resolve => {
      window.addEventListener('message', e => {
        const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data

        if (data.code === 'Ready') {
          resolve()
        }
      })
    })
  })
})

And using our two commands together:

cy.isIFrameReady().then(() => {
  cy.get('iframe').iframe(() => {
    cy
      .get('input', { timeout: 10 * 1000 })
      .type('fake@email.com')
  })
})

Wrapping up

Working with iframes is a headache in Cypress, but I hope this post helps you overcome common issues I ran into with them.

Feel free to leave questions below.


Thanks for reading! You are my favorite person for sticking around until the end. 🍻

This blog is a constant work in progress, and I want to get better with your help! If you have feedback or questions on this post, please leave a comment below or reach out to me on Twitter.

This article is from nicknish.co where I publish articles on software engineering and how to leverage technology to build products that people will pay you for.

cypressjavascriptdev