生成AIでE2Eテスト自動生成に挑戦した話

こんにちは、Anti-Patternの塚本です。
弊社の開発チームでは、生成AIを積極的に活用しています。開発ツールとしてVSCodeを使用しており、開発時はRoo CodeやGitHub Copilotを活用しています。

Roo CodeはVSCode上で動作するAIコーディングアシスタントで、コード生成やリファクタリング、テストコードの自動生成などを支援してくれるツールです。今回は、このRoo Codeを使ってE2Eテストを自動生成する検証を行った際の経験を共有します。

トークン上限エラーとの遭遇

今回、E2Eテストを生成するために、oapi-codegenで生成した以下のファイルをRoo Codeに読み込ませました

  • client.gen.go: 15,475行
  • types.gen.go: 1,345行

テスト対象となるメソッドは160以上。これらすべてに対してE2Eテストを自動生成しようとしたところ、プロンプトのトークン数がmax値を超えてエラーが発生してしまいました。読み込ませるファイルが大きすぎたのです。

解決策:AIにAI用のツールを作ってもらう

そこで、読み込ませるファイルを適切に分割するシェルスクリプトを、生成AI自身に作ってもらうことにしました。

私が投げたプロンプトは、シンプルにこれだけです

意味ある単位で分割するシェルを作ってください

かなり曖昧な指示でしたが、生成AIはしっかりとしたシェルスクリプトを作成してくれました。何度か修正を重ねる必要はありましたが、最終的に期待通りの動作をするツールが完成しました。

生成されたシェルスクリプト

生成AIが作成してくれたシェルスクリプトは以下のような仕組みになっています

  • 特定のパターン(構造体定義、インターフェース定義、関数定義など)を検出
  • 各セクションの開始行を特定
  • 意味のある単位でファイルを分割
#!/bin/bash

# 使用法: ./split_client_gen.sh <入力ファイル.go>

input_file="$1"
output_dir="client_gen_split_output"

# パターン定義
P_CLIENT_STRUCT_START='^type Client struct'
P_CLIENT_INTERFACE_START='^type ClientInterface interface'
P_CLIENT_METHODS_START='^func \(c \*Client\) GetAuthInfo\('
P_REQUEST_BUILDERS_START='^func NewGetAuthInfoRequest\('
P_APPLY_EDITORS_START='^func \(c \*Client\) applyEditors\('
P_CLIENT_WITH_RESPONSES_STRUCT_START='^type ClientWithResponses struct'
P_CLIENT_WITH_RESPONSES_INTERFACE_START='^type ClientWithResponsesInterface interface'
P_CLIENT_WITH_RESPONSES_METHODS_START='^func \(c \*ClientWithResponses\) GetAuthInfoWithResponse\('
P_RESPONSE_STRUCTS_START='^type GetAuthInfoResponse struct'
P_PARSE_FUNCTIONS_START='^func ParseGetAuthInfoResponse\('

# 各セクションの行番号を検出し、チャンクに分割
# ... (詳細は省略)

完全なスクリプトは記事末尾に掲載しています。

分割結果

シェルスクリプトは、client.gen.goを意味のある単位で以下の11ファイルに分割してくれました

  1. 01_preamble.go - パッケージ宣言とインポート
  2. 02_client_definition.go - Clientの構造体定義
  3. 03_client_interface.go - Clientのインターフェース定義
  4. 04_client_methods.go - Clientのメソッド実装
  5. 05_request_builders.go - リクエストビルダー関数群
  6. 06_apply_editors.go - エディター適用関数
  7. 07_client_with_responses_definition.go - ClientWithResponsesの構造体定義
  8. 08_client_with_responses_interface.go - ClientWithResponsesのインターフェース定義
  9. 09_response_structs.go - レスポンス構造体群
  10. 10_client_with_responses_methods.go - ClientWithResponsesのメソッド実装
  11. 11_parse_functions.go - パース関数群

今回生成するE2Eテストは、08_client_with_responses_interface.goに定義されている160以上のinterfaceメソッド全てが対象です。

結果

ファイルを分割することで、トークン数の上限を超えることなく、スムーズにE2Eテストの自動生成ができるようになりました!

AI時代のコード設計:発見と気づき

この経験を通じて、個人的にいくつかの気づきがありました

ファイル数は多くても1ファイルは小さく

ファイル数が増えることを恐れず、1ファイルあたりのサイズは小さく保つ方が良いと感じました。AIがコンテキストを理解しやすいようです。

冗長性よりも明確性

人間の視点では「共通化すべき」と思える部分も、あえて冗長なままにしておく方が、AIには理解しやすいようです。

