🚧

AI駆動開発を支えるテスト重視の戦略を考える

に公開

AIと人間が効率的に協働するための「テスト重視の戦略」について、一般論と自身の経験に基づいて有効な戦略をまとめました。
それらを実践するためのサンプルプロジェクトも作成しました。

なお、本記事は主にRDBを含む Webバックエンド を前提としています。
説明の都合上、具体例はGo言語で示しますが、アーキテクチャやテスト戦略の考え方は他の言語でも応用可能です。

🟦 AIコード生成の現実と課題

🟠 規模による制約

ある程度大きなソフトウェアになると、全てをAIに任せることは現実的ではありません。
参考記事: ソフトウェア規模とAIに任せられる範囲

🟠 AIが生成するコードの特性

AIが生成するコードは一見正しく見えても、実際に動かすとバグが潜んでいることがあります。
机上レビューだけでは検出が困難なこれらの問題は、AIの高速な生成能力と相まって、チームを障害対応に追われる状況に陥れるリスクがあります。
そのため、AIの成果物を効率的に検証する仕組みづくりが不可欠です。

🟦 AI駆動開発を支える3つの柱

🟠 1. テストコードの重要性

AIが生成したコードの品質を保証するには、包括的なテストコードが不可欠です。テストコードは以下の役割を果たします

  • AIが生成したコードの正しさの自動検証
  • リリース前のバグ発見と本番環境での障害防止
  • テストコードが振る舞いを説明するドキュメントになる
  • コードを保護し、リファクタリングを可能にします

🟠 2. テスト容易なアーキテクチャ

  • テストコードで検証できる範囲を最大化する
  • リファクタリング耐性を高める(実装を変更してもテストが壊れにくい)

🟠 3. テストコードの可読性

テストコードを効率よくレビューできる可読性と、それをもたらすコーディングルールが必要です。

🟦 AIと人間の責任分界点

明確な責任分界点を設けることで、AIと人間の協働を効率化します。

🟠 基本方針

  • 人間の責任
    • OpenAPI定義、DBスキーマ定義、テストケースの作成またはレビュー
  • AIの責任
    • 上記に基づく実装の作成

🟠 この方針のメリット

明確な仕様定義(OpenAPI・DBスキーマ)により、AIの実装品質が安定します(迷走しにくくなります)。

また、テストコードをAIに書かせる場合は人間がテストコードのレビューを行いますが、人間がAPIの仕様を決めているためレビュー効率が上がります(詳しくは後述)

🟦 テスト容易なアーキテクチャ

🟠 オニオンアーキテクチャ

本記事で紹介するオニオンアーキテクチャは、従来のレイヤードアーキテクチャを発展させたもので、依存関係の方向を逆転させることでテスト容易性を高めています。

レイヤードアーキテクチャとの違い

  • 従来のレイヤードアーキテクチャ
    • 上位層(UIやアプリケーション層)が下位層(インフラ層)に依存する
  • オニオンアーキテクチャ
    • ビジネスロジックを中心に置き、内側の層(ドメイン層)がインフラ層のインターフェースに依存する

この依存関係の逆転により、ビジネスロジックが外部の技術詳細から独立します。これを実現するための技術的な手法が DI(依存性注入) です。

これにより、テスト時にインフラ層を簡単にモックに置き換えることができます。

🟠 フォルダ構成と責務

大まかに、以下4つのフォルダを用います。

├── application/handler    # APIハンドラ(ドメインロジックを含む)
├── domain/service         # 共有ドメインロジック
├── domain/infrastructure  # インフラ層のインターフェース
└── infrastructure         # 副作用を扱う実装

各フォルダの詳細は以下のとおりです。

📁 application/handler

  • APIのハンドラを配置(フレームワークによってはController)
  • ドメインロジックを記述(usecase層を兼ねる)

📁 domain/service

  • 複数のAPIで共有するドメインロジックを配置

📁 infrastructure

  • 副作用を扱う実装を配置(テスト時はモックに置き換える)
  • ドメインロジックは含めない

📁 domain/infrastructure

  • インフラストラクチャ層のインターフェースを配置

以下は「オニオンアーキテクチャのレイヤー」と「フォルダ構成」の対応を表した図です。

オニオンアーキテクチャのレイヤーとフォルダ構成の対応

なお、自身はDDDの戦術的なテクニックとWebバックエンドの相性には懐疑的なのでエンティティ、集約あたりは作りません
参考記事: DDDとWebバックエンドの相性

🟠 テスト容易性との関係

インフラストラクチャ層に副作用を隔離することで、モック化によってテスト容易性を高めます。
ドメインロジックをインフラストラクチャ層に混ぜないことが重要です。

🟦 効果的なテスト戦略

🟠 API単位でのテスト

基本的にはAPI単位(handler単位)でテストを書きます。

