CypressのE2Eテストが不安定なときのトラブルシュート
概要
CypressでE2Eテストを書いていると、「ローカルでは通るのにCIでは落ちる」「同じテストが実行するたびに成否を変える」という、いわゆるフレーキー(flaky)なテストに悩まされます。本記事では、不安定なE2Eテストの典型的な原因と、それぞれへの実践的な対処法を、再現条件と確認方法つきで整理します。
結論から言うと、Cypressのフレーキーの大半は次の3つに集約されます。
- 固定待機(
cy.wait(ms))に頼っている - 非同期なUI更新やネットワーク完了を待てていない
- テスト間で状態が共有されてしまっている
環境
- 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)を使う - 最後は複数回実行で安定性を実測する
「落ちたらリトライ」で蓋をする前に、まず待ち方を見直すのが近道です。
