Tabelog Tech Blog

食べログの開発者による技術ブログです

生成AIで自動テストを楽に作りたい!

この記事は 食べログアドベントカレンダー2024 の12日目の記事です🎅🎄

目次

はじめに

こんにちは。食べログ開発本部 ウェブ開発2部 第1プロダクトチームで主にバックエンド周りの開発を担当しています、エンジニアの高田です。

ソフトウェア開発において自動テスト作成は重要な反面、大きな労力・工数を伴う作業です。本記事では特に単体テストに焦点を当て、生成AIを活用して自動テストを効率的に作成する取り組みとその成果、課題について紹介します。

自動テスト作成の課題

ソフトウェア開発において、自動テストの重要性は広く認識されています。高品質な自動テストはコードの信頼性を高め、予期せぬバグの発生を抑える上で欠かせない要素です。しかしその一方で、自動テスト作成には多くの工数が必要とされるため、現場では十分なリソースを割けないケースも少なくありません。

テストケースを考えることの難しさ

自動テスト作成の最初の障壁は、テストケースを考えること自体にあります。コードを書く段階では仕様やロジックを意識しながら実装を進めているものの、それを具体的なテストケースに落とし込むには別のスキルが求められます。

特に、条件分岐が多い処理やエッジケースを含む仕様を対象とする場合、次のような作業が発生します。

  • 各条件に対応する適切な入力値と期待値を洗い出す。
  • 境界値や例外ケースを含め、網羅的なテストケースを設計する。
  • 漏れや重複がないか確認する。

これらを一つ一つ手作業で行うのは作業者の集中力や経験によっては多大な時間を要することになります。結果として、仕様の複雑さに比例してテストケースを考える時間が増加し、場合によっては肝心の自動テスト作成自体が後回しになるリスクがあります。

テストコードに落とし込む作業の負担

次に挙げられる課題は、考えたテストケースを具体的なテストコードに変換する作業です。RSpecを用いる場合、以下のようなテストコードを記述することが一般的です。

テスト対象のコード例

class UserFetcher
  def initialize(api_client)
    @api_client = api_client
  end

  def fetch_user_data(user_id)
    response = @api_client.get("/users/#{user_id}")
    if response.success?
      response.body
    else
      raise "Failed to fetch user data"
    end
  end
end

RSpecでのテストコード例

RSpec.describe UserFetcher do
  let(:api_client) { instance_double("ApiClient") }
  let(:user_fetcher) { described_class.new(api_client) }

  describe "#fetch_user_data" do
    context "when the API call is successful" do
      it "returns the user data" do
        user_id = 1
        response = double("Response", success?: true, body: { id: user_id, name: "John Doe" })
        allow(api_client).to receive(:get).with("/users/#{user_id}").and_return(response)

        result = user_fetcher.fetch_user_data(user_id)

        expect(result).to eq({ id: user_id, name: "John Doe" })
      end
    end

    context "when the API call fails" do
      it "raises an error" do
        user_id = 2
        response = double("Response", success?: false)
        allow(api_client).to receive(:get).with("/users/#{user_id}").and_return(response)

        expect {
          user_fetcher.fetch_user_data(user_id)
        }.to raise_error("Failed to fetch user data")
      end
    end
  end
end

上記の例では、テストコードの作成には次のような課題が含まれます。

  • モックやスタブの設定の複雑さ

    外部依存を再現するためにモックやスタブを作成し、テストケースごとに適切な動作を設定する必要があります。

  • エッジケースの再現性

    実際の環境で発生し得る例外ケースを再現し、それを正しくテストするには手間がかかります。

  • テスト構造の設計

    describe や context の粒度をどのように分割するか、実際に運用しやすい構造を検討する必要があります。

自動テスト作成の課題がもたらす影響

これらの課題が積み重なることで、以下の問題が発生します。

  • テストの網羅性が不足する

    特に、スケジュールに追われる場面では、時間のかかるエッジケースを省略することが少なくありません。

  • テストコードのメンテナンス性が低下する

    急いで書かれたテストは構造が複雑化し、後から修正や追加をする際に問題となります。

  • 開発の効率低下

    十分な自動テストがないことでリリース後にバグが発生し、対応に多くのコストがかかります。

これらの課題を解決するために、生成AIを活用したアプローチを試みました。次章では、生成AIを活用して自動テストを生成する具体的な取り組みについて解説します。