「人間が読みやすいコード」から「AIが理解しやすいコード」へ

これまでの時代、当然ながら人間がコードを書いていたので、「人間が読みやすいコード」を書くことが重要視されてきました。

しかし、AI時代の今、AIにコードを書いてもらうという前提で考えると、むしろ「AIが理解しやすいコード構造」を意識する必要があるのかもしれません。

これは、コード設計の考え方そのものが変わりつつあることを示唆しているように思います。もちろん、最終的には人間がメンテナンスすることも考慮すべきですが、AIとの協働を前提とした新しいベストプラクティスが生まれつつあるのかもしれません。

生成AIにコードを解析させる際のその他の注意点

最後に今回の経験を踏まえて、生成AI(Claude)にプログラムを解析させる際に気をつけるべき点を聞いてみました。多くは一般的なプログラミングのベストプラクティスと重なりますが、4、9、10はAI活用において重要なポイントだと感じました。

1. コンテキストの明示的な提供

  • ファイル間の依存関係を明示する
  • 使用しているフレームワークやライブラリのバージョンを伝える
  • プロジェクト全体の構造を簡潔に説明する

2. 命名規則の一貫性

  • 変数名、関数名、型名などの命名を一貫させる
  • AIが理解しやすい説明的な名前を使う
  • 略語よりも完全な単語を使う方がAIには理解しやすい

3. コメントの活用

  • 重要なロジックには日本語/英語でコメントを残す
  • AIがコードの意図を理解しやすくなる
  • 特にビジネスロジックや複雑なアルゴリズムには必須

4. 型情報の明示

  • TypeScriptやGoなど、型のある言語では型を明示的に書く
  • AIが型推論に頼らず、確実に理解できるようにする

5. 循環参照の回避

  • ファイル間の循環参照はAIが混乱しやすい
  • できるだけ一方向の依存関係にする

6. 適切な粒度での分割

  • 1つの関数/メソッドは1つの責務に
  • 長すぎる関数はAIも人間も理解しにくい
  • 100行を超える関数は分割を検討

7. 標準的なパターンの使用

  • 一般的なデザインパターンを使う
  • AIは学習データから標準的なパターンを理解している
  • 独自の特殊な実装よりも、広く知られたパターンの方が理解されやすい

8. エラーハンドリングの明示

  • エラーケースを明確に記述
  • エッジケースの処理を明示的に

9. テストコードの活用

  • 既存のテストコードがあれば一緒に読み込ませる
  • テストコードは「仕様書」としてAIの理解を助ける

10. 段階的な解析

  • 一度に全てを解析させず、段階的に情報を提供
  • まず構造を理解させ、次に詳細を解析させる

おわりに

今回の経験を通じて気づいたのは、人もAIも、コードを理解するために必要な情報は本質的に同じだということです。明確な命名、適切なコメント、一貫した構造――これらは従来から「良いコード」とされてきた要素そのものです。AIは、それらを人間よりも高速に解釈できるという点で優れているに過ぎません。

つまり、「AIが理解しやすいコード」を書くことは、結局のところ「人間が理解しやすい良いコード」を書くことと同義なのかもしれません。AI時代だからといって特別なことをする必要はなく、基本に忠実であることが最も重要だと感じました。


参考)生成されたシェルスクリプト

#!/bin/bash

# 使用法: ./split_client_gen.sh <入力ファイル.go>
# 例: ./split_client_gen.sh client.gen.go

input_file="$1"
output_dir="client_gen_split_output"

if [[ -z "$input_file" ]]; then
    echo "エラー: 入力ファイル名を指定してください。"
    echo "使用法: $0 <入力ファイル.go>"
    exit 1
fi

if [[ ! -f "$input_file" ]]; then
    echo "エラー: 入力ファイル '$input_file' が見つかりません。"
    exit 1
fi

