📝

AIのテストコードは信用するな🙅‍♂️

に公開
1

※この記事は主観的な内容が多く詰まっているため、あくまで一つの意見として捉えてください。

はじめに

私はソフトウェアエンジニアとして、普段はAI駆動開発の実験で0→1のプロダクト開発に従事しています。

このタイトルはAIを責めているわけではありません。AIを使いこなせていない私自身への叱責です。

AI駆動開発を実践する中で、テストコードに関して大きな落とし穴にハマりました。この記事では、その経験から得た教訓を共有します。

対象読者

  • AIにテストコードを書かせている人
  • AI駆動開発でテストの品質に悩んでいる人
  • バイブコーディングでテストスイートが肥大化してしまった人

用語解説:単体テストにおける古典派とロンドン派

本記事では「古典派」「ロンドン派」という用語が登場します。「単体」という言葉に対しての認識がそれぞれで異なり、それによってモック・スタブの利用方針が違います。ここで簡単に解説します。

派閥 特徴 モックの使用
古典派(デトロイト派) 実際のオブジェクトを使い、振る舞いの結果を検証する 外部依存(DB、APIなど)はテストダブル/スタブで置き換えることがある
ロンドン派 テスト対象以外の依存をすべてモック化し、相互作用を検証する 内部の協調オブジェクトもモック化

古典派はリファクタリング耐性が高く、内部実装が変わってもテストが壊れにくいのが特徴です。一方、ロンドン派は相互作用(呼び出し)を検証するため、観測点が増えやすく、結果としてリファクタリングで壊れやすくなることがあります(ただし、境界の設計とモックの粒度が適切なら有効な場面もあります)。

より詳しく知りたい方は『単体テストの考え方/使い方』を読んでみてください。

AIにテストコードを実装してもらう際の落とし穴

以下は、AIが生成したテストコードを鵜呑みにしてレビューした結果、ハマりがちな落とし穴です。

落とし穴①:AIはテストを「作り出す」のが得意。「創り出す」のは苦手

AIにテストコードを書かせると、驚くほどのスピードで大量のテストを生成してくれます。
しかし、ここに罠がありました。

AIはテストを「作り出す(量産する)」ことは得意ですが、
「創り出す(どこで何を保証するか設計する)」ことは苦手なのです。

特に起きやすいのが、同じ振る舞いを複数の粒度・複数の層で繰り返し検証してしまうパターンです。

重複の具体例:集約の振る舞いと依存先の“二重保証”が偽陽性を増やす

集約の単体テストは、内部の実装ではなく、 外部から観測できる振る舞い(結果と状態変化)を保証するのが基本です。(古典派の文脈において)
この形に寄せると、集約内部のリファクタリングに対してテストが壊れにくくなります。

一方でAIに任せると、集約のテストに加えて、集約内の依存先(例:Money / Inventory / Policy など)にも、集約と同じ観点のテストを大量に作りがちです。
すると、集約の振る舞いが変わっていないにもかかわらず、依存先のリファクタリング(内部表現の変更、関数分割、最適化など)だけで壊れるテストが増え、偽陽性が発生します。

ここでいう「同じ観点」とは、集約の振る舞いテストで既に検証している結果(成功/失敗、状態遷移、計算結果など)を、依存先のテストでも再度検証してしまうことです。

良い状態:

  • Aggregate のテスト → 「購入が成功し、在庫/合計が期待通りになる」を保証 ✓

悪い状態(AIが増やしがち):

  • Aggregate のテスト → 同じ保証 ✓
  • 依存先(Money/Inventory)のテスト → Aggregate と同じ観点を再保証 ← 二重保証
  • 結果、依存先のリファクタで壊れるテストが増える(集約の振る舞いは無関係でも)

ポイントは、依存先にテストを書くかどうかではなく、
依存先のテストが “集約の振る舞いで既に保証している観点”をなぞっていないかです。
依存先に書くなら、集約のテストでは担保しづらい 依存先固有の契約(不変条件・境界値・丸めなど) に絞るのが安全です。(重要かつ複雑なビジネスロジック)

落とし穴②:AIはテストカバレッジ・テスト網羅率を高めるのが得意

AIはカバレッジを上げることに長けています。指示がなければ、あらゆるパターンを網羅しようとします。

その結果、本来テストすべき「振る舞い」ではなく、「実装の詳細」までテストしてしまい、リファクタリングに弱いテストスイートが出来上がります。

落とし穴③:AIは壊れたテストに対して、ロンドン派のテストに頼ることがある

何も指示をしないと、AIはロンドン派のテストスタイルを実装する可能性があります。

リファクタリング中にテストが失敗すると、最初は根本原因である振る舞いの変化へのアプローチを試みます。
しかし、試行錯誤が続くと、AIが “正しい振る舞いを保証する”よりも“目の前のテストを通す”ことを優先してしまうことがあります。

  • 失敗原因が曖昧(環境依存/データ依存/設計不備/仕様変更のどれか判別できない)
  • AIは短期で成功しやすい手段を選ぶ(=依存をモック化して不確実性を排除する)