メリット

  • APIの仕様は人間が決めているため、テストケースを考えやすい
  • テストコードがAPIの仕様書として機能する
  • すべてのレイヤーを一気通貫でテストできる

レイヤー単位のテストを避ける理由

  • 実装の詳細を知りすぎており、リファクタリング耐性が低い
  • 実装を修正するとテストが壊れやすく、リグレッションに対する保護がない
  • コード修正のコストが上がる

近年のWeb開発環境の変化により、WebAPI1つの責務が小さくなっており、API単位のテストでも簡潔に書けるようになっています。

🟠 インフラストラクチャ層のモック化

インフラストラクチャ層のみをモックに置き換えることで、副作用を排除してテストを実行します。

この実現にはDI(依存性注入)を活用します。
handlerのコンストラクタでインフラストラクチャ層の実装を外部から注入することで、本番環境とテスト環境で異なる実装を差し替えできます。

// 本番環境:実際の実装を注入(DI)
handler := NewUserHandler(db, &realNotificationService{})

// テスト環境:モックを注入(DI)
handler := NewUserHandler(db, mockNotificationService)

handlerが直接 NotificationService の実装を作るのではなく、外部から依存オブジェクト(NotificationService)を注入しています。
これにより、同じhandlerコードで本番では実際の外部サービスと通信し、テストでは副作用なく実行できます。

🟠 RDBは実接続でテスト

RDBはモック化せず、Dockerで起動した実DBに接続してテストします。

実DBを使うメリット

  • SQLの構文エラーロジックの誤りを実際の実行で検出できる
  • DBスキーマ変更時のリグレッションを検出できる
  • DBのメジャーバージョンアップ時の非互換性を検出できる

モック化を避ける理由

一方、RDBをモック化すると以下の問題が発生します

  • SQLの条件式の正しさをモックでは検証できない
  • ダミーレスポンスの準備と管理が煩雑になる
  • テストコードがモックの設定で埋め尽くされる
  • 実装のSQL変更に伴いテストの修正が頻発する(リファクタリング耐性が低い)

🟠 モックの検証

モックの入力値と呼び出し回数を必ず検証します。
これを怠ると、外部サービスを異常値で更新してしまうなどのバグを見逃す可能性があります。

以下、gomockを使った実装例です。

    mockNotification := mocks.NewMockNotificationService(ctrl)
    mockNotification.EXPECT().
        SendEmail("user@example.com", "Todo作成通知", "新しいTodoが作成されました"). // 入力値を検証
        Times(1).  // 呼び出し回数を検証
        Return(nil)
}

🟦 テストコードのコーディングルール

🟠 AAAパターンの採用

Arrange(準備)、Act(実行)、Assert(検証)のパターンに従うことで、テストの可読性と保守性を向上させます。

以下、Go言語を例にAAAパターンを示します。最初にテストの準備(Arrange)、次にテスト対象の実行(Act)、最後に結果の検証(Assert)を行います

// Arrange
user := &models.User{ID: 1, Name: "Test User"}
db.Create(user)

// Act
response := handler.CreateTodo(todoRequest)

// Assert
assert.Equal(t, 200, response.StatusCode)

参考資料:【アンチパターン】Arrange、Act、Assert(AAA)を意識できていないRSpecのコード例とその対処法

🟠 制御構造の回避

テストコード内でif文やfor文などの制御構造を使うことを避けます。
制御構造を多用すると可読性が落ち、レビューが困難になります。

🟠 DRYよりも可読性を優先

テストコードではDRY原則にこだわりすぎず、可読性を優先します。

理由

  • 変数に代入して使い回すより、都度リテラル値で書く方が見やすい
  • テストケースの独立性を高められる
  • 共通化しすぎると、一つのテストケースの変更が他に影響する

参考資料:テストコードの期待値はDRYを捨ててベタ書きする

🟦 テストコードの実装例

サンプルコード:postUsersIdTodos_test.go

🟠 テストケース「既存のtodoを更新できること」の詳細解説

このテストケースを例に、前述の戦略とコーディングルールがどのように実践されているか見てみましょう。
以下のコード例はGo言語で記述されていますが、テストの構造や考え方は他の言語でも応用できます。

Arrange(準備)

テストケースを実行するのに必要な最低限のレコードをDBに登録します。
ここではテスト用の会社とユーザーを作成し、更新対象となる既存のTodoを作成します

db.Company.Create().SetID("UUID-1").SetName("テスト株式会社").SaveX(t.Context())
db.User.Create().SetID("UUID-2").SetName("テストユーザー").SetAge(30).SetGender("man").SetCompanyID("UUID-1").SaveX(t.Context())
db.Todo.Create().SetID("UUID-3").SetTitle("更新前タスク").SetDescription("更新前の説明").SetStatus(schema.StatusNone).SetOwnerID("UUID-2").SaveX(t.Context())

Act(実行)