生成AIと自動テスト

自動テスト作成の効率化を目指して

自動テスト作成の課題を解決するための1つのアプローチとして、生成AIを活用する方法に注目しました。生成AIを使えば、これまで人間が手作業で行っていた「テストケースの設計」や「テストコードの実装」にかかる手間を削減できる可能性があります。

特に生成AIをチャット形式で活用することで、テストコード作成を対話的に進められるのがポイントです。開発者はAIに必要な情報を与えるだけで適切なテストコードが出力され、効率化が期待できます。

導入の条件

生成AIを活用して自動テスト作成フローを確立するにあたっては、以下の3つの条件を満たすことを目標としました。

  1. 今すぐ使えること

    生成AIの活用は、現在の日常的な開発タスクや進行中のプロジェクトにすぐ適用できることが求められました。 特に、頻繁に発生するタスク(例えば、新規機能の追加や軽微な修正)に活用できるほど、その効果は大きくなります。

    一方で、利用できる場面が限られると、その導入効果は発揮されません。例えば完全に新しい設計が必要な箇所や、特殊なロジックに対してのみ有効な場合、全体としての工数削減にはつながりにくいと考えました。

  2. 効果的であること

    生成AIが出力するテストコードの品質も重要です。そのままテストコードとしてコミットできる品質であれば、手動によるテスト設計や実装の工数を削減できます。

    一方で、出力されたコードに毎回多くの修正が必要だったり、再生成を繰り返す必要がある場合、工数削減の効果は薄くなってしまいます。そのため、生成AIが安定して高品質なアウトプットを提供できることが必要条件となります。

  3. 楽に使えること

    開発フローへの適合性も欠かせない条件です。現行の設計や開発プロセスを変えずに導入できれば、多くのタスクで利用でき、工数削減の恩恵を最大化できます。

    一方で、生成AIを活用するために既存システムのリアーキテクチャが必要になったり、開発フローを根本的に見直さなければならない場合、コストや時間の面で逆効果になる可能性もあります。

Difyを活用したチャットボット

本プロジェクトでは、Dify1を利用して対話型のチャットボットを構築しました2。このチャットボットは、生成AIを活用してRSpec形式のテストコードを自動生成する仕組みを持っています。

開発者が実装したいコードをチャットボットに送信するだけで、対応するテストコードが返ってくるよう設計しました。

チャットボットの利用方法

チャットボットの操作はシンプルで、以下の流れで利用できます。

  • 実装コードを入力

    開発者は用意されたテスト対象のコードをプロンプトの中にコピペし、チャットボットの入力欄に貼り付けます。

  • AIがテストコードを生成

    チャットボットが生成AIモデルを呼び出し、対応するRSpecのテストコードを生成します。

  • テストコードの確認と利用

    生成されたテストコードを確認し、そのままプロジェクトに導入します。修正が必要な場合はチャットボットとの対話を繰り返し、求めるテストコードになるように再度生成させます。

チャットボットのトップ・入力画面

画面上では、開発者とチャットボットのやり取りが対話形式で進行し、出力されたテストコードをその場で確認できます。この直感的な操作性により、誰でも簡単に利用できる仕組みを実現しました。

テスト生成の障害

生成AIを活用してテストコードを自動生成することで手間の削減が期待されましたが、実際の運用ではいくつかの課題が浮き彫りになりました。本章では生成AIを用いた際に直面した問題と、その解決に向けた気づきをご紹介します。

実装コードをそのまま送った場合の問題点

実際にチャットボットにテストコード生成を依頼すると、多くの場合次のような問題が発生しました。

  1. 自動テストが正しく動かない

    生成されたテストコードはRSpec形式としての体裁は整っているものの、コードが正しくないことによるエラーやテストしたい対象もスタブしてしまう等、意図した動作を満たさないケースがほとんどでした。

  2. 適切なテストケースにならない

    対象コードの外部で定義しているデータの構造や使用しているライブラリの仕様などは与えられる実装コードからは読み取れません。そのため仕様やエッジケースが十分に反映されず、不適切なテストケースが出力されました。

    外部APIやデータベースへの依存が切り離されていないコードでは、生成された自動テストが実際には動作しないことが多く見られました。

    以下のコードはモデルの構造が実装コードからは明確に読み取れないため、生成AIが適切なテストケースを生成するのが難しい例です 3

