CypressでStripe Elementsの自動テストをおこなう


はじめに

開発しているWebアプリケーションにて、Stripe Elementsを用いたクレジットカード情報入力機能💳を実装したのですが、
Cypressを用いてE2Eテストを作成する際に少し考慮が必要だったので、備忘として残します✍️

ハマりポイント

stripe-jsを用いて、こんな感じのコードからカード番号入力要素を作成しました
なお、アプリはVuetify + Nuxt.jsでできているのですが、Cypressサイドからはあまり関係のないことなのでガッツリと割愛します🍛

StripeElementsの実装イメージ
// 諸々を定義
// 本旨から逸れるので割愛
const clientSecret = 'xxxxx'
const stripe = await getStripe()

// カード番号を入力する要素を生成
const elements = stripe.elements()
const card = elements.create('card', {
  hidePostalCode: true
})

// blurイベント発生時に処理実施
card.on('blur', () => {
  // コントロールを入力不可とする
  card.update({ disabled: true })

  stripe.confirmCardPayment(clientSecret, {
    payment_method: {
      card
    }
  }).then((confirmResult) => {
    if (confirmResult.error) {
      // エラーありの場合、入力完了を示すアラートを表示
      // 割愛

      // コントロールを再度入力可能とする
      card.update({ disabled: false })
    } else {
      // エラーなしの場合、画面項目を編集不可とする
      // 割愛
    }
  }).catch((error) => {
    // システムエラーの場合
    // 割愛
  })
})

// IdがCardNoの要素の配下にマウント
card.mount('#CardNo')

上記のコードより、以下のようなDOMが生成されます🌲
ポイントは、カード番号を入力するinput要素が、Stripeによって生成されたiframeの中にある...というところでしょうか

本来ならば、カード情報の入力が別ドメイン(Stripe)のiframeに隔離されていることで安全に利用できるものになりますが、
安全すぎてCypressからDOMにアクセスできず、テストカード番号の入力ができません

  1) トップページ -> 検索 -> カートに入れる -> 注文情報入力 -> 購入:
     SecurityError: Blocked a frame with origin "http://localhost" from accessing a cross-origin frame.

また、Cypressから要素をいじっている影響からか、ライブラリ内?で未キャッチの例外が発生し、それに引きずられてテストケースも落ちてしまいます
理由を調べたいところではありますが、セキュリティ上の観点からStripeのクライアント実装のソースコードは公開されておらず(stripe-jsはただのラッパー&型定義)、なんとも言えない感じです...

何がしかの理由で confirmCardPayment が複数回呼ばれてしまっているようですが、
プロダクトコードでは非活性化等は考慮しており、自動テスト上でのみ発生する事象のため、問題なさそうなら見て見ぬフリを決め込みたいところです🙈

  1) トップページ -> 検索 -> カートに入れる -> 注文情報入力 -> 購入:
     IntegrationError: The following error originated from your application code, not from Cypress.

  > You have an in-flight confirmCardPayment! Please be sure to disable your form submit button when confirmCardPayment is called.

When Cypress detects uncaught errors originating from your application it will automatically fail the current test.

This behavior is configurable, and you can choose to turn this off by listening to the `uncaught:exception` event.

https://on.cypress.io/uncaught-exception-from-application

作ったもの

というところでしたが、
対処療法的な形で以下のように自動テストを修正し、無事動かせるようになりました🙄

テストコードのイメージ
it('トップページ -> 検索 -> カートに入れる -> 注文情報入力 -> 購入', () => {
  const options = {
    timeout: 10000
  }

  // 他のシナリオは割愛

  // クレジットカード情報を設定
  // Stripe起因で発生する特定のエラーを無視する
  cy.on('uncaught:exception', (error) => {
    if (/You have an in-flight confirmCardPayment/gi.test(error.message)) {
      return false
    }
    // 他のエラーは無視しない
    return true
  })

  // 読み込みを確認
  cy.frameLoaded('#CardNo iframe')

  // カード番号・有効期限・セキュリティコードを入力
  cy.iframe('#CardNo iframe')
    // カード番号のinputを選択
    .find('input[placeholder="カード番号"]')
    // 各inputに値をフル桁入力すると、自動的に次のinputにフォーカスされるのでありがたく利用する
    .type(['4242424242424242', '01', '50', '123'].join(''))

  // Stripeのレスポンスを確認
  cy.get('#CardNo')
    // 他の要素を適当にクリックし、blurイベントを発火させる
    .click()
    // waitUntil()は `cypress-wait-until` というプラグインのものですが割愛
    .waitUntil(() => cy.contains('お支払い情報を確認しました'), options)

  // 他のシナリオは割愛
})

まず、iframeに対するクロスオリジンアクセスについては、 cypress.json に以下の設定を追加すると動作するようになりました🧐

cypress.json
{
  "chromeWebSecurity": false
}

実装としては多分こちらで、恐らくにはそれぞれ以下の意味と思われますが、テストコードなのでこれ以上の深追いはやめました...

その上で、Cypressからiframe配下のコンテンツへのアクセスを楽にしてくれるcypress-iframeというプラグインを公開されている方がいたので、こちらを利用しました
cy.frameLoaded() で対象のiframeの読み込み確認、 cy.iframe() で対象のiframeの取得がおこなえます

Stripe起因の未キャッチ例外については、
cy.on() を用いて例外メッセージをチェックし、今回見なかったことにしたい例外だった場合のみ無視することにしました🐞
これ以外の例外が発生し、それが自動テスト環境に限られる場合は、無視対象を増やして対応するとよいかも🐝
もっとも、こちらの問題については、私のコードの方に原因があるような気はします...

オマケとして、テストシナリオが元気よく実行される様子を貼っておきます🏋️‍♀️

動いてるからヨシ! はほどほどに😾

参考文献