CI/CDパイプラインの実行中に、「コードは何も変えていないのに、時々テストが落ちる」という現象に悩まされたことはないでしょうか。このような、実行するたびに結果が成功(Pass)と失敗(Fail)に揺れ動くテストを「Flaky Test(不安定なテスト)」と呼びます。
Flaky Testを放置すると、開発メンバーがテストの失敗を無視するようになり、本物のバグを見逃す原因になります。この記事では、Flaky Testが発生する主な原因、具体的なコード例を用いた改善手順、および実務で使えるチェックリストを解説します。
対象読者
- CI/CDで自動テスト(特にE2Eテストや統合テスト)を運用している開発者・QAエンジニア
- テストの実行結果が不安定で、デプロイのパイプラインが頻繁に止まる課題を抱えている方
Flaky Testが発生する4つの主な原因
Flaky Testが発生する原因は多岐にわたりますが、実務で遭遇しやすいのは以下の4点です。
-
非同期処理の同期ズレ(タイミング問題)
APIレスポンスの遅延やDOMのレンダリング完了を待たずにアサーションを実行してしまうケースです。 -
テスト間での状態の共有(テストの独立性欠如)
データベースのレコードやグローバルな状態(シングルトン、セッションなど)が、前のテストの実行結果に依存しているケースです。 -
環境依存(ネットワーク・リソース不足)
CIサーバーのCPU/メモリ不足による処理遅延や、外部APIのレートリミットに達することで発生します。 -
非決定的な処理(時間・乱数・順序)
テストコード内で現在時刻(new Date())やランダムな値、ソート順が保証されていない配列などを利用しているケースです。
具体的な改善手順とコード例
1. 非同期処理の同期ズレを解消する
E2Eテスト(PlaywrightやSeleniumなど)で最も多い原因です。固定値の待機(スリープ)を使用すると、環境の負荷によってテストが失敗しやすくなります。
悪い例(固定値での待機)
// 3秒待つ実装。ネットワークが遅い環境では3秒で足りず、テストが失敗する原因になります
await page.click('#submit-button');
await page.waitForTimeout(3000);
expect(await page.textContent('#result')).toBe('Success');
良い例(状態の変化を待つオートウェイティング)
// Playwrightの例:要素が特定のアサーションを満たすまで自動で待機します
await page.click('#submit-button');
// 要素が表示され、指定のテキストになるまで暗黙的に待機(タイムアウト値までリトライ)
await expect(page.locator('#result')).toHaveText('Success');
2. テストデータの独立性を確保する
並行してテストを実行した際、同じデータベースのレコードを更新し合うことでテストが失敗することがあります。
悪い例(固定のIDや共通のデータを使用)
// 共通のテストデータ「user-1」を書き換えるため、並行実行時に競合が発生する
test('ユーザー情報の更新テスト', async () => {
await updateUser({ id: 'user-1', name: 'New Name' });
const user = await getUser('user-1');
expect(user.name).toBe('New Name');
});
良い例(テストごとに一意なデータを生成、またはトランザクションの分離)
// テストごとにユニークなIDやデータを生成して実行する
test('ユーザー情報の更新テスト', async () => {
const uniqueId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await createUser({ id: uniqueId, name: 'Original Name' });
await updateUser({ id: uniqueId, name: 'New Name' });
const user = await getUser(uniqueId);
expect(user.name).toBe('New Name');
// テスト終了後にクリーンアップ
await deleteUser(uniqueId);
});
3. 時間に依存する処理のモック化
「深夜0時に実行すると日付が変わってテストが落ちる」「うるう年に失敗する」といった問題は、テスト実行時のシステム時刻に依存していることが原因です。
悪い例(システム時刻をそのまま利用)
// 実行する日時によって期待値が変わるため不安定になる
test('キャンペーン期間内であることの判定', () => {
const isActive = checkCampaignActive(); // 内部で new Date() を使用
expect(isActive).toBe(true);
});
良い例(テストフレームワークの機能で時刻を固定)
// Jest / Vitest の例:システム時刻を固定する
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-15T10:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('キャンペーン期間内であることの判定', () => {
const isActive = checkCampaignActive();
expect(isActive).toBe(true); // 常に2026年1月15日として実行される
});
実務用:Flaky Test 対策チェックリスト
テストの新規作成時や、不安定なテストのデバッグ時に以下のチェックリストを活用してください。
| チェック項目 | 分類 | 確認内容 | 対応策の例 |
|---|---|---|---|
| 固定の待機時間(Sleep)がないか | 同期処理 |
sleep(3000) や page.waitForTimeout が使われていないか |
要素の出現やAPIレスポンスの完了を待つ実装に変更する |
| テストデータの競合がないか | 独立性 | 複数のテストが同じレコードを編集・削除していないか | テストごとに一意なキーを生成するか、テスト後にロールバックする |
| 実行順序に依存していないか | 独立性 | テストAが成功した後にテストBを実行しないと失敗しないか | 各テストが単体で実行可能な状態にする(beforeEachでの初期化) |
| 時間や日付に依存していないか | 非決定性 |
new Date() やタイムゾーンに依存する処理がないか |
テスト内で時刻をモック(Fake Timers等)する |
| 外部APIに直接依存していないか | 環境要因 | 外部サービスのダウンやレートリミットの影響を受けないか | 外部APIとの通信部分をモック(MSWなどのライブラリ)に置き換える |
| リソース不足が発生していないか | 環境要因 | CI環境のCPU/メモリ使用率が限界に達していないか | 並列実行数を調整する、またはCIマシンのスペックを見直す |
運用上の判断基準と注意点
1. リトライ(Retry)機能の乱用に注意する
多くのテストランナー(Playwright, Cypress, Jestなど)には、失敗したテストを自動で再実行する「リトライ機能」が備わっています。
一時的なネットワークエラーへの対策として有効ですが、根本原因を解決せずにリトライ回数を増やすことは避けてください。 テスト全体の実行時間が延びるだけでなく、本物のバグ(競合状態など)を検知できなくなるリスクがあります。
2. 不安定なテストは一時的に「隔離」する
原因特定に時間がかかる場合は、テストスイート全体をブロックしないよう、一時的にスキップ(test.skip)するか、隔離された別のパイプラインで実行する運用ルールを設けることを推奨します。これにより、CIの信頼性と開発速度の低下を防ぐことができます。
まとめ
Flaky Testの改善は、開発の生産性を維持するために不可欠なプロセスです。まずは「非同期処理の待ち方」と「テストデータの独立性」の2点を見直すだけでも、多くの不安定なテストを改善できます。
※テストフレームワークやライブラリのバージョンによって、時刻のモック化やオートウェイティングの仕様が異なる場合があります。実装の際は、利用しているツールの最新の公式ドキュメントを確認してください。