その結果、呼び出し回数や内部メソッドなど“相互作用”を検証するテストが増え、観測点が増え、テストスイートがロンドン派に偏っていくのではと考えています。

補足: ロンドン派の単体テストがダメだと言っているわけではありません。
ただ、リファクタリングのたびに偽陽性(振る舞いに問題ないのにテストが失敗する)でテストが壊れてしまうことを危惧して、私は古典派のテストを推奨しています。

なぜ落とし穴にハマるのか

人間がテストを書く場合、労力が大きいため自然と「これは本当に必要か?」と考えます
同じことを二度書くのは面倒だからです。

しかしAIは違います。テストケース(たとえ意味のないものでも)の生成が得意なので、
重複を気にせず量産してしまいます。人間がレビューするまで、この問題に気づきにくいのです。


以上が、私の経験則から考えるAIによるテスト実装の落とし穴です。
以降はその落とし穴の何が問題なのか、その対策として何が考えられるかを記述していきたいと思います。

何が問題なのか

1. 人間によるコードレビューがつらい

AIが生成した大量のテストコードをレビューするのは、想像以上に大変です。

  • 重複したテストケースを見分けるのに時間がかかる
  • 「このテストは本当に必要なのか」を一つ一つ判断する必要がある
  • レビューの負荷が高く、見落としが発生しやすい

2. テストが壊れやすく保守コストが大きくなる

テストコードをバイブコーディング(AIに任せきり)してしまうと、一つのテストに対してとんでもない量のテストを生み出してしまいます

その結果:

  • 一つのリファクタリングで10のテストが壊れる
  • 一つの振る舞いの変更で100のテストが壊れる

このような状況に陥ってしまい、3. コンテキストの浪費 につながります。

3. コンテキストの浪費

一部のリファクタリングでテストケースが複数壊れると、その修正に延々とコンテキストと時間を使い続けます。

10分以上ロスすることも珍しくありません。

AIとの会話コンテキストを浪費し、本来やりたかった実装が進まない。TDDによる振る舞いの変更が困難になり、スケールしづらいコードベースが出来上がります。

対策:プロンプトでテスト実装方針を伝える

概要

プロンプトに以下で説明するテスト実装方針を伝えることで、AIの振る舞いを制御します。
私はClaude Codeを使用することが多いので、rulesやskillsにあらかじめ記載しています。(カスタムプロンプトやサブエージェントなどで定義しても良いと思います)

目的

  1. リファクタリングのコストを下げるため、壊れやすいテストを生成させない
  2. レビュー負担の増加やコンテキストメモリの節約を兼ねて、必要最低限のテストコードに留める

注意:銀の弾丸ではない

方針を定義してもたまに無視されることがあります。人間がしっかりレビューすることを忘れずに。(週末バイブコーディングを除く)

方針1:単体テストは振る舞いの確認のみ行う

古典派テスト > ロンドン派テスト を明確にルール化します。

要はテスト対象がどのように動作するか(実装の詳細・メソッド呼び出し)ではなく、何をするか(入力に対する出力や状態変化)を検証するということです。

古典派テストの特徴

特徴 説明
振る舞いの検証 実装の詳細ではなく、入出力や状態変化を検証する
リファクタリング耐性 内部実装が変わっても、振る舞いが同じならテストは通る
偽陽性の低減 実際の結果を検証するため、モックによる誤検知が少ない
保守性 テストがシンプルで、メンテナンスコストが低い

方針2:要件からテストケースを生成

AIに対してユニットテストで検証する内容をシステムの振る舞いに絞ります。
そうすることで、AIがテストするべき対象が明確になります。(仕様駆動開発で要件定義がドキュメントとして構造化している)

仕様駆動開発の一環で要件がEARS記法やGherkin形式で記載されている場合、そのままテストケース生成のリソースとして活用できます。(テストの記法をAAAやGiven-When-Thenが明確になるようにすると読みやすいテストコードになります)
要件が構造化されていれば、テストケースの漏れをより防ぎやすくなります。

方針3:簡単なCRUDは統合Testで十分

シンプルなCRUD操作は、単体テストで個別にテストするよりも統合テスト(IntegrationTest)で一括してテストする方が効率的です。

判断基準 テスト戦略
ビジネスロジックが薄い 統合テスト のみ
複雑な条件分岐や計算がある 単体テスト + 統合テスト

実際のファイル例

※skillsに記載しているコードテンプレートについて、浮動小数の誤差を避けるため税率はgo-bps等で扱うことが多いですが、ここでは簡略化のためfloat64を使用しています。

rules.md の例

rules/test-code.md
# テストコードルール

## テストスタイル

**古典派テスト(振る舞いの検証)** を採用します。
実装の詳細ではなく、外部から観察可能な振る舞いをテストしてください。
実装する際は`skills/create-test/SKILL.md`に記載の通りに作業を進めてください。

skills.md の例

skills/create-test/SKILL.md
# テスト作成スキル

## 概要

Go言語でテストコードを作成する際の手順とテンプレートです。

## テストファイルの配置

テストファイルは対象ファイルと同じディレクトリに `_test.go` サフィックスで作成します。