# チャットボットに送信したテスト対象のコード
class DiscountCalculator
    def calculate_discount(user, total_amount)
        return total_amount * 0.9 if user.special_member
        return total_amount * 0.95 if total_amount > 10_000

        total_amount
    end
end

# チャットボットには送信していない、userモデルの定義
class User < ApplicationRecord
    def special_member
        membership_level == "special"
    end
end

# 生成されたテストコード
RSpec.describe DiscountCalculator do
    let(:calculator) { described_class.new }

    describe "#calculate_discount" do
        it "applies a 10% discount for special members" do
        user = create(:user, special_member: true) # 実際には存在しないカラム
        result = calculator.calculate_discount(user, 20_000)

        expect(result).to eq(18_000)
        end

        it "applies a 5% discount for orders over 10,000" do
        user = create(:user, special_member: false) # 実際には存在しないカラム
        result = calculator.calculate_discount(user, 15_000)

        expect(result).to eq(14_250)
        end

        it "returns the full amount for regular members under 10,000" do
        user = create(:user, special_member: false) # 実際には存在しないカラム
        result = calculator.calculate_discount(user, 5_000)

        expect(result).to eq(5_000)
        end
    end
end

このようにチャットボットではメソッド名から user.special_member をDBのカラムとしてテストコードを出力していますが、正しくはモデル内に定義されたメソッドです。

また以下のように、テストしたい対象も含めてスタブしてしまい、意味のあるテストにはなっていない場合も見られました。

RSpec.describe DiscountCalculator do
    let(:calculator) { described_class.new }

    describe "#calculate_discount" do
        it "applies a 10% discount for special members" do
        user = create(:user)
        allow(user).to receive(:special_member).and_return(true) # スタブ化

        allow(calculator).to receive(:calculate_discount).with(user, 20_000).and_return(18_000) # テスト対象メソッドもスタブ化

        result = calculator.calculate_discount(user, 20_000)
        expect(result).to eq(18_000)
    end
end

テストはスタブ化された値が期待通りに返ってくるかを確認するだけになっており、実際のロジックやエッジケースのテストになっていません。

これを防ぐためには user モデルのコードも合わせてチャットボットに送信する必要がありますが、実務上はモデルのコード量は多くなりがちです。 大量のコードの中から必要なコードを抜き出し、更にその中から参照している先のコードを見つけて抜き出し……と人間側の負担が大きくなり、生成AIを使っても効率化ができないことになってしまいます。

良い自動テスト生成が可能なケース

一方で、外部依存を切り離した設計が行われた箇所では、生成AIが意図した通りのテストコードを出力するケースがありました。特に次のような条件下では良好な結果が得られました。

  1. 純粋関数のテスト

    外部依存がない関数や、計算ロジックだけを持つメソッドに対するテストコードは、適切な形で生成されました。

  2. ビジネスロジックだけを含むサービスクラスのテスト

    外部依存を抽象化し、ビジネスロジックだけを含むクラス(サービスクラスなど)を対象とした場合、質の高いテストコードが生成されました。

上記の例では以下のようにロジック箇所を切り出すことができます。

# チャットボットに送信したテスト対象のコード
class DiscountCalculator
    def calculate_discount(is_special_member, total_amount)
        return total_amount * 0.9 if is_special_member
        return total_amount * 0.95 if total_amount > 10_000

        total_amount
    end
end

# 生成されたテストコード
RSpec.describe DiscountCalculator do
    let(:calculator) { described_class.new }

    describe "#calculate_discount" do
        it "applies a 10% discount for special members" do
        result = calculator.calculate_discount(true, 20_000)
        expect(result).to eq(18_000)
        end

        it "applies a 5% discount for orders over 10,000 for non-special members" do
        result = calculator.calculate_discount(false, 15_000)
        expect(result).to eq(14_250)
        end

        it "returns the full amount for non-special members under 10,000" do
        result = calculator.calculate_discount(false, 5_000)
        expect(result).to eq(5_000)
        end
    end
end

ここでは「user.special_memberでユーザーがプレミアムアカウントかどうか判定できる」という知識を外部に吐き出し、引数で渡すことでロジック箇所と分離する設計になっています。 calculate_discountの計算ロジックを自動テストで保証し、人間側はcalculate_discountにuser.special_memberを渡している箇所が正しいかどうかのテストに注力することができます。

プロンプトの工夫

