フレーキーテストの正体と向き合い方 — ReRunで凌いでいた自分がいま考えていること

に公開

はじめに

本記事は、テストコードとAIをめぐるシリーズの7本目です。前作「テスト名は仕様の1行 — AIに命名を任せられる環境を作るまで」と同じく、シリーズ本編から少し離れた単発記事として書いています。

テーマはフレーキーテスト(flaky test)です。
同じコードに対して、CIで落ちたり通ったりする不安定なテストのことです。私自身、過去に何度も格闘してきて、結局根本対処できずに「ReRunで凌ぐ」というやり方で逃げ続けた経験があります。
本記事は、その苦い体験を一つ深掘りしながら、いまならどう向き合うかを構想として書き残す試みです。

想定読者として念頭に置いているのは、2〜7年目あたり、チームでテストを運用し始めて、CIの不安定さに頭を悩ませている方々です。一度でも「あれ、また同じテストが落ちた、ReRunすれば通るんだけど」と呟いたことがあれば、本記事の入口に立っています。

なお、第4章で書く対処は現時点で構想の段階であり、実際にチームで試して効果が出たというものではありません。これから試したいことを言語化した記録、と捉えてもらえればうれしいです。


第1章: ReRunでパスを待っていた頃

数年前のプロジェクトで、PHPUnitとLaravelのシーダーを使ったDB連携テストを運用していた時期があります。CIでテストを回すたびに、特定のテストが落ちる。落ちたテストをローカルで動かすと通る。CIで再実行すると通ることもあれば、また落ちることもある。原因がよく分からないまま、「ReRunすればパスするんだから」とCIのリトライで凌ぎ続けていた時期が、私には確かにありました。

当時の自分は、その振る舞いを「フレーキーテスト」と呼ぶことすら知りませんでした。「DBが絡むテストはたまに落ちるもの」「CIの環境のせいだろう」と片付けて、根本原因を追い切れない状態が続きました。テストが落ちるたびに、PRのCheck欄が赤くなる。マージ予定だったPRが止まる。仕方なくReRunを押す。ReRunで通れば、何事もなかったように次の作業に戻る。

その頃の私の手の中には、フレーキーテストを根本から解消するだけの引き出しがありませんでした。原因が完全には特定できていない、対処法もよく分からない、他のタスクの優先度が高くて根本原因を追う時間も取れなかった、というジレンマの中で、ReRunが一番手軽な逃げ道だったのです。

いまになって振り返ると、あの頃の自分が見逃していたものがいくつもあります。本記事ではそれを、過去の自分に向けて整理する形で書いていきます。


第2章: なぜシーダー由来のテストが揺れるのか

私が苦しんでいたフレーキーテストの多くは、Laravelのシーダーで投入されるデータに依存していました。シーダーは、開発・テスト用にDBへ初期データを流し込む仕組みです。チーム内で共有されるシーダーファイルにデータを定義し、テストの前に一括投入する、という運用は珍しくない構造だと思います。

この構造が、フレーキーテストの温床になります。シーダーはテストの外側で管理されているため、テスト本体を読んでも、何のデータが入っているのかが見えません。たとえば次のようなテストがあるとします。

public function test_管理者ユーザーは全件のレコードを取得できる(): void
{
    $admin = User::where('role', 'admin')->first();
    $this->actingAs($admin);

    $response = $this->getJson('/api/items');

    $response->assertStatus(200);
    $response->assertJsonCount(10);
}

このテストは、シーダーで「管理者ロールのユーザーが少なくとも1人いる」ことと、「itemsテーブルに10件のレコードがある」ことを暗黙の前提にしています。User::where(...)->first() という書き方そのものが、シーダーで管理者が用意されていることをテストの外側に依存させている格好です。テスト本体には、その前提がどこにも書かれていません。シーダーを覗きに行って初めて、依存していることが分かる構造です。

シーダーが揺れる要因はいくつかあります。

  • チームメンバーがシーダーを編集する: 別の開発のためにテストデータを追加・変更したとき、既存テストの前提が崩れる
  • シーダーの実行順序が変わる: 複数のシーダーが連動している場合、順序の変化で生成されるIDや紐付けがズレる
  • refreshDatabase などの仕組みとの組み合わせ: テスト間でDB状態がリセットされるかどうかで、シーダーの再投入タイミングが変わる
  • テスト実行の並列度: 並列実行時のトランザクション競合や、テスト間のデータ干渉

これらが合わさると、同じコードでも、走らせる環境やタイミングによって結果が変わる状態が生まれます。ローカルで通るのにCIで落ちる、特定の他のテストと同時に走ったときだけ落ちる。これらの背景にあるのは、テスト外で管理されている前提(シーダーのようにテスト本体の外側に置かれているデータ定義)の不安定さです。


第3章: ReRunで凌いだことの代償

ReRunでパスするまで再実行する運用は、短期的には開発を止めない手段として機能します。一方で、続けるうちにじわじわと代償を払うことになります。私自身が払った代償を、3つだけ書いておきます。

一つ目は、テスト全体の信頼性が下がること。「このテストは、たまに落ちるけど気にしなくていい」という見方がチームに馴染むと、本物のバグでテストが落ちたときも「またフレーキーかな」と疑う癖がつきます。CIが赤くても誰も慌てない状態は、テストがシグナルとして機能しなくなった、と言い換えられます。

