
こんにちは、kickflow QAチームの川村です。
今回は、E2Eテストの実行時間を短縮するために、Cypressのテスト並列実行基盤を自作した話をします。
E2Eテストはプロダクトの品質を担保する上で非常に重要ですが、テストケースが増えるにつれて実行時間が長くなり、開発サイクルを遅延させる一因になりがちです。
私たちのチームでもこの課題に直面し、自動テストの実行時間短縮に取り組みました。
この記事では、Cypress Cloud公式のParallelization機能を使わずに、Node.jsを用いて独自の並列実行システムを構築し、テスト実行時間を1並列の約40分から8並列の約10分へと74%短縮した方法とその過程で得られた知見について詳しく解説します。
なぜ自作したのか
Cypressには、テストの並列実行や結果の可視化を容易にするCypress Cloudという優れた公式サービスがあります。
また、cypress-parallelというOSSのパッケージも存在します。
しかし、私たちのチームでは以下の理由から、あえて独自の並列実行基盤を構築することにしました。
Cypress Cloudを使わなかった理由
ローカル環境での並列実行の必要性
Cypress Cloudの並列実行機能は、--recordフラグとCypress Cloudへの接続が必須です。
つまり、ローカル環境での開発中やCypress Cloudを使わないCI環境では、公式の並列実行機能が利用できません。
開発効率向上のため、ローカル環境でも手軽に並列実行できる仕組みが必要でした。--recordなしでの並列実行の実現
Cypress Cloudの並列実行機能自体は全てのプランで利用可能ですが、必ず--recordフラグを使用してテスト結果をCypress Cloudにアップロードする必要があります。
私たちは、テスト結果の記録が不要な場合でも並列実行のメリットを享受したいと考え、recordなしで動作する仕組みを構築しました。
cypress-parallelパッケージを使わなかった理由
実は最初、cypress-parallelパッケージの利用を検討しましたが、以下の問題に直面しました。
ディレクトリ構造の非互換性
- cypress-parallelはデフォルトで
cypress/integrationディレクトリを探す - 私たちのプロジェクトは
cypress/e2eディレクトリを使用(Cypress v10以降の標準) -dオプションで指定しても、内部処理が正しく動作しなかった
- cypress-parallelはデフォルトで
コマンド実行の問題
error Command "yarn cy:base run --browser chrome" not found.- cypress-parallelは内部でコマンドを文字列として解釈
- yarnスクリプトのエイリアスを正しく解決できなかった
- 直接
cypress runを指定しても、各スレッドでのコマンド実行に失敗
レポート統合の制限
- cypress-parallelは独自のレポート形式を使用
- mochawesomeレポーターとの統合が困難
- HTMLレポートの生成とSlack送信機能の追加が複雑
独自実装による解決
これらの課題を踏まえ、プロジェクトの要件に完全に適合した並列実行システムを独自に実装することにしました。
実装の概要
今回構築した並列実行システムのアーキテクチャは、主に以下の4つの要素で構成されています。
テストファイルの分割
glob パターンを用いて対象のテストファイルをすべて収集し、指定された並列数(スレッド数)で均等にグループ分けします。並列プロセスの管理
Node.js のchild_process.spawnを利用して、分割されたテストグループごとに複数のCypressプロセスを同時に起動・管理します。レポートの統合
各並列プロセスで生成されたMochawesomeのJSONレポートを、テスト完了後に一つに統合します。
これにより、すべてのテスト結果を一覧できる単一のHTMLレポートを生成します。Slack通知
テスト完了後、Puppeteerでレポート画面のスクリーンショットを撮影し、テスト結果の概要とともに自動的にSlackへ送信します。
並列実行の流れ(シーケンス図)
以下は、並列実行の処理の流れを示すシーケンス図です。
sequenceDiagram
participant ParallelRunner
participant Glob
participant CypressProcess1
participant CypressProcessN
participant Mochawesome
ParallelRunner->>ParallelRunner: 環境変数読み込み (.env)
ParallelRunner->>Glob: テストファイル一覧取得
Glob-->>ParallelRunner: テストファイル配列
ParallelRunner->>ParallelRunner: ファイルを8つのチャンクに分割
par 並列実行
ParallelRunner->>CypressProcess1: spawn(cypress run --spec chunk1)
ParallelRunner->>CypressProcessN: spawn(cypress run --spec chunkN)
CypressProcess1->>CypressProcess1: テスト実行
CypressProcessN->>CypressProcessN: テスト実行
CypressProcess1->>Mochawesome: thread-0-report.json
CypressProcessN->>Mochawesome: thread-N-report.json
end
CypressProcess1-->>ParallelRunner: exit(code)
CypressProcessN-->>ParallelRunner: exit(code)
ParallelRunner->>Mochawesome: mochawesome-merge
Mochawesome-->>ParallelRunner: merged-report.json
ParallelRunner->>Mochawesome: marge (HTML生成)
Mochawesome-->>ParallelRunner: index.html
主要な実装コード
並列実行を担うスクリプト(scripts/parallel-runner.js)の主要部分を以下に示します。
// scripts/parallel-runner.js の主要部分 const { spawn } = require('child_process'); const glob = require('glob'); // スクリプトの引数から並列数、テストファイルのパターン、ブラウザを取得 const threads = parseInt(process.argv[2], 10); const pattern = process.argv[3]; const browser = process.argv[4]; // globでテストファイルの一覧を取得 const testFiles = glob.sync(pattern); // テストファイルを並列数に応じて分割(チャンク化) const chunks = []; const chunkSize = Math.ceil(testFiles.length / threads); for (let i = 0; i < threads; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, testFiles.length); if (start < testFiles.length) { chunks.push(testFiles.slice(start, end)); } } const processes = []; const mochawesomeDir = 'mochawesome-report'; // 分割したチャンクごとにCypressプロセスを起動 chunks.forEach((chunk, index) => { // Cypressが受け取れるように、ファイルパスをカンマ区切りの文字列に変換 const specPattern = chunk.join(','); const cypressProcess = spawn('npx', [ 'cypress', 'run', '--browser', browser, '--spec', specPattern, '--reporter', 'mochawesome', '--reporter-options', // 各スレッドのレポートが上書きされないように、ファイル名を一意にする `reportDir=${mochawesomeDir},reportFilename=thread-${index}-[name],html=false,json=true` ], { stdio: 'inherit', // 親プロセスの標準入出力を共有し、CIログに表示 env: { ...process.env, FORCE_COLOR: '1' } // ログに色を付ける }); processes.push(cypressProcess); }); // 全てのプロセスの完了を待つ Promise.all(processes.map(p => new Promise(resolve => p.on('close', resolve)))) .then(async (codes) => { console.log('All Cypress processes have finished.'); // ここでレポートのマージやSlack通知の処理を呼び出す });
独自実装のメリット
自作することで、以下のような利点を得ることができました。
完全な制御
- テストファイルの分割ロジックをカスタマイズ可能
- 各スレッドのプロセス管理を細かく制御
- エラーハンドリングと終了コードの適切な管理
レポート機能の統合
- mochawesomeレポーターを直接使用
- JSONレポートのマージとHTML生成を自動化
- Slack送信機能をシームレスに統合
環境変数の管理
- dotenvを使用した環境変数の読み込み
- 既存のプロジェクト構造との整合性
- 環境ごとの設定ファイルの切り替え
工夫した3つのポイント
ただ並列で実行するだけでなく、開発体験(DX)を向上させるためにいくつかの工夫を凝らしました。
1. 実行環境に応じた最適な並列数の決定
当初は4並列でCIを実行していましたが、より高速化を目指して検証を行いました。 QAで主に使用しているM2 Pro搭載のMacBook Pro(12コア, 32GB RAM)のスペックを考慮し、段階的に並列数を増やして測定しました。
実行時間の測定結果:
- 1並列実行時: 2322秒(約39分)
- 2並列実行時: 1358秒(約23分) - 42%短縮
- 4並列実行時: 729秒(約12分) - 69%短縮
- 8並列実行時: 599秒(約10分) - 74%短縮!
4並列までは期待通りに実行時間が短縮されましたが、4→8並列では短縮幅が小さくなっています(約2分の短縮)。
これは、並列数が増えるほどCypressの起動オーバーヘッドやリソース競合の影響が大きくなるためです。
それでも、8並列にすることで全体として74%の時間短縮を達成でき、開発効率の大幅な向上を実現しました。
package.json のnpm-scriptsは以下のようになっています。
引数で並列数を簡単に変更できるようにしています。
"scripts": {
"local:sand:parallel": "cross-env NODE_ENV=sand node scripts/parallel-runner.js 8 'cypress/e2e/**/*.cy.ts' chrome"
}
これにより、ローカル環境でもCI環境でも、マシンスペックを最大限に活用した高速なテスト実行が可能になりました。
2. 失敗したテストだけをハイライトするレポーティング
テストが失敗した際、大量の成功テストの中から失敗箇所を探すのは非常に手間がかかります。 そこで、MochawesomeレポートをPuppeteerで開き、失敗したテストスイートのみを表示するフィルタリング機能を実装しました。
具体的には、PuppeteerでレポートのHTMLを操作し、失敗(has-failed クラスを持つ)していないスイートをCSSで非表示にしています。
// レポートを加工する処理の一部 if (onlyFailed) { await page.evaluate(() => { // 'has-failed' クラスを持たないスイートを非表示にする const suites = document.querySelectorAll('section[class*="suite--component"]'); suites.forEach(suite => { if (!suite.classList.contains('has-failed')) { suite.style.display = 'none'; } else { // 失敗したスイート内でも、成功したテストは非表示にする const passedTests = suite.querySelectorAll('[class*="test--passed"]'); passedTests.forEach(test => { test.style.display = 'none'; }); } }); }); }
この機能のおかげで、テストが失敗した際に原因を特定するまでの時間が劇的に短縮されました。
3. ノイズを減らすためのSlack通知の改善
テスト結果のスクリーンショットをSlackに通知する際、毎回新しいメッセージとして投稿するとチャンネルがすぐに埋まってしまいます。 これを防ぐため、SlackのWeb APIを使い、まず結果概要を投稿し、詳細なレポートのスクリーンショットはそのメッセージへのスレッドリプライとして送信するようにしました。
// Slackに通知する処理の一部 (抜粋) const { WebClient } = require('@slack/web-api'); const fs = require('fs'); const web = new WebClient(process.env.SLACK_BOT_TOKEN); // 最初にサマリーを投稿 const result = await web.chat.postMessage({ channel: '#your-channel-id', text: 'Cypress test run finished. See thread for details.', }); // 撮影したスクリーンショットをスレッドリプライとしてアップロード const uploadResult = await web.files.uploadV2({ channel_id: '#your-channel-id', file: fs.readFileSync(imagePath), filename: `cypress-report-${new Date().toISOString()}.png`, title: 'Detailed Test Report', initial_comment: onlyFailed ? 'Failed tests report screenshot' : 'Full test report screenshot', thread_ts: result.ts // メインメッセージのタイムスタンプを指定 });
これにより、チャンネルの可読性を保ちつつ、必要な情報を確実にチームへ共有できるようになりました。

