は、コードを変えていないのに成功したり失敗したりするテストです。実害がないように見えるため、再実行して通ったら次に進むという扱いになりがちです。
しかしこれは、テスト全体にとって最も危険な状態を作ります。この回は、なぜそう言えるのかと、検知・隔離・修正をどう運用するかを扱います。
1. 放置のコストは「信用」で払う
不安定なテストが1本あるとき、失われるのはその1本の検知力だけではありません。赤を見たときの反応が変わります。
- 1赤が出る。「またあれか」と考える
- 2再実行する。通る。次へ進む
- 3この流れが習慣になる
- 4本物の失敗も、同じ流れで通過する
4番目が本質的な損害です。テストの価値は「壊れたら止まる」ことにありますが、止まらなくなります。しかも、この変化は静かに進みます。誰も設定を変えていないのに、実効的な検知力だけが落ちていきます。
この構造は、EP.6 で扱った の形骸化とよく似ています。どちらも「たいていは問題ない」という正しい推論が習慣化し、例外を見落とすという形です。人の注意力に依存した運用は、正しい判断が続くほど危うくなる ── というのが共通の教訓になります。
不安定だからと一時的に無効化したテストは、戻された例がほとんどありません。期限と担当を決めずに外すのは、実質的に削除です。削除するなら削除だと認識したうえで判断してください。
2. 原因はほぼ5種類に収まる
不安定さの原因は多様に見えて、実際には限られた型に収まります。原因の型が分かれば対処も決まります。
| 原因 | 典型的な症状 | 対処 |
|---|---|---|
| 待ち方 | 環境が遅いときだけ落ちる | 固定待ちをやめ、条件が満たされるまで待つ |
| 実行順序 | 単体では通るが全体では落ちる | テスト間で状態を共有しない |
| 共有資源 | 並列実行にすると落ちる | データ・ポート・ファイルを分離する |
| 時刻依存 | 特定の時間帯や日付で落ちる | 現在時刻を差し替え可能にする |
| 外部通信 | 不定期に落ちる | 境界を に置き換える |
「時刻依存」は見つけにくい部類です。月末、年度替わり、うるう日、タイムゾーンの境界。普段は通り、特定の日だけ落ちるため、再現に時間がかかります。日付や時刻の処理でつまずいた事例は データ基盤トラブル事件簿 EP.10「タイムゾーン地獄」 にまとめてあります。
from datetime import datetime, timezone
# 悪い例: 内部で現在時刻を取るため、実行した瞬間で結果が変わるdef is_expired_bad(contract): return contract.end_at < datetime.now(timezone.utc)
# 良い例: 基準時刻を外から渡せるdef is_expired(contract, now=None): now = now or datetime.now(timezone.utc) return contract.end_at < now
def test_expiry_boundary(): base = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc) contract = Contract(end_at=base) # 境界の前後を明示的に確認できる。実行日時に左右されない assert is_expired(contract, now=base) is False assert is_expired(contract, now=base.replace(second=1)) is True3. まず検知する
対処の前に、どのテストが不安定なのかを把握する必要があります。体感で語ると、印象に残ったものだけが挙がります。実行結果を蓄積して、同じコードで結果が割れたテストを機械的に拾います。
from collections import defaultdict
# (commit, test_id) -> 結果の集合seen = defaultdict(set)
for run in load_test_results(): # 蓄積した実行結果 for case in run["cases"]: seen[(run["commit"], case["id"])].add(case["status"])
# 同じコミットで pass と fail の両方があれば不安定flaky = defaultdict(int)for (commit, test_id), statuses in seen.items(): if {"pass", "fail"} <= statuses: flaky[test_id] += 1
for test_id, count in sorted(flaky.items(), key=lambda x: -x[1]): print(f"{count:3d} 回ぶれた {test_id}")同一コミットという条件が要点です。コードが変われば結果が変わるのは当然なので、それを除外しないと本物の失敗まで混ざります。この集計を定期的に出すだけで、議論が印象論から離れます。
4. 隔離する(放置しない)
原因の特定に時間がかかる場合、本流から隔離します。ただし前述のとおり、隔離と放置は違います。隔離には条件が要ります。
- 期限を決める — いつまでに判断するかを書く
- 担当を決める — 全員の担当は誰の担当でもない
- 何を検証していたかを記録する — 復帰時にも、削除判断時にも要る
- 隔離枠の数を見る — 増え続けているなら、根本に共通原因がある
4番目が有効な指標になります。隔離されたテストの本数が単調に増えているなら、個別の問題ではなく、テスト基盤か設計に共通の原因があります。1本ずつ追うより、共通原因を潰すほうが速いという判断ができます。
不安定なテストを自動で再実行する仕組みは、再実行したことが記録されるなら有用です。記録がないと、不安定さが見えないまま蓄積します。再実行を許すなら、何回目で通ったかを必ず残す構成にしてください。
5. 直すか、書き直すか、消すか
原因が分かったら、3つの選択肢から選びます。直すのが基本ですが、他の2つも正当な選択です。
| 選択 | 選ぶ条件 | 注意点 |
|---|---|---|
| 直す | 原因が5類型のどれかに当てはまる | 対処は定型。多くはここで済む |
| 書き直す | 検証したいことは正しいが、層が合っていない | で見ていたものを下の層へ降ろす |
| 消す | 同じ内容が他でも検証されている | 何を失うかを確認してから |
「書き直す」が見落とされがちです。不安定さの原因が層の選択にあるケースは多く、画面越しに検証していたロジックを下の層へ降ろすだけで、速く安定します。EP.4 の線引きがそのまま効いてきます。
6. 予防としての設計
最後に予防の話をします。EP.3 で「並列化の準備は フレーキーテスト 対策と重なる」と書きましたが、逆も言えます。最初から独立して書いておけば、不安定にならない。
- 時刻・乱数は外から渡す — 内部で取得しない
- テストごとにデータを分離する — 共有すると順序に依存する
- 固定の資源名を使わない — ポート・ファイル名は動的に決める
- 待つときは条件で待つ — 時間で待たない
- 外部通信は境界で止める — ネットワークの不調がテストの不調になる
この5項目は、AI にテストを書かせるときの指示にもそのまま使えます。EP.2 で扱った「単位と差し替え可能な境界を明示する」に、この観点を足しておくと、不安定なテストが生まれる確率を下げられます。
逆に言えば、これらを指示しないまま量産すると、不安定なテストも同じ速度で量産されます。書く速度が上がったこと自体は良いことですが、不安定さの生産速度も一緒に上がっているという点は意識しておく価値があります。 の形を保つことと同じで、放っておくと崩れる方向に力が働きます。
不安定さのコストは信用で支払われる。原因は5類型にほぼ収まる。まず同一コミットでの結果の割れを機械的に検知し、隔離するなら期限と担当を決める。隔離枠が増え続けるなら共通原因を疑う。次回は、ここまでの内容をアンチパターンとして整理します。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。