二つ目は、CIの意味が薄れること。CIは本来、マージ前にこの変更で何かが壊れていないかを機械的に確かめる仕組みです。ReRunでパスを待つということはその確認を「とりあえず通るまでガチャを引く」行為に変えてしまいます。緑のチェックが付いても、それは変更が壊していないことの保証ではなく、ガチャに当たった記録になってしまいます。

三つ目は、根本対処の機会を失うこと。フレーキーテストが落ちた瞬間が、本来は「テストの構造に問題があるかもしれない」と気付ける貴重なタイミングです。ReRunで凌いでしまうと、その問題は次にもっと痛い形で表に出てくるまで放置されることでしょう。

フレーキーテストはノイズではなくシグナルだというのが本記事で一番伝えたいことです。テストの設計、テストデータの設計、並行性、環境依存。何かしらの問題を可視化してくれているのに、ReRunはそのシグナルにフタをする行為だったのだと、いまは見えています。


第4章: いまならどう向き合いたいか

冒頭でも書いた通り、ここから先は構想の段階の話です。実際にチームで試して効果が出ているわけではなく、過去の自分の状況に向き合い直すならこう動くだろう、という思考実験です。

3つの構想を、根本対処・運用上の工夫・予防の3レイヤーで並べておきます。

最初にやりたいのは、テストデータの所在を、シーダーからテスト内に寄せることです。ファクトリ機能(LaravelであればModel Factory)を使い、テストごとに必要なデータをそのテストの中で生成する形に変える。

public function test_管理者ユーザーは全件のレコードを取得できる(): void
{
    // Given: 管理者ユーザーと10件のレコードを用意する
    $admin = User::factory()->admin()->create();
    Item::factory()->count(10)->create();
    $this->actingAs($admin);

    // When: 一覧APIを呼び出す
    $response = $this->getJson('/api/items');

    // Then: 200で、10件返る
    $response->assertStatus(200);
    $response->assertJsonCount(10);
}

シーダーに依存していたときは、テスト本体を読んでも前提が見えませんでした。テスト内でファクトリから生成する形にすると、何の前提でこのテストが成り立っているかがコードから読み取れます。チームの誰かがシーダーを編集しても、このテストは影響を受けません。

もちろん、既存テストを一気に書き換えるのは現実的ではないはずです。新規追加するテストや、フレーキーで落ちたタイミングで触ったテストから段階的に置き換えていく、くらいが現場で動かしやすい順序になりそうだと考えています。

次にやりたいのは、フレーキーテストを発見したら、ReRunを押す前に短くメモを残す習慣です。落ちたテスト名、落ちた状況、直前の他のテスト、CIの環境、並列度。これらを1行ずつでもチームのチケットやIssueに積む運用にしておくと、気のせいで流れていた揺れの傾向が、データとして見えるようになるはずです。本来は再現条件を絞り込むための情報なのに、ReRun運用ではこの情報が一瞬で蒸発していました。

もう一つ意識したいのは、AIエージェントにテストを書かせるときの指示です。CursorやClaude Codeに「このAPIのテストを書いて」と頼むと、規約ファイルにシーダーへの言及がなければ、シーダー前提の書き方が初期出力に混じることがあります。これは規約整備の話と地続きで、テスト内で必要なデータを生成すること、シーダーに依存しないこと、をテスト命名規約と同じファイルに書いておけば、AIの初期出力も変わるはずです。前作で書いた「規約整備でAIに任せる」という発想が、フレーキーテストの予防にも効きそうだ、というのが現時点の見立てです。


第5章: フレーキーテストが教えてくれること

ここまで書いてきて、フレーキーテストとは何だったのかが、自分の中で少し言葉になってきました。

フレーキーテストは、テストの設計・テストデータの設計・並行性・環境依存といった、システムのどこかにある不安定さを、無作為なタイミングで表に出してくれる仕組みです。落ちる/通るの揺れそのものは厄介ですが、揺れが起きる事実は、何かが暗黙の前提に依存していることを教えてくれている。ReRunで凌ぐのは、シグナルにフタをする行為であり、過去の私はそのフタを何年も押さえ続けていたことになります。

過去の自分に何か言葉を残すなら、こうなります。「ReRunを押す指は、原因に近づくための観察を止める指でもあった」。短期的には開発を止めない手段でも、長期的にはテストの信頼性とチームの判断力を削っていく行為だった、と。

これからチームでテストを運用し始める方や、いままさにフレーキーテストに頭を悩ませている方には、ReRunを押す前にもう一呼吸置いて、何が揺れているのかを観察する時間を取ってみてほしいと思います(過去の自分にも言いたい)

最後に補足すると、本記事ではシーダー由来のフレーキーテストに絞って書きました。実際のフレーキーテストは、タイミング依存(非同期処理、待機時間)、順序依存(テスト間の状態共有)、環境依存(ローカルとCIの差)など、複数の類型があります。それぞれの類型ごとに別の対処があるはずで、私もまだ全部に答えを持っているわけではありません。本記事で扱えなかった類型については、別の記事で扱う機会があれば書きたいと思います。

Discussion