mkdir -p "$output_dir"
rm -f "$output_dir"/*
echo "出力先ディレクトリ: $output_dir"

get_line_num() {
    local pattern="$1"
    grep -E -n -m 1 -- "$pattern" "$input_file" | cut -d: -f1
}

extract_chunk() {
    local start_line="$1"
    local end_line_exclusive="$2"
    local out_file="$3"
    local actual_end_line

    if [[ -z "$start_line" ]]; then
        echo "情報: セクション '$out_file' の開始点が見つかりません。空ファイルを作成します。"
        touch "$output_dir/$out_file"
        return
    fi

    if [[ -z "$end_line_exclusive" || "$start_line" -ge "$end_line_exclusive" ]]; then
        actual_end_line="$L_EOF"
    else
        actual_end_line=$((end_line_exclusive - 1))
    fi

    if [[ "$actual_end_line" -lt "$start_line" ]]; then
         echo "情報: セクション '$out_file' の範囲が無効です。空ファイルを作成します。"
         touch "$output_dir/$out_file"
         return
    fi

    echo "抽出中: $output_dir/$out_file (行: $start_line - $actual_end_line)"
    sed -n "${start_line},${actual_end_line}p" "$input_file" > "$output_dir/$out_file"
}

L_EOF=$(wc -l < "$input_file" | xargs)

# --- 各セクションの開始を定義するパターン ---
P_CLIENT_STRUCT_START='^type Client struct'
P_CLIENT_INTERFACE_START='^type ClientInterface interface'
P_CLIENT_METHODS_START='^func \(c \*Client\) GetAuthInfo\('
P_REQUEST_BUILDERS_START='^func NewGetAuthInfoRequest\('
P_APPLY_EDITORS_START='^func \(c \*Client\) applyEditors\('
P_CLIENT_WITH_RESPONSES_STRUCT_START='^type ClientWithResponses struct'
P_CLIENT_WITH_RESPONSES_INTERFACE_START='^type ClientWithResponsesInterface interface'
P_CLIENT_WITH_RESPONSES_METHODS_START='^func \(c \*ClientWithResponses\) GetAuthInfoWithResponse\('
P_RESPONSE_STRUCTS_START='^type GetAuthInfoResponse struct'
P_PARSE_FUNCTIONS_START='^func ParseGetAuthInfoResponse\('

L_CLIENT_STRUCT_START=$(get_line_num "$P_CLIENT_STRUCT_START")
L_CLIENT_INTERFACE_START=$(get_line_num "$P_CLIENT_INTERFACE_START")
L_CLIENT_METHODS_START=$(get_line_num "$P_CLIENT_METHODS_START")
L_REQUEST_BUILDERS_START=$(get_line_num "$P_REQUEST_BUILDERS_START")
L_APPLY_EDITORS_START=$(get_line_num "$P_APPLY_EDITORS_START")
L_CLIENT_WITH_RESPONSES_STRUCT_START=$(get_line_num "$P_CLIENT_WITH_RESPONSES_STRUCT_START")
L_CLIENT_WITH_RESPONSES_INTERFACE_START=$(get_line_num "$P_CLIENT_WITH_RESPONSES_INTERFACE_START")
L_CLIENT_WITH_RESPONSES_METHODS_START=$(get_line_num "$P_CLIENT_WITH_RESPONSES_METHODS_START")
L_RESPONSE_STRUCTS_START=$(get_line_num "$P_RESPONSE_STRUCTS_START")
L_PARSE_FUNCTIONS_START=$(get_line_num "$P_PARSE_FUNCTIONS_START")

# --- チャンク抽出 ---
extract_chunk 1 "$L_CLIENT_STRUCT_START" "01_preamble.go"
extract_chunk "$L_CLIENT_STRUCT_START" "$L_CLIENT_INTERFACE_START" "02_client_definition.go"
extract_chunk "$L_CLIENT_INTERFACE_START" "$L_CLIENT_METHODS_START" "03_client_interface.go"
extract_chunk "$L_CLIENT_METHODS_START" "$L_REQUEST_BUILDERS_START" "04_client_methods.go"
extract_chunk "$L_REQUEST_BUILDERS_START" "$L_APPLY_EDITORS_START" "05_request_builders.go"
extract_chunk "$L_APPLY_EDITORS_START" "$L_CLIENT_WITH_RESPONSES_STRUCT_START" "06_apply_editors.go"
extract_chunk "$L_CLIENT_WITH_RESPONSES_STRUCT_START" "$L_CLIENT_WITH_RESPONSES_INTERFACE_START" "07_client_with_responses_definition.go"
extract_chunk "$L_CLIENT_WITH_RESPONSES_INTERFACE_START" "$L_RESPONSE_STRUCTS_START" "08_client_with_responses_interface.go"
extract_chunk "$L_RESPONSE_STRUCTS_START" "$L_CLIENT_WITH_RESPONSES_METHODS_START" "09_response_structs.go"
extract_chunk "$L_CLIENT_WITH_RESPONSES_METHODS_START" "$L_PARSE_FUNCTIONS_START" "10_client_with_responses_methods.go"
extract_chunk "$L_PARSE_FUNCTIONS_START" "$((L_EOF + 1))" "11_parse_functions.go"

echo "---"
echo "ファイルの分割処理が完了しました。'$output_dir' ディレクトリを確認してください。"