既存のTodoと同じID(UUID-3)を使用して更新リクエストを送信します

params := operations.PostUsersIDTodosParams{
   HTTPRequest: util.Ptr(http.Request{}),
   ID:          "UUID-2",
   Body: &models.Todo{
      ID:          util.Ptr(strfmt.UUID("UUID-3")),
      Title:       util.Ptr("更新後タスク"),
      Description: util.Ptr("更新後の説明"),
      Status:      util.Ptr("progress"),
   },
}
resp := testee.Main(params)

Assert(検証)

レスポンスの確認とデータベースの状態を検証します。
既存レコードの更新であるため、レコード件数が変わっていないことも検証しています。

_, ok := resp.(*operations.PostUsersIDTodosOK)
assert.True(t, ok)

db := conn.GetEnt()
todos, err := db.Todo.Query().WithOwner().All(t.Context())
assert.NoError(t, err)
assert.Equal(t, 1, len(todos)) // レコード件数の検証
assert.Equal(t, "UUID-3", todos[0].ID)
assert.Equal(t, "更新後タスク", todos[0].Title)
assert.Equal(t, "更新後の説明", todos[0].Description)
assert.Equal(t, schema.StatusProgress, todos[0].Status)
assert.Equal(t, "UUID-2", todos[0].Edges.Owner.ID)

このテストが示す重要なポイント

  1. AAAパターンの明確な分離

    • 準備、実行、検証が明確に分かれており、テストの流れが理解しやすい
  2. リテラル値の使用

    • 変数に格納せず、直接文字列を使用することで、期待値が一目瞭然
  3. 制御構造の不使用

    • if文やfor文を使わず、シンプルな流れで記述
  4. API単位のテスト

    • handler.PostUsersIDTodosを直接呼び出し、全レイヤーを一気通貫でテスト
  5. 実DBの使用

    • モックではなく実際のDBを使用することで、SQLの正しさも検証

🟠 テストコードのレビューポイント

AIがテストコードを作成した場合は以下の観点でレビューします。

  1. リクエスト/レスポンスの検証

    • OpenAPIで定義した仕様通りか確認
  2. データベースの状態確認

    • 事前状態と事後状態が想定通りか確認
  3. モックの呼び出し確認

    • 外部サービスへの呼び出しが適切か確認

🟦 サンプルプロジェクト

🟠 特徴

本記事の戦略を実装したサンプルプロジェクト「Go Web API Template」の主な特徴は以下の通りです。

  • オニオンアーキテクチャの採用
  • Dev Container対応
    • コンテナ内の隔離された環境でAIツールを実行できるため、ホストマシンの環境を意図せず変更されるリスクを回避できます
  • Claude Code、Cline、Cursor対応
  • OpenAPIからの実装生成(go-swagger利用)

🟠 カスタムプロンプトの活用

カスタムプロンプトには、基本的には本記事で述べた戦略とアーキテクチャの内容を記載しています。
例:CLAUDE.md

他には以下を記載しています。

  • APIを実装時は正常系テストを1パターン実装する指示
  • テストコードの実行方法
  • OpenAPI定義とDB定義は変更しない指示
  • 矛盾がある場合は人間に確認する指示

コーディングルールの詳細は記載せず、「各レイヤーの類似ファイルを参考にすること」に集約しています。

🟠 AIによる実装例

以下が実際にAIに実装させてみたPRです。

サンプルPR: claude code + claude-sonnet-4

使用したプロンプトは以下の通り。

POST /users/{id}/todosを実装して。
完了条件: テストコードが正常に完了する

前提として、OpenAPI定義とDBのスキーマは事前に作成している必要があります。

事前に定義したOpenAPI仕様「レコードがなければ作成、レコードがあれば更新する」に従い、upsertを実装してくれています。
また、既存のコードの書き方を踏襲して実装してくれているほか、テストコードも実装してくれています。

以下はCursorに実装させている動画です。
コードの実装からテストコードでの検証まで行っているのが分かります。

1:53〜 実装開始
3:55〜 テストコード実装開始
4:10〜 テストコードを実行して検証

https://youtu.be/zNYEDeKIbh8

🟦 まとめ

AI駆動開発を成功させるには、以下の3つが重要です。

  1. 明確な責任分界点
    • 人間が仕様を定義し、AIが実装する
  2. テスト容易なアーキテクチャ
    • オニオンアーキテクチャによる副作用の隔離
  3. 効果的なテスト戦略
    • テストコードで検証可能な範囲の最大化とテストコードの可読性

これらを組み合わせることで、AIの生成速度を活かしながら、品質を担保した開発が可能になります。

本記事で紹介した戦略がAI駆動開発の一助となれば幸いです。

(余談)最後まで読んで頂きありがとうございます!
内容に共感頂けたら いいね or リツイート 頂けると励みになります 🙏

Discussion