AI にテストコードを書かせる前に知っておくべきこと【Gemini 編】
AI コーディングエージェントを使うことを考えずに書いたコードの単体テストを Gemini CLI を使って書かせてみました。
使用モデル
Gemini の使用モデルは Gemini 2.5 Flash、Kiro は Claude Sonnet 4.0 です。文中で Kiro と比べていますが Gemini 側には不利なモデル選択になっています。
が、結果的には低いグレードのモデルでも調整の効いたテストコードを出力できたので Gemini も悪くないのでは? もっと活用できるようにサブエージェントとか自作スラッシュコマンドに対応して欲しいですね。
はじめに
AI コーディングエージェントの登場によって、プログラミング言語ではなく自然言語を使って開発を行えるようになったという認識は間違いで、
「ガチガチに縛りがあるプログラミング言語から、制限がほぼ存在しない Markdown という形式を使うようになった。しかし実際には暗黙的に書くべき書式・記述方法が存在する、仕様書というクソ言語を使って開発を行う形に変わっただけである。」
という感じでしょうか。仕様書はプログラミング言語と違ってエラーが出ないので扱うのが難しいです。Amazon Kiro がその事実に対処し、明確にワークフローとして落とし込んだことで、なんとなく感じていたフワっとした認識が鮮明になったように思います。
以下略
コーディングエージェント自体は、
- ネットから集めたコードの断片を切り貼りしてそれっぽいコードを生成する。
- コンパイラーやリンター・アナライザーに生成結果を丸投げする。
- エラーメッセージが出たら、エラーメッセージ自体を自分自身にプロンプトとして投げる。
- 1 に戻る。
って感じで、実はすごくないんじゃないかと思っていますが、Gemini と Claude を比べるとやっぱり学習モデルも重要なのかなーと思ったりもします。
Gemini は文字列のエスケープが出来てない状態でステップ2まで進んで、「文字列のエスケープ忘れてますよ」というエラーメッセージが出ないので一生エージェントループから抜けれなかったりしたことも。。。
なんとなく、Claude には以下で公開されているシステムプロンプト以外の暗黙的に使われるプロンプトが存在していて、学習結果よりもそれが効いているのでは? と思ったりしますが。。。どうなんでしょうね。
テスト対象の概要
HybridList という Queue と List の良いとこ取りを目指したものが今回のテスト対象です。
-
TryTakeFirstメソッドは要素の並び替えを省いてoffsetのみを進める高速化- 👉 offset を考慮し忘れたメソッドでエラーが起きそう!
以下略
- Unity でも
AsSpan(),AsMemory()(非同期メソッドに必要)を使って高速アクセス -
AsList()(ArraySegment ==IEnumerable<T>)を通して既存 API にアロケ無しで引き渡し可能 -
Queue<T>と違ってRemove(T)が可能 - 所謂 SwapRemove がデフォで
StableRemove(要素の順序を維持するヤツ)も出来る -
AddUniqueとAddRangeUniqueを使ってSet<T>っぽい動作- (ハッシュマップじゃないのでぽいだけ)
- 戻り値が(失敗:-1 成功:リストの要素数)なので、規定値までキューが貯まったらループを抜ける処理がほんのちょっと楽に。
- 追加した後に Count を調べるよりはスレッドセーフに近い
-
TryTakeLastでStack<T>っぽくもなる -
GetRefUnsafe(index)で要素のref参照を取得- インデックス系の処理はどうやってもアトミック操作に出来ないので苦肉の策(名前に Unsafe を付けたい)
- 値の代入は
list.GetRefUnsafe(0) = xxx;みたいに書く
- せっかくなので
IBufferWriter<T>も実装
スレッドセーフ版
内部に HybridList を抱えて IProducerConsumerCollection<T> を実装しただけのスレッドセーフ版も用意。都合に合わせて BatchRequest を使ってロックの頻度を調整できるので、自前でやるよりちょっとだけ便利。
lock (concurrentList.BatchRequest(out HybridList<int> rawList))
{
// IBufferWriter
array.CopyTo(rawList.GetSpan(array.Length));
rawList.Advance(array.Length);
// 先に容量を割り当ててから追加(AddRange すれば自動でやってくれる)
rawList.EnsureRemainingCapacity(3);
rawList.Add(1);
rawList.Add(2);
rawList.Add(3);
}
lock 構文と違って using は try-finally ブロックの外でインスタンス化を行う。なので IDisposable が使えずちょっと変なメソッドになっている。using も lock と同じように try ブロック内でインスタンス化するべきなんでは?
特に 1. の offset 更新を伴う処理とその後に続くメソッドの動作にバグがありそうで、ガッツリテストという要望がありました。
Gemini 向け指図書とプロンプト
今回は DO NOT 構文に日本語を使ってみました。ちょこちょこ更新した結果、記載場所が不適切だったり内容の整合性が取れていない部分もありますが、このまま Gemini に渡しています。
# 単体テストの作成
# ルール
- DO NOT `System.Diagnostics.Debug.Assert` 以外を使う
- DO NOT モックを作る
- DO NOT ネットワーク処理を行う
- DO NOT ファイルアクセスを伴う処理を行う
- DO NOT 非同期処理を行う
- DO NOT テスト間で共有する目的の、書き換え可能な値やインスタンスを使用する
- DO NOT 一つのテストメソッド内で複数の対象に対するテストを行う
# テスト対象
- `IHybridList` の関数とプロパティー
- `HybridList` の `public` メンバーとコンストラクター
- `IHybridList` に含まれている関数とプロパティーを除く
- `ConcurrentHybridList` の `public` メンバーとコンストラクター
- `IHybridList` に含まれている関数とプロパティーを除く
**IMPORTANT** テスト対象の**実装**に対するいかなる考察も不要。
# テスト方法
- 関数のオーバーロードがある場合はテストを分けること
- 関数に戻り値がある場合は必ずテストを行うこと
- `public` 関数のパラメーターのテスト方法
1. 各パラメーターごとのテスト
- テスト対象のパラメーター以外は規定値または単純な値を使用すること
2. パラメーターの可能な限りの組み合わせのテスト
- 数が30を超える場合はその旨を `TODO` コメントとして残し、残りの組み合わせのテストはスキップする
**IMPORTANT** 関数カバレッジ以外は 100% にならなくても良い。代わりに不足しているテストを行う方法を `TODO` コメントとして残すこと。
**IMPORTANT** どのようにテストを行うかは考えなくて良い。テスト実行の為のアプリの作成も不要。
# テストの構成
`RunAllTests` メソッドを作成し、実装したすべてのテストメソッドを呼び出す。
1. テスト対象のインスタンスをインターフェイス型で受け取る。(可能な場合)
- インターフェイス型で受け取るテストメソッドに対しては、`RunAllTests` メソッド内からインスタンスを渡す。例: `TestMethod(new ConcreteTestTarget());`
2. テストに必要なセットアップを行う
3. 失敗した場合の理由を詳細に出力するテストコードを記述する
4. 作成したインスタンスを破棄する(破棄方法がない場合は何もしない)
**IMPORTANT** 決められた規則でテストが作成できない場合はコメントを残す。例: `// TODO: <テスト対象の名前> <テストが実装できなかった理由>`
完成までのプロンプトの記録
結果記録が残るので良いんですが、ターミナルで日本語打ちづらいのどうにかなりませんかね?
(誤字ママ)
- @TASK.md に従って .cs ファイルの単体テストを `tests` フォルダーに作成してください。
- テスト対象毎にメソッドを分けてください。
- @SCENARIOS.md に従って追加のテストを別ファイルとして作成してください。
- 実装したテストの中の `IHybridList<T>` を全て `IHybridList<int>` に変更してください。新たに追加された `GetRefUnsafe(int)` を使って、リスト操作によって変化したリスト内の要素の検証も行ってください。※ テスト用の要素として 0 は使用しないでください。
- テストメソッドの冒頭で Clear を行わないでください。テストでは複数の要素を追加して検証を行ってください。
- Clear メソッドのテストが見当たりません。以下の構成で実装してください。 1. インスタンスを作成し要素を追加し Clear を実行。 2. 要素をクリアしたインスタンスを、既に実装されているテストメソッドに渡す。 3. 実装されているすべてのメソッドに対して 1, 2, を繰り返す。
- インターフェイス型を受け取るすべてのテストメソッドに対して、次の要領でテストを実装してください。 `var list = new(); list.AddRange(...); list.Clear(); TestMethodX(list);`
- 既存のメソッドを書き変えないでください。TestIHybridListClear 内で行ってください。
- まず、すべての実装済みのテストメソッドをリストアップしてください。
- リストアップしたメソッドに対して `var list = new(); list.AddRange(...); list.Clear(); <ListedUpMethod>(list);` を繰り返す**新たなテストメソッド**を実装してください。
- RunAllTests メソッドは一切編集しないでください。
- X という名前のメソッドを定義しろ。その中で実装しろ。
- X という名前のメソッドを定義しろ。その中で実装しろ。(2回目)
- 新しいメソッドは問題ありません。ただ、list に多雨する要素の追加(AddRange)が不足しているのですべての繰り返しにたして追加してください。
- OKです。次に、今実装したメソッドの、`Clear` を `TryTakeFirst` を使って内容を全てクリアする処理に書き換え、**新たなメソッド Y として追加してください。**
- 次のタスクです。シナリオテストにも同様の修正を適用できますか? ファイル内容を確認し、修正プランを示してください。
- 修正プランの 4, 5 をなくした修正プランを示してください。
- 作業途中のようです。一度内容を確認してみてください。
印象ベースですが Gemini は返事に「承知いたしました。」の枕が付いている時は「内部モード変わった?」ってぐらい性能が良くなる感じがします。
Debug.Assert を指定する理由
出力結果の一部抜粋です。まあ普通ですね。
private static void TestIHybridListAdd(IHybridList<int> list)
{
Debug.WriteLine($"Testing {list.GetType().Name}.Add");
int initialCount = list.Count;
int newCount = list.Add(10);
Debug.Assert(newCount == initialCount + 1, $"Add failed. New count is {newCount}, expected {initialCount + 1}.");
Debug.Assert(list.Count == initialCount + 1, $"Add failed. List count is {list.Count}, expected {initialCount + 1}.");
Debug.Assert(list.GetRefUnsafe(initialCount) == 10, "Add failed. Item value is incorrect.");
}
TypeScript 関係で散々な目にあったから警戒し過ぎているだけで、この程度のコードは長々とした指示を行わないで「単体テストを実装して」だけで出してくれる可能性はあります。
「DO NOT
static修飾子の使用」というルールを加えるべき。
Debug.Assert を指定しているのは、テストフレームワークやアサーションライブラリの依存を排除したいといった意図ではなく、単なるテストコードなので余計なコンテキストを排除し、とにかく決まったパターンしか書けないようにしたいというのが理由です。
Claude Sonnet 4.0 + Amazon Kiro と比べると Gemini の書き出すテストコードは碌なもんじゃなく、余計なコンテキストが加わると「テクニック」を駆使した挙句に失敗したりします。
失敗するだけなら良いんですが、エージェントループの中で、
-
AをBに直す。 - コンパイラー/リンターに生成結果を投げる。
- エラー内容を自分自身に指示として投げる。
-
BをAに直す。 - コンパイラー/リンターに生成結果を投げる。
- 1 に戻る。
というループを、放っておいたら枠を使い切るまで繰り返していた事もあります。
で、色々と試した結果 Debug.Assert が一番良いんじゃないかと思っています。
value.shouldBe(expected)Assert.That(value, Is.XXX)
これらは(欧米人目線だと特に)分かりやすいし、自分で書くときは楽かもしれませんが「AI にテストを書かせて結果をレビューする」という視点では、インデントが揃っている Debug.Assert の最初の引数の条件文だけを見ればいい、shouldBe なのか shouldNotBe なのか、後方を確認する必要が無いというのはとても楽です。Debug.Assert を F3 連打で見ていけば良いだけです。
// 欲を言えばメッセージのインデント揃えさせたいが指示が難しい
Debug.Assert(newCount == initialCount + 1, $"Add failed. New count is {newCount}, expected {initialCount + 1}.");
Debug.Assert(list.Count == initialCount + 1, $"Add failed. List count is {list.Count}, expected {initialCount + 1}.");
Debug.Assert(list.GetRefUnsafe(initialCount) == 10, "Add failed. Item value is incorrect.");
// 英文系アサーションだとガタガタっすね! UX 最悪です!!
newCount.shouldBe(initialCount + 1);
list.Count.shouldBe(initialCount + 1);
list.GetRefUnsafe(initialCount).shouldBe(10);
// 拡張メソッドを直接呼ばせるという手もある(こういう時はヨーダ構文が良い)
// 順序を強制するために expected, actual の順番でパラメーターを定義した専用メソッドを作る?
shouldBe(expected: initialCount + 1, actual: newCount);
shouldBe(expected: initialCount + 1, actual: list.Count);
shouldBe(expected: 10, actual: list.GetRefUnsafe(initialCount));
&&を Debug.Assert で使うなという指示も併せて出したほうが良さそうです。リスト操作後の要素チェックで一気に判定して、Array elements are invalid的な纏め方をされてしまったことがあります。
失敗したところ
- 最後に
/memoryを使って積み上げたプロンプトが最終的にどのような指示に化けているのか確認し忘れました。無念。 -
@TASKS.mdに不足していると思うことを挙げてください。というステップを忘れていて、コレを最初にやっておけば無駄なやり取りを防げた可能性。- Kiro は基本的に AI を通して文書を作成する。手動で書き換えた場合も基本的には AI による校正が入る。(怪しい英語表現が全部直る等)
- リスト操作後の値の検証を行うコードは生成されているが、リスト操作後に内部バッファーが適切な状態になっているかの検証が行われていない。
- そもそもコチラが指示していない問題。
-
T[]やList[]等の最適化パスを通る IEnumerable と、そうでない IEnumerable を対象にテストしていない。- 同上。
3 に関しては C# 的には、
var list = new HybridList(new int[1, 2, 3]);
var rawBuffer = list.AsListUnsafe(); // ArraySegment<T>
// 範囲外のバッファーにもアクセスできる
var prev = rawBuffer.Array[rawBuffer.Offset - 1];
var next = rawBuffer.Array[rawBuffer.Offset + rawBuffer.Count];
といった感じで検証可能なので言われなくてもやれるんですが、指示を出さなかったしアクセス方法も一般的ではないので、「あーー 全部言わなきゃ分かんない感じかーー(ニチャァ」というよりは、指示の仕方に問題があったという感じでしょう。この辺りは AI に限らずですね。
生成結果に対する所見
「実装に対するいかなる考察も不要」という指示が悪さをした可能性がありますが、例外を投げる可能性のあるメソッドが例外を投げるかのテストが完全に抜けた結果でした。指示としては以下のようなモノの方が良かったかもです。
- 確認したテスト対象の実装に合わせてテストを書くのではなく、テスト対象に期待される動作に基づいてテストを実装してください。
- 「実装に対するいかなる考察も不要」ってなんだよって感じですね。分かりづらすぎたw
- そもそも言うまでもないことですが Gemini の場合は言っておいた方が良い印象。
- テスト対象に期待されていることが明確でない場合は、代わりに TODO コメントをファイルの冒頭に記載してください。
- 「ファイルの冒頭」と明確に指示しないと Gemini はテストメソッドの中に流れでコメントを残す。
- 例外を投げる可能性があるメソッドは、期待通りに例外を投げるかテストしてください。
- 普通にそのままを伝える。
Gemini は特にそうですが、コーディングエージェントなのにシステムプロンプトが空っぽなのか、言わないと何もしてくれません。良く言えばユーザー側に全て委ねられているって感じです。
対して Kiro(Claude Sonnet 4.0 側かも?)は、Requirements、Design、Task list それぞれのステップで適切な指示が暗黙的に与えられているように見受けられ、まあ言わないでもそうなるよね? って結果が得られます。
実は文章の記述方法に秘密があるのでは? と思い上記の文章は Kiro が生成する文章に寄せた記述方式をしています。が、やっぱり文章の構成や記述方式ではなくモデルの出来とチューニングの方が大事っぽいです。Gemini はオートパイロットだと厳しくて、付きっきりで修正内容を確認して調整しないとダメです。(頻度は低いけど Claude も無限テスト編に突入することはある)
※ Kiro は README.md を作るという作業にも 100 行弱の Requirements を出力するので、そもそもコチラの指示が曖昧なだけという可能性も。
@TAKS.md では例外を投げるかの確認が行われませんでしたが、@SCENARIOS.md シナリオテストの方では一部実装されていたので、タスク指示の文章が長かったのがマズいのかもしれません。ルールに記載の「DO NOT 一つのメソッド内で複数の対象に対してテストを行う」も守られていませんでした。
ただ、最初に出力された結果は一つの対象「インスタンス」に対してテストを行っているという点では間違っていなかったので、ルールで対象「メソッドやプロパティー」と明確に記載しなかったのが原因の可能性もあります。文脈から明らかである部分でも、必ず主語・目的語を「明確かつ詳細に」「しつこい位に」記載する必要があります。
ゲームの英訳なんかで重要視されていますが、対象が単数なのか複数なのかもかなり重要なようです。「a object」なら察してくれますが「まあ 1 object(s) でええやろ」という英語分らん勢の感覚はダメっぽいです。しっかりやらないと「対象が分からん」どころではなく「文章全体の意味が分からん」位に捉えられている感じがあります。
テスト対象の指定
当初、「IHybridList で定義されているメンバー」と記載したところ全然ダメな結果になりました。明確に「関数とプロパティー」と指定しないとダメなようです。
具象型に関してはメンバーという指定で問題ありませんでしたが、メンバーだとコンストラクターが含まれませんでしたw この辺りは「凡例/言葉の定義」等を ~/.gemini/GEMINI.md の項目として作っておいた方が良いかもですね。
モックを作らせない理由
これは直近で枠を使い切るまでモックを作るのに失敗し続けたから、というのが主な理由です。これと合わせて無駄なモック作りにチャレンジしないようにカバレッジの目標値も定めています。
失敗し続けたときに使っていたモックを作るライブラリは AI が選定したもので、どのような状態だったかは詳細に確認していないのですが、
-
Invalid value等ではなく詳細なエラーの理由を投げるか。- 行全体を出力しその中でエラーになっている部分に波線
~や^が付くと尚よい。 - ※ 出力が「エラーです」だけだとエージェントループが無駄に回るだけで何も解決しない。
- 行全体を出力しその中でエラーになっている部分に波線
- 日本語に対応「していない」か。エラーメッセージに英語を指定できるか。
- (AI がエラーメッセージをそのまま咀嚼するので)
- AI が迷うような、いろいろな書き方や知っていると便利なテクニックが「無い」か。
ライブラリを選定するにあたっては、この辺りが重要でしょうか。
コンパイラーやリンター・アナライザーが詳細なエラーを投げるのは重要で、コーディングエージェントはそれらの詳細なエラーメッセージを自分自身へのプロンプトとして受け取ることで機能している、という側面があります。
テストカバレッジの目標値の設定
カバレッジの目標値を設定をしているのはモックを作らせない理由と被ります。
コーディングエージェントは概ね以下のループを繰り返しています。
- コード生成
- テストカバレッジの算出
- 100% になっていなかったら 1. に戻る
このループの中でエラーを出しまくったことがあります。(TypeScript/JavaScript)
myMethod() : void
{
systemLibrary.doSomething();
myOtherClass.foo(); // 👈
}
myOtherClass.foo() というメソッドはそれ自身に対する単体テストが行われる状態です。そしてそのテストは AI が生成したものです。
で、AI は次に myMethod 内で呼び出される myOtherClass.foo が「エラーで失敗したときのテスト」として一生懸命にモック作りに励み、失敗し続け利用枠を全て使い切りました。
モッキングライブラリを使ったり、以下のような「ザ・JavaScript」って感じのコードを生成してはエラーを出していました。
// myMethod のテスト内
const originalFunc = myOtherClass.foo;
myOtherClass.foo = () => { throw new Error(...); } // インスタンスメソッドの書き換え!?
//...なんか色々
myOtherClass.foo = originalFunc; // テスト用のインスタンスなんだから元に戻さなくて良くね
システムライブラリのようなプロジェクト外のコードに対しては、モックを作るような変な操作やエラーを起こすかも? な実装を行っていませんでした。
myOtherClass もそれと同様の扱いにするにはどうすれば良いか、
- テスト実装済みのメソッドがエラーを出した場合のテスト
という、した方が良いんだろうけど、、、なテストを実装させない効率的な指示の出し方を考えた結果、辿り着いたのが「関数カバレッジ以外は 100% じゃなくても良い」という目標値を設定でした。
目的はレビュー負荷の高い、謎テクニックの使用や考えすぎた余計なテストコード生成をさせないことで、「○○かもしれない」「もし△△だったら?」等はシナリオテストとして別枠で詳細に記述し生成します。AI は余計なモノは頼まなくても作るのに欲しいものは勝手に作ってくれませんから。
テスト方法の指定
テストの方法はちゃんと指示を出します。出さないと実装できません。(出来ますがコンパイルエラーが出ないだけの動く何かです)
テストの実装だけではなく何かしらのアプリの実装時もですが、基本的に「テスト」「インターフェイス」「○○アーキテクチャ」「○○パターン」とか、そういう曖昧な指示は全て NG です。メンバーですらダメで関数とプロパティー、コンストラクターと言わなければならないのが AI です。
逆に一切指示を出さずお任せでやるか、指示するなら「適用できるデザインパターンがあったら適用してください」位の方が良いです。
注意しなければならないのはデザインパターンなんかより目的・目標設定の方が大事だという事です。例えばインターフェイスを使って制御を反転することはあらゆるクラスで適用できるパターンです。目標設定をしないと無意味にあらゆるクラスにインターフェイスを定義・実装するクソコーダーが生まれます。
Kiro は README.md を作ってという依頼に対して「デベロッパー目線」「ユーザー目線」等の User Story を要件の中に勝手に追加します。その位大枠の指示だけ出して、逆に実装は任せた方が良いのでしょう。AI は「パターンの暗記と適用」は得意です。が、それをどこで使えばイイかは考えられません。考えている「ふり」をしているだけです。必ず指示が必要です。
Mermaid 記法
AI にコードを書かせる場合は必ずアーキテクチャ図、必要ならクラス図も書きます。コーディングエージェントを使うときは省略することも出来ますが、実際には必ず書かなければならないものだと思います。
作図には Mermaid を使います。というのも厳格な文法が定められており AI エージェントは Mermaid 記法の意味するところを理解できるからです。Kiro もめっちゃ Mermaid で書きます。
下手な説明をするぐらいなら表現と記法が一対一で対応している Mermaid がベストだということです。仕様書を書くときは AI が理解できて表現にブレのない、別の解釈を行う余地が一切ない書式を積極的に使いましょう。
駄文
今回、HybridList<T> の各メソッドを abstract にして ConcurrentHybridList<T> が HybridList<T> を継承し各メソッドをオーバーライドする、という手法は取りたくなかったので IHybridList<T> というインターフェイスを定義しています。(こうすると具象型を握ればメソッドを直接呼出しに出来る)
オブジェクト指向におけるインターフェイスは複数の型を区別なく扱いたい(=抽象化したい)時に使うもので、それとは関係なく転用したテクニックの一つとして制御の反転(IoC Inversion of Control)等があります。
基本的にインターフェイスを定義する必要はありませんし、「あらゆる型にインターフェイスを定義するべき」というもの間違いです。インターフェイスは素晴らしいモノじゃないです。なんなら
「様々なインターフェイスを切り貼りして無理矢理形にする(本当は抜本的に治した方が良い)」
みたいなことにもなりかねません。インターフェイスは必要になった時に必要な数だけ定義すれば大丈夫です。IDE にインターフェイスの抽出という機能があるのはそれが理由です。作んなくて大丈夫です。
おわりに
生成したテストを通したところ、エンバグしそうだと思っていた offset 関連ではエラーが起きず、別の場所で3つほどケアレスミスをしている部分を見つけることが出来ました。
AI のおかげで「面白い部分だけ自分でやる」が実現できそうで良いですね!
以上です。お疲れ様でした。
Discussion