上記の課題を踏まえて、最終的なプロンプトには以下の記述を盛り込みました。

  1. 対象がサービスクラスであることの明示

     ## テストケース
    
     与えられた Usecase の public なメソッドについて、必要なテストケースを列挙してください。
    

    ロジックに焦点を当てたテストケースを生成させるため、詳細なprivateメソッドの挙動まではテストしないことを指定しています。 (食べログ内ではビジネスロジックのみ切り出したサービスクラスをUsecaseと呼んでいます)

  2. スタブ・モック使用の抑制

     - **古典学派的なテストアプローチ**を指向し、なるべくモックを使わないこと
    

    上記例の過剰なスタブ利用によって自動テストの意味が無くなることを防ぐ意図です。

プロンプトや対話の工夫では解決できないこと

一方で、プロンプトや対話手法の工夫では解決できない根本的な課題もありました。その背景には以下の理由が挙げられます。

  1. 必要なデータ量が多い

    生成AIが適切なテストコードを生成するには、対象コードの仕様や依存関係を詳細に伝える必要があります。そのため、外部APIの構造やデータベースのスキーマなど、膨大な情報をチャットボットに送信しなければなりません。

  2. 大量のデータ送信が非現実的

    毎回必要な情報をすべてチャットボットに送信することは現実的ではありません。データが増えれば増えるほどそれを探し出してチャットボットに送り出す作業の負担が増大し、効率化という本来の目的を達成できなくなります。

設計の重要性

以上の結果から、生成AIを活用してテストコードを生成する場合、外部依存を最小化した良い設計が重要であると結論づけました。具体的には、次のような設計指針が求められます。

  1. 外部依存を抽象化する

    データベースや外部APIなど、外部依存を直接利用するロジックを別のクラスやインターフェースに分離する。

  2. ビジネスロジックを独立させる

    サービスクラスや純粋関数として、外部依存がなくても動作するロジックを明確に分離する。

  3. テストしやすい単位で設計する

    小さな単位で動作が明確になるよう、適切な粒度でメソッドやクラスを分ける。

このように設計することで、生成AIが提供するメリットを最大限に引き出すことが可能になります。

まとめ

生成AIを活用した自動テスト作成の試みは、実際の開発現場における自動テスト作成の効率化に向けた新しいアプローチとして、多くの可能性を示しました。しかし万能の解決策とはならず、その効果を発揮するためにはコードの設計が重要な役割を果たします。

ロジックを切り出した対象での有効性

今回の試みを通じて、外部依存を切り離した純粋なロジックを対象にした場合、生成AIを活用するアプローチが有効だと分かりました。例えば、純粋関数や外部依存を抽象化したサービスクラスに対しては、生成されたテストコードの品質が高い結果となりました。

そのため以下を意識した設計をした上で実装に取り組むことが重要だと結論づけました。

  1. 依存箇所の切り分け

    外部APIやデータベースといった外部依存を分離し、テスト対象となるロジックを独立させることで、生成AIによる自動テスト作成の成功率が向上します。

  2. テストしやすい構造

    テストの粒度やモジュールの責務を適切に分割することで、生成AIが扱いやすい対象となり、より良い結果が得られます。

自動テストと設計の相乗効果

生成AIによる自動テスト作成を活用するためには、設計の良さとその効果を引き出す仕組みが必要不可欠です。設計が良いほど、生成AIを活用した自動テストの適用範囲が広がり、工数削減や開発効率化の恩恵を受けられるようになります。

これまで設計の良さは人間にとっての可読性・保守性を高める効果が期待されてきましたが、これからの開発では 設計と自動テストが相互に補完し合いながら プロジェクトを進めるためにも寄与すると考えられます。

最後に

今回の試みは、生成AIを活用した自動テスト作成の可能性と課題を明確にするものでした。設計の重要性を改めて認識するとともに、今後も設計と生成AIの相乗効果による効率的な開発フローを模索していきます。

明日は、米山さんによる「Android15(APIレベル35)への対応について」です。お楽しみに!


  1. LLMアプリ開発のプラットフォームサービス。プロンプトや各パラメータを設定するだけで使用可能なチャットボットアプリを構築できる。 https://dify.ai/jp
  2. AIモデルはGPT-4oを使用。
  3. この記事内に登場するコードは全て例示用に作成したものであり、食べログのプロダクト内で使われているコードには由来していません。