得られた成果
この取り組みによって、主に2つの大きな成果を得ることができました。
実行時間の大幅な短縮
1並列での約39分(2322秒)の実行時間を、8並列で約10分(599秒)まで短縮。
74%の時間削減を実現しました。開発体験(DX)の向上
失敗したテストのみをフィルタリング表示するレポート機能により、デバッグ効率が大幅に向上し、開発者のストレスを軽減できました。
また、Slack通知の改善により、テスト結果の確認がより素早く行えるようになりました。
今後の展望
この並列実行基盤は、今後さらに進化させていきたいと考えています。
- 動的な並列数の調整: CIを実行するマシンのスペック(CPUコア数など)に応じて、最適な並列数を自動で決定する仕組み。
- 実行時間に基づいたインテリジェントなテスト分割: 各テストの過去の実行時間履歴を基に、全スレッドの実行時間が均等になるようにテストを分割する、より賢い負荷分散ロジック。
まとめ
今回は、Node.jsを使ってCypressのテスト並列実行基盤を自作し、テストの高速化と開発体験の向上を実現した事例をご紹介しました。
特に、失敗箇所のフィルタリング機能やSlack通知の工夫は、日々の開発サイクルに良い影響を与えています。
今回ご紹介した実装は、特定のサービスに依存しないオープンな技術の組み合わせで実現可能ですので、同様の課題を抱えているプロジェクトの参考になれば幸いです。
kickflowのQAチームでは、テスト自動化の効率化だけでなく、品質保証プロセス全体の改善に取り組んでいます。
テストの高速化・安定化を通じてプロダクト開発の生産性向上に貢献し、品質と開発速度の両立を実現することがQAチームの重要なミッションだと考えています。
品質保証の技術的な課題解決に興味がある方、一緒にkickflowの品質を支えていただける方を募集しています。 ぜひ採用サイトをご覧ください!