CI が赤くなって Slack に通知が飛んでくる。ログを開いてみると、見覚えのあるテストが落ちています。Retry を押すと通る。「またあのテストか」と思いながら閉じる ── この繰り返しに心当たりはないでしょうか。
flaky test (不安定テスト) は、放置するとチーム全体の CI への信頼を蝕みます。「どうせ再実行で通る」という空気が生まれた瞬間、本当に直すべきリグレッションも「flaky だろう」で片付けられはじめます。私は過去に、リリース直前にこの空気のまま本番デプロイしてしまい、ユーザー報告で初めて気づくというトラブルを経験しました。それ以来、flaky test は「あとで直す」ではなく「最優先で原因を特定する」ものとして扱っています。
Claude Code を使って flaky test を体系的に潰していく実践的な手順を順番にご紹介します。一発で原因が分かる魔法のコマンドは存在しませんが、原因の分類さえできれば、Claude Code はかなり強力な相棒になってくれます。
flaky test の正体は「実行順序・タイミング・状態」のいずれか
まず大事なのは、flaky test は単一の現象ではないという認識です。「たまに落ちるテスト」と一括りにしても、原因はおおむね次の3カテゴリに分類できます。
実行順序依存: テスト A の後に B を実行すると落ちるが、B 単体では通る。グローバル状態 (DB、シングルトン、環境変数) のリセット漏れが典型です。
タイミング依存: 非同期処理やアニメーション、ネットワーク待機の不安定さ。setTimeout(500) で待つようなコードは、CI 環境のロード次第で確実に裏切ります。
外部状態依存: 現在時刻、乱数、外部 API、ファイルシステムなど、実行ごとに変わる要素を直接参照していること。
修正の方針はカテゴリごとにまったく違います。だからこそ、Claude Code に丸投げする前に「どのカテゴリか」を判別するのが第一歩です。
Claude Code への最初の依頼は「分類」
私は flaky test を見つけたら、まず Claude Code にこう依頼します。
このテストファイルを読んで、以下の3カテゴリのどれに該当する可能性が高いか判定してください。
1. 実行順序依存 (グローバル状態の汚染)
2. タイミング依存 (非同期/待機処理)
3. 外部状態依存 (時刻/乱数/API/ファイル)
該当箇所を行番号付きで指摘し、根拠も簡潔に説明してください。
複数該当する場合は優先度の高い順に。
target: tests/integration/payment.test.tsポイントは「修正してください」ではなく「分類してください」と頼むこと。最初から修正を依頼すると、Claude Code は最も目立つ候補に対して fix を提案してきます。それが本当の原因とは限りません。flaky test の修正で最も避けたいのは「症状を抑えるだけのコード」を入れて、再発時に原因究明が困難になることです。
ステップ1: 再現スクリプトを Claude Code に書かせる
分類ができたら、次は再現性の確保です。flaky test を直す前に「100% 再現する手段」を手に入れることが、地味ですが最大の前進になります。
カテゴリ別に効くアプローチを紹介します。
実行順序依存の場合: テストの実行順をシャッフルして、複数回ループさせる。
# Vitest の例
npx vitest run --sequence.shuffle --sequence.seed=12345
# Jest の例
jest --testSequencer=./scripts/random-sequencer.jsClaude Code に「Vitest のテストをランダム順で 50 回実行して、失敗したシードと失敗テスト名を収集するスクリプトを書いて」と頼むと、次のような bash スクリプトを返してきます。
#!/usr/bin/env bash
# scripts/find-flaky.sh — 実行順依存の flaky test を炙り出す
set -uo pipefail
ITERATIONS="${1:-50}"
LOG_DIR="./.flaky-logs"
mkdir -p "$LOG_DIR"
> "$LOG_DIR/failures.tsv"
echo -e "iter\tseed\tfailed_test" > "$LOG_DIR/failures.tsv"
for i in $(seq 1 "$ITERATIONS"); do
SEED=$RANDOM
echo "▶ iteration $i (seed=$SEED)"
if ! OUTPUT=$(npx vitest run --sequence.shuffle --sequence.seed="$SEED" --reporter=json 2>&1); then
# 失敗テスト名を JSON から抽出 (jq が必要)
FAILED=$(echo "$OUTPUT" | jq -r '.testResults[].assertionResults[] | select(.status=="failed") | .fullName' 2>/dev/null | tr '\n' ',')
echo -e "${i}\t${SEED}\t${FAILED}" >> "$LOG_DIR/failures.tsv"
fi
done
echo ""
echo "=== 失敗ログ ==="
cat "$LOG_DIR/failures.tsv"期待する出力は次のようになります。
=== 失敗ログ ===
iter seed failed_test
3 8421 integration > payment > should reject expired card,
12 19284 integration > payment > should reject expired card,
27 4477 integration > payment > should reject expired card,同じテストが特定のシードでだけ落ちていれば、ほぼ確実に実行順依存です。逆にランダムに別テストが落ちていれば、共有リソースが汚染されている可能性が高い。
タイミング依存の場合: 同じテストを連続で実行して落ちる頻度を測ります。シャッフルは不要で、--repeat=100 のような繰り返しオプションを使うか、bash で単純に 100 回ループさせる方が早いです。
外部状態依存の場合: テスト実行時刻を変えたり、ネットワークを切ったりして、外部依存ごとに切り分けます。
ステップ2: 「正しい修正」と「ごまかしの修正」を区別する
再現できたら修正に入ります。ここで Claude Code に「直して」と頼むと、たいてい以下のいずれかの提案が返ってきます。
- リトライロジックを追加する (
expect(...).toEventuallyEqual(...)的なもの) setTimeoutで待機時間を延ばす- そのテストだけ
it.skipする
これらはすべて症状を隠す修正であって、原因を直す修正ではありません。私は Claude Code に対して、次のような明示的な制約を伝えます。
flaky の根本原因を直してください。以下のアプローチは禁止です。
- 待機時間を延ばす (sleep, setTimeout, waitFor の timeout 増加)
- リトライ回数を増やす
- テストをスキップする
- try/catch で失敗を握りつぶす
代わりに、以下のいずれかで修正してください。
A. テスト間で共有されている状態を beforeEach/afterEach で確実にリセットする
B. 非同期処理の完了を、時間ではなくイベント/状態で待つ
C. 外部依存を時刻固定 / モック / シードで決定論的にするこの制約をかけるだけで、提案の質が体感的にかなり変わります。とくに「時間ではなくイベントで待つ」という指示は、Playwright や Testing Library 系のテストで効果が大きいです。
よくある修正パターンの Before/After
Before (タイミング依存・脆弱):
// アニメーション完了を時間で待つ
await page.click("#open-modal");
await new Promise((r) => setTimeout(r, 500)); // ← 500ms で足りる保証はない
expect(await page.isVisible("#modal-content")).toBe(true);After (状態で待つ・堅牢):
// モーダルが「実際に表示されるまで」待つ
await page.click("#open-modal");
await page.waitForSelector("#modal-content[data-state='open']", {
state: "visible",
timeout: 5000, // 上限ガードのみ
});
expect(await page.isVisible("#modal-content")).toBe(true);waitForSelector の timeout は「最大何秒待つか」のガードであって、毎回その時間待つわけではありません。状態が満たされた瞬間に進むので、CI の負荷が高い日にも強くなります。
Before (外部状態依存・乱数):
test("ID が一意に発行される", () => {
const id1 = generateId();
const id2 = generateId();
expect(id1).not.toBe(id2); // たまたま衝突したら落ちる
});After (シード固定で決定論):
import { mockRandom } from "./helpers/mock-random";
test("連続生成しても ID が衝突しない (seeded)", () => {
const restore = mockRandom([0.1, 0.2, 0.3]); // 決定論的な値を返す
try {
const ids = Array.from({ length: 100 }, generateId);
expect(new Set(ids).size).toBe(100);
} finally {
restore();
}
});「衝突しない」という性質を確認したいなら、ランダム性そのものは別の責務として切り出して、テストでは決定論的に振る舞わせる ── という設計が王道です。
ステップ3: 修正を「再発しない仕組み」に昇華する
個別のテストを直しても、同じパターンの flaky が後から増えてしまっては意味がありません。私はチームで運用するうえで、次の3つを最低限のセットとして導入することにしています。
1. CI でランダム順実行を常設する: 毎回固定順で通っていても、ランダム順で初めて落ちる依存はよくあります。最初は「壊れたテストを毎日見せられて辛い」と感じますが、それこそが今まで見えていなかった負債なので、避けずに直していきます。
2. 待機系のヘルパーをラップする: チームのコードベースで setTimeout が直接使われているテストを、Claude Code に検出させます。
tests/ 配下のすべてのファイルから、以下のパターンを抽出してください。
- new Promise(resolve => setTimeout(resolve, ...))
- await sleep(...)
- await new Promise((r) => setTimeout(r, ...))
ファイルパスと行番号、待機ミリ秒数を表形式で。返ってきた一覧をもとに、waitForCondition のようなチーム共通ヘルパーへ徐々に置き換えていきます。Claude Code は機械的な置換に強いので、こうした「広く浅い掃除」がとても捗ります。
3. flaky 履歴を可視化する: 一度修正した flaky test がまた落ちはじめたら、それは別の原因のサインです。CI のテスト結果を集計するダッシュボードを用意するか、最低限「直近30日でリトライされた回数が多い順にリスト化する」だけでも、再発の早期発見につながります。
一緒に押さえておきたい記事
- Claude Code TDD ガイド
- Claude Code で Vitest / Jest のユニットテスト自動化
- Claude Code 大規模リファクタリングを安全に進めるコミット粒度
脆いテストの設計上の根本原因が章を追って整理されていて、Claude Code への依頼文を書くときの語彙も増えます。
次の一歩
明日 CI を見たときに「またあのテストか」と思ったら、リトライボタンを押す前に Claude Code を開いて、まず3カテゴリのどれに該当するか分類だけ依頼してみてください。修正よりも、「この flaky はタイミング依存だ」と言語化できることのほうが、実は大きな前進です。そこから先は、この記事で紹介した制約 (待機・リトライ・スキップを禁じる) をかけながら、根本原因に手を入れていけば十分です。