## テストの構成

**テーブルテスト + Given-When-Then パターン** を採用します。

- テーブルテストで複数のケースを効率的に記述
- 各ケースの構造を Given-When-Then で整理

## テストメソッドの命名

ビジネス上の意味が伝わる名前をつけます。Goのテーブルテストではnameフィールドも含めて意味が明確になるよう適用しています。

| 観点 | ガイドライン |
|------|--------------|
| 対象読者 | 非開発者にも伝わる |
| 内容 | ビジネス上の意味を伝える |
| 禁止 | メソッド名をテスト名に含めない |

## テストテンプレート

func TestPriceCalculator(t *testing.T) {
    tests := []struct {
        name     string
        // Given: テストの前提条件
        taxRate  float64
        price    int
        // Then: 期待する結果
        expected int
    }{
        {
            name:     "税率10%で1000円の場合1100円になる",
            taxRate:  0.10,
            price:    1000,
            expected: 1100,
        },
        {
            name:     "税率8%で500円の場合540円になる",
            taxRate:  0.08,
            price:    500,
            expected: 540,
        },
        {
            name:     "価格が0の場合は0のまま",
            taxRate:  0.10,
            price:    0,
            expected: 0,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Given
            calc := NewPriceCalculator(tt.taxRate)

            // When
            result := calc.Calculate(tt.price)

            // Then
            if result != tt.expected {
                t.Errorf("got %d, want %d", result, tt.expected)
            }
        })
    }
}

## 集約の振る舞いテスト(状態ベース)

集約の内部実装(在庫減少など)を直接テストするのではなく、
集約の振る舞い(商品購入)をテストし、その結果を検証します。

func TestPurchase(t *testing.T) {
    tests := []struct {
        name string
        // Given: 商品購入の前提条件
        initialStock    int
        purchaseQuantity int
        // Then: 購入後の期待結果
        expectedSuccess bool
        expectedStock   int
    }{
        {
            name:             "在庫がある場合は購入成功し在庫が減る",
            initialStock:    10,
            purchaseQuantity: 3,
            expectedSuccess: true,
            expectedStock:   7,
        },
        {
            name:             "在庫と同数の購入で在庫が0になる",
            initialStock:    5,
            purchaseQuantity: 5,
            expectedSuccess: true,
            expectedStock:   0,
        },
        {
            name:             "在庫不足の場合は購入失敗し在庫は変わらない",
            initialStock:    2,
            purchaseQuantity: 5,
            expectedSuccess: false,
            expectedStock:   2,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Given: 店舗に商品を登録
            store := NewStore()
            store.AddProduct("商品A", tt.initialStock)

            // When: 商品を購入(集約の振る舞い)
            result := store.Purchase("商品A", tt.purchaseQuantity)

            // Then: 購入結果と在庫の状態を検証
            if result.Success != tt.expectedSuccess {
                t.Errorf("購入結果: got %v, want %v", result.Success, tt.expectedSuccess)
            }
            if got := store.GetStock("商品A"); got != tt.expectedStock {
                t.Errorf("在庫数: got %d, want %d", got, tt.expectedStock)
            }
        })
    }
}

## エラーケースのテンプレート

func TestPriceCalculator_Validation(t *testing.T) {
    tests := []struct {
        name    string
        // Given
        taxRate float64
        price   int
        // Then
        wantErr bool
    }{
        {
            name:    "負の価格はエラー",
            taxRate: 0.10,
            price:   -100,
            wantErr: true,
        },
        {
            name:    "負の税率はエラー",
            taxRate: -0.10,
            price:   1000,
            wantErr: true,
        },
        {
            name:    "正常な値はエラーなし",
            taxRate: 0.10,
            price:   1000,
            wantErr: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Given
            calc := NewPriceCalculator(tt.taxRate)

            // When
            _, err := calc.CalculateWithValidation(tt.price)

            // Then
            if (err != nil) != tt.wantErr {
                t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

まとめ

AIにテストコードを任せる際は、現状以下の点を意識しています

  1. カバレッジ100%を目指させない - 量より質
  2. 古典派テストを採用する - 実装の詳細ではなく、振る舞いの結果をテスト
  3. テスト方針を明示的に定義する - プロンプトやrules/skillsで方針を伝える
  4. 人間がレビューする - AIは重複を気にしないので、人間がチェックする

AIは優秀なペアプログラマーですが、テスト設計の「創造性」は人間が担うべき領域です。AIの得意な「作り出す」力と、人間の「創り出す」力を組み合わせることで、持続可能なテストスイートを構築していくのが現時点でのベストな選択だと思います。

Discussion

take0(たけまる)take0(たけまる)

memo

  • 複数の集約オブジェクトから呼ばれているオブジェクトについては、そのオブジェクトに対してテストを行う方が良いかも知らない。検証内容の重複を許容するか、依存先に検証を持っていくかは設計判断。
    • その依存先を要件の時点で固め、要件定義書に記載することは現実的ではなそう
  • テストをそのオブジェクトの振る舞いのドキュメントとして運用している場合は、集約の依存先にもテストがあった方が自然なのかもしれない