CypressのE2Eテストが不安定なときのトラブルシュート

概要

CypressでE2Eテストを書いていると、「ローカルでは通るのにCIでは落ちる」「同じテストが実行するたびに成否を変える」という、いわゆるフレーキー(flaky)なテストに悩まされます。本記事では、不安定なE2Eテストの典型的な原因と、それぞれへの実践的な対処法を、再現条件と確認方法つきで整理します。

結論から言うと、Cypressのフレーキーの大半は次の3つに集約されます。

  1. 固定待機(cy.wait(ms))に頼っている
  2. 非同期なUI更新やネットワーク完了を待てていない
  3. テスト間で状態が共有されてしまっている

環境

  • Cypress 13系(12系でも考え方は同じ)
  • ブラウザ: Chrome / Electron(CIではElectronヘッドレス想定)
  • CI: ヘッドレス・低スペック想定
  • 対象アプリ: 非同期でDOMが更新されるSPA(React / Vue など)

発生した問題

よくある症状は次のとおりです。

  • cy.get('.item').should('have.length', 10) が、ローカルでは通るがCIでは got 7 で落ちる
  • クリック直後の cy.get() で element is detached from the DOM になる
  • ログインが必要なテストが、単体では通るのに他テストと一緒に流すと落ちる
  • 動画を見返すと、要素は正しく表示されているのに失敗している

共通するのは「マシンが速い/遅いで結果が変わる」点です。これはタイミング依存のバグがテストコードに潜んでいるサインです。

原因

原因1: cy.wait(数値) による固定待機

「2秒待てば描画されているだろう」という前提は、CIの負荷が高いときに簡単に崩れます。逆に待ちすぎればテスト全体が遅くなります。

// アンチパターン: マシン速度に結果が依存する
cy.get('#submit').click()
cy.wait(2000)
cy.get('.result').should('be.visible')

原因2: 非同期処理の完了を待たずにアサートしている

Cypressのコマンドはリトライしてくれますが、要素取得とアサーションを分断するとリトライが効かなくなります。

// アンチパターン: 変数に入れた時点のDOMを使い、リトライが効かない
const items = cy.get('.item')
items.should('have.length', 10)

原因3: テスト間の状態共有

前のテストが作ったデータ、残ったログインセッション、localStorageなどが次に漏れて、実行順によって成否が変わります。

対応方法

対応1: ネットワークを cy.intercept で待つ

固定待機を、実際のイベント待ちに置き換えます。

cy.intercept('GET', '/api/items').as('getItems')
cy.get('#submit').click()
cy.wait('@getItems')          // レスポンス完了を確実に待つ
cy.get('.result').should('be.visible')

これで「2秒」という当て推量が消え、速いマシンでも遅いマシンでも同じ挙動になります。

対応2: アサーションをチェーンしてリトライを活かす

Cypressは .should() が通るまで直前のコマンドを自動でリトライします。要素取得とアサーションは必ずチェーンでつなぎます。

cy.get('.item').should('have.length', 10)
cy.get('.list').contains('保存').click()

element is detached from the DOM は、取得した直後に再描画で要素が差し替わるケースです。再取得をチェーンに含めることでリトライ対象になります。

対応3: 状態をテストごとにリセットする

beforeEach(() => {
  cy.session('user', () => {
    cy.request('POST', '/api/login', { email, password })
  })
  cy.clearLocalStorage()
})

ログインはUIを経由せず cy.request で済ませると速く安定します。テストデータはUI操作ではなくAPIシードで用意すると独立性が上がります。

対応4: アニメーション完了を待つ

cy.get('#modal').should('be.visible')
cy.get('#modal-confirm').should('not.be.disabled').click()

実装例

「フォーム送信 → 一覧更新」のフローを安定版に書き直した例です。

describe('アイテム登録', () => {
  beforeEach(() => {
    cy.session('user', () => {
      cy.request('POST', '/api/login', {
        email: 'test@example.com',
        password: 'password',
      })
    })
    cy.clearLocalStorage()
    cy.visit('/items')
  })

  it('登録すると一覧に反映される', () => {
    cy.intercept('POST', '/api/items').as('createItem')
    cy.intercept('GET', '/api/items').as('getItems')

    cy.get('[data-testid="name"]').type('サンプル')
    cy.get('[data-testid="submit"]').click()

    cy.wait('@createItem').its('response.statusCode').should('eq', 201)
    cy.wait('@getItems')

    cy.get('[data-testid="item"]')
      .should('have.length.greaterThan', 0)
      .and('contain', 'サンプル')
  })
})

セレクタに data-testid を使うのもポイントです。クラス名や表示文言に依存すると、スタイル変更や文言変更で容易に壊れます。

確認方法

  • 複数回連続実行: cypress run --spec xxx.cy.js を数回〜十数回回し、落ちないことを確認する(フレーキーは1回では見つからない)
  • CIと同条件: --browser electron のヘッドレス、CPU制限下で再現を試みる
  • 動画・スクリーンショット: 失敗時にUIがどの状態だったかを確認する
  • リトライ設定の切り分け: 調査時は retries を0にして素の安定性を測る
// cypress.config.js
module.exports = {
  retries: { runMode: 2, openMode: 0 },
}

注意点

  • リトライ設定(retries)はフレーキーの“隠蔽”になりうる。応急処置としては有効だが、根本原因を放置すると不安定なまま残る
  • cy.wait(ms) を使うなら「アニメーションの固定時間」など理由が明確なときに限定する
  • data-testid のような安定セレクタを用意しておくと保守コストが下がる
  • CIはローカルより低スペックなことが多い。ローカルで通っても過信しない

まとめ

Cypressのフレーキーは「魔法の設定」で消えるものではなく、何を待つべきかを明示することで解消します。

  • cy.wait(ms) を cy.intercept + cy.wait('@alias') に置き換える
  • 取得とアサーションをチェーンしてリトライを活かす
  • テスト間の状態を cy.session + ストレージ初期化で分離する
  • 安定したセレクタ(data-testid)を使う
  • 最後は複数回実行で安定性を実測する

「落ちたらリトライ」で蓋をする前に、まず待ち方を見直すのが近道です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。