10月23日、thecloudlet.github.ioにて「Pinning Both Ends: Letting AI Rewrite 15k Lines of Test Tooling」と題した記事が公開された。この記事では、AIを活用して約15,000行のテスト生成ツールを約3,500行にリファクタリングした実践的な手法について詳しく紹介されている。
「両端を固定して、中身をAIに書かせる」
作業期間は3.5営業日。変更されたゴールデンファイル(既存の正解出力)は0バイト。これが今回のリファクタリングの結果だ。
手法の核心は単純だ。入力と出力の両端を固定し、その間の実装をAIに丸ごと書き直させる。入力端はパース済みデータ、出力端はゴールデンファイル(バイト単位で一致を要求)。AIが間違いを犯せばテストが赤くなり、AIが自分で修正する。人間はコードレビューだけに集中できる。
ここで言うゴールデンファイルテスト(Golden File Testing)とは、ツールの出力をあらかじめ「正解ファイル」として保存しておき、コード変更後の出力と逐一比較することで意図しない変更を検知する手法だ。スナップショットテストとも呼ばれ、出力の安定性を担保するために広く使われている。
この手法は同著者のPart 1でも用いられており、今回はより大規模な適用事例となる。
対象ツールの惨状
対象となった「Tool Y」は、スプレッドシートからテスト仕様を読み込み、ゴールデンファイルを生成するツールだ。長期間メンテナンスされておらず、構造は以下のような状態だった。
tool-y/
├── config_main/ # スプレッドシートとJSON
├── gen/ # メインのCLIエントリポイント
├── gen_b/ # ジェネレータB:elements.pyとutils.pyを独自にコピー
├── gen_c/ # ジェネレータC:リポジトリ外のスプレッドシートを読む
├── post/ # JSONファイルを介してチェーンされた4つのスクリプト
└── README.md # 手動の後処理手順
問題点を列挙すると:
- コピーが乖離していた。ジェネレータBはメインのコードを複製したもので、今や数千行の差異がある
- ジェネレータBとCはメインのパスを呼ばない。メインへのバグ修正が届かない
- gitの外にある入力。ジェネレータBは共有ドライブからスプレッドシートをダウンロード、ジェネレータCはハードコードされたパスを読む
- エントリポイントが4つ存在し、どれが現行かコードからは判別できない
さらに深刻だったのがスプレッドシートそのものだ。仕様バージョンごとにスプレッドシートがあり、要素タイプごとにタブが存在した。テストケースは行番号でスプレッドシートの行を参照しており、ツールは番号が範囲内かどうかしかチェックしない。実際に有効な行を1行挿入して同じコマンドを実行すると、警告なしに別のゴールデンファイルが生成された。
リファクタリングの4ステップ
Step 1:ゴールデン出力を固定する
コードに手をつける前に、旧ツールを全モードで実行して出力を保存した。これがゴールデンファイルだ。次に、2つの出力ツリーをフィールド値レベルで比較する小さなツールを書き、CIがすべての変更に対してこれを実行するようにした。
Step 2:入力端を固定する(フォーマット選定)
スプレッドシートの代替フォーマットを選定するにあたり、6つの候補を比較した。
| フォーマット | コメント | Gitのdiff | パーサー | 問題点 |
|---|---|---|---|---|
| スプレッドシート(旧) | 不可 | 不可 | サードパーティ | バイナリ、行番号参照 |
| JSON | 不可 | 可 | stdlib | コメントが書けない |
| YAML | 可 | 可 | サードパーティ | 型推測(1.10→1.1) |
| Pythonをconfig代わりに | 可 | 可 | コードそのもの | レシピがプログラムになる |
| Racket DSL | 可 | 可 | 書きやすい | 読めるエンジニアが少ない |
| TOML | 可 | 可 | stdlib(3.11+) | 深いネストが面倒 |
YAMLはversion: 1.10を数値1.1として読む。型を勝手に推測するフォーマットは避けたい。Racketが本命だったが、次の担当者がインタープリタをメンテしなければならない。結果としてTOMLを選択した。コメントが書け、型が明示的で、Python標準ライブラリにパーサーがある(3.11+)。
テストケースは1ブロックのTOMLで表現される:
# Recipes for upstream specification version "1.10".
spec_version = "1.10" # 文字列なので "1.10" のまま保持される
[[case]]
name = "case_001"
test_intent = "<one line: why this case exists>"
kind = "alpha"
mode = "A"
size = 4
strict = true
変換はAIに一度きりのコンバーターを書かせた。スプレッドシートの各行がレシピになり、行番号による参照が名前による参照に変わった。
Step 3:要素タイプを1つずつ、バッチで移行する
まず1つの要素タイプを移行し、パーサー → バリデーター → ジェネレーター → ゴールデンファイルの構造をAIが正しく構築できるかを確認した。次に2つ、5つ、10個とバッチを拡大し、全タイプの移行とすべてのチェックのグリーン化まで続けた。
AIは間違いを犯した。しかしチェックが赤くなり、AIが自分で修正した。固定された両端は安全網であると同時に、AIのフィードバックループでもあった。
1つの注意点がある。今回はゴールデンファイルや比較ツールをAIが誤って編集するのを防ぐ仕組みがなかった。著者は次回はchmodでファイルを読み取り専用にし、CIがエージェントのワークスペース外に保管したハッシュリストと照合するようにすると述べている(エージェントはシェルからchmodも実行できるため)。
Step 4:旧パスを削除する
全モードがバイト単位で一致してから初めて削除した。旧ジェネレーター群、第2のゴールデンパス、スプレッドシートを削除。その後、移行用のアダプターとマイグレーションテストも削除した。存在しないコードをテストし続ける意味はないからだ。
結果
| 項目 | Before | After |
|---|---|---|
| ジェネレーターコード(行数) | 約15,000 | 約3,500 |
| 作業時間(営業日) | - | 3.5 |
| 変更されたゴールデンファイル | - | 0バイト* |
| エントリポイント数 | 4 | 1 |
| git外の入力 | 2 | 0 |
| 対応する上流仕様バージョン | 1 | 複数 |
*上流定義に違反していた旧テストケースは修正または削除し、比較対象から除外した。
この脚注は見逃せない留保事項だ。「0バイト変更」という結果は、比較対象そのものを一部除外したうえでの数字である。上流仕様に照らして不正だったテストケースが除外されているため、厳密には「既存の全出力を保ったまま0バイト変更」ではない。著者もこの点を隠してはおらず、除外の判断はむしろ適切なクリーンアップと言えるが、数字を評価する際には念頭に置いておきたい。
当初依頼された上流仕様バージョンのアップグレードは完了し、下流フローはすでに新しい出力を使用している。「AIがタイピングの大半をこなした。ピンがミスを捕まえ、私はすべてのラウンドを読んだ」と著者は締めくくっている。
詳細はPinning Both Ends: Letting AI Rewrite 15k Lines of Test Toolingを参照していただきたい。