How To Interact With Iframes Using Cypress.io

Intermediate

This post covers a solution in Cypress, which requires modern JavaScript and ES6+ knowledge.

Cypress.io is great for testing the front end of your app’s UI, but at the time of this post, it lacks a few fundamental features. One important feature is handling file uploads natively, but there is a workaround for that. Another popular use case I came across is testing and interacting with iframes. Testing iframes with automated software seems niche, but it’s critical for many types of web apps.

A common example is embedded payment handlers such as BrainTree or Stripe, where their inputs are embedded as iframes. In the modern day, fully testing payment flows is a must. Unfortunately, there is a long issue thread with requests for this feature dating back from 2016! For now, many workarounds have been provided. Some are good for certain use cases. However, I needed a general purpose solution that was easy to implement. I’ll go over the ones that worked for me.

Testing Stripe Inputs

Your use cases will of course vary, but I’ll use Stripe as an example since it’s very common in modern e-commerce apps. There’s a few steps for interacting with an iframe:

  • Target the iframe’s selector: i.e: #cardnumber iframe
  • Wait for the iframe to load and return its body content
  • Interact with the iframe’s contents like any other Cypress element
<div class="card-container form-group">
    <label>Card information</label>
    <div class="form-row">
      <div class="col card-number">
        <div class="form-control" id="cardNumber"></div>
      </div>
    </div>

    <div class="form-row">
      <div class="col card-expiry">
        <div class="form-control" id="cardExpiry"></div>
      </div>
      <div class="col card-cvc">
        <div class="form-control" id="cardCvc" ref="cardCvc"></div>
      </div>
    </div>
</div>

Stripe will mount card elements at #cardNumber, #cardExpiry, and #cardCvc. Once they’re mounted, there will be input elements that we expect cypress to be able to type into.

The “iframe” Command

I found this solution in the issue thread:

Cypress.Commands.add(
  'iframeLoaded',
  { prevSubject: 'element' },
  ($iframe) => {
    const contentWindow = $iframe.prop('contentWindow')
    return new Promise(resolve => {
      if (
        contentWindow &&
        contentWindow.document.readyState === 'complete'
      ) {
        resolve(contentWindow)
      } else {
        $iframe.on('load', () => {
          resolve(contentWindow)
        })
      }
    })
  })

Cypress.Commands.add(
  'getInDocument',
  { prevSubject: 'document' },
  (document, selector) => Cypress.$(selector, document)
)

Let’s imagine what using this on our Stripe fields may look like. Using the above example, the usage in the actual tests will look something like:

cy.get($iframeSelector)
  .iframeLoaded()
  .its('document')
  .getInDocument($innerElement)
  .type('4242424242424242')

Assuming our iframe selector is #cardNumber iframe and the element we want to target is input[name="cardnumber"], the usage will look like this.

cy.get('#cardNumber iframe')
  .iframeLoaded()
  .its('document')
  .getInDocument('input[name="cardnumber"]')
  .type('4242424242424242')

This should type “4242424242424242” into the cardnumber input Stripe generated. Using this method, you should be able to chain cypress commands like normal. Unfortunately, switching from iframes can be difficult, which is the only major drawback I’ve encountered.

An Alternative Method

A simpler method is making the iframe command return the body contents once it’s loaded. Here’s how it works:

Cypress.Commands.add(
  'iframe',
  { prevSubject: 'element' },
  ($iframe) => {
    return new Cypress.Promise(resolve => {
      $iframe.on('load', () => {
        resolve($iframe.contents().find('body'))
      })
    })
  })

This method works just as well, and I find it more straightforward for my use case. Feel free to try either! Here’s how you’d use the above snippet in a test:

cy.get($iframe)
    .iframe()
    .find($inputSelector)
    .type(input)

That’s pretty much it! Notice that the command and the implementation is quite a bit shorter.

While not as good as an official implementation, these methods are pretty usable and flexible in my experience. These should hopefully hold over until an official implementation is added to Cypress. In the meantime, do what you must to get these examples working. I know testing iframes is a must for some companies, so try not to let it stop you from using Cypress. Have fun!

Leave a Comment