You can't trust agent tests

Alex O'Callaghan

エージェントが生成したテストは信用できない

53件のEnzymeテストスイートをReact Testing Libraryに移行するのにエージェントを使いました。テストはすべて通り、コードも一見まとまって見えました。しかし、よく見てみると、リグレッションを検出できないテスト、誤った対象を検証しているテスト、そもそも何もテストしていないに等しいものが見つかったのです。

エージェントが生成したテストでは、こうした問題は見逃しがちです。出力は洗練されて見え、コードは綺麗で、しかも大量にあるからです。

EnzymeからRTLへの移行

Enzymeのテストは、長年バックログに低優先度の技術的負債として積み残されていました。これはまさに、エージェントが得意とされる、定義が明確で機械的な作業です。私は複雑さごとに分割し、自作のprepare-mr-skillを使ってコミットを整理し、説明文を書かせました。そしてうまくいきました。少なくとも、うまくいったように見えました。テストは移行され、パスし、コードの変更も妥当に見えたのです。

マージする前にいつもどおりチームレビューにかけたところ、一見正しく見えてもリグレッションを検出できないテストが見つかりました。例えば、特定のARIAロールを持つ要素がレンダリングされていないことを検証するテストがありましたが、そのロール自体が無効なものであり、そもそもそのコンポーネントがその要素をレンダリングすることはあり得ませんでした。また、disabledaria-disabledの混同もありました。この2つは挙動が異なり、アクセシビリティ上も重要な違いがありますが、見た目は似ているため、一見もっともらしいテストが間違ったままパスしてしまうのです。

なぜこうなるのか

この問題はエージェント特有のものではありません。手書きでも同じようなテストは簡単に書けてしまいます。ただ、人が不適切なアサーションを書く場合、たいていコンポーネントを理解しているため、「このテスト、簡単すぎないか」と違和感に気づくことがあります。エージェントにはそうした直感がありません。実装からパターンマッチして、もっともらしく見えるものを作って次へ進むだけです。さらに、エージェントが生成するコードの量が多いため、こうした問題はすり抜けやすくなります。

実装がすでに存在する場合(テストの移行、カバレッジの補完、何年も前に書かれたテストの検証など)、テスト駆動開発(TDD)が本来持つ自然な安全装置は機能しません。動いているコードに対してテストを書くため、誤ったアサーションが失敗する機会がないのです。これを見つける唯一の方法は、意図的にコンポーネントを壊してみることです。一時的にコンポーネントを変更し、テストがそれを検出することを確認してから元に戻します。

検証を確実に行わせるには

最初に思いついたのは、プロンプトを修正してエージェントにこうしたミスをチェックさせることでした。RTL移行用のskillを更新し、すべてのテストを失敗する状態で検証することを明示的に求めるようにしました。

しかし、安定しては機能しませんでした。エージェントは要件を一応考慮し、ときには問題を見つけるものの、そのまま先に進んでしまいます。検証を制約としてではなく、チェックボックスのように扱っていたのです。

問題の一因は構造にあります。大量のテストを一括で移行させようとすると、タスクが長くなりコンテキストが膨らみます。コンテキストが膨らむにつれて、モデルは指示へのフォーカスを失い始めます。一部のモデルでは、コンテキストウィンドウの上限に近づくと作業を早めに切り上げようとする「コンテキスト不安」も見られます。こうした要因によって、エージェントは検証の指示を忘れたり、スキップしたりするようになるのです。

そこで、移行を一括ではなくテストスイートごとに実行してみました。コンテキストウィンドウを短くすれば、完了へのプレッシャーが減り、検証のステップにじっくり取り組む余裕が生まれると考えたからです。確かにエージェントがチェックする可能性は高まりましたが、すべてのテストではなく「主要なテストをいくつか」だけ検証する、という判断をすることが多くありました。

最終的に、移行とレビューのステップを2つの別々のエージェントタスクに分けることにしました。各テストスイートをループ処理し、移行用とレビュー用の2つのプロンプトを順番に実行するbashスクリプトを書きました。

#!/bin/bash

echo "Starting migration..."

declare -a files=(
  "path/to/test-suite.test.tsx"
)

for i in "${files[@]}"; do
  echo "Migrating $i"

  agent -p --force --output-format stream-json --stream-partial-output --model claude-4.6-sonnet-medium \
    "Migrate $i to use RTL. Ensure linting passes and then commit your changes in a single commit."

  agent -p --force --output-format stream-json --stream-partial-output --model claude-4.6-sonnet-medium \
    "Review the RTL tests in $i -

		* Identify any unnecessary tests, remove them
		* Identify any tests not following best practices, fix them
		* For every single test intentionally change the implementation to break the specific feature tested and confirm the test fails as expected, fix them if they don't

		Ensure linting passes and then commit your changes in a single commit."

  echo "Migrated $i"
done

検証に焦点を絞ったタスクにしたことで、エージェントは自らToDoリストを作成し、各テストを徹底的に確認しました。結果として問題を洗い出し、レビューで見つかったもの以上の改善も行ってくれました。

コストはどうなったか

この方法はうまくいきましたが、コストは大幅に増えました。すべての移行はCursor上でclaude-4.6-sonnet-mediumを使い、22件のテストスイートを対象に実行しました。

アプローチコスト
一括移行~$13
スイート単位~$32
スイート単位+レビュー~$62

スイート単位でのレビューパスは、人間のレビュアーが以前に指摘したすべての問題を解消しましたが、一括移行と比べるとコストは約5倍になりました。

エージェントのおかげで、これまでバックログに眠っていたモダナイゼーションの作業をこなせるようになりました。しかし、信頼できる結果を求めてきちんとやろうとすると、思った以上にお金がかかります。やるならきちんとやるべきだと私は考えていますが、たとえ所要時間が大幅に短縮されたとしても、実際の金銭的コストとのトレードオフは存在します。

組織は、こうして高まった生産性をどう収益の向上につなげるのかを意識的に考える必要があります。AIベンダーへの支払いが増えるだけで終わらせないためにもです。

まとめ

2つのプロンプトを使ったスクリプトは、優れたプロンプトだったわけではなく、異なるアーキテクチャでした。1つのエージェントに移行と検証を同じパスでやらせると、タスクの構造自体が検証の妨げになります。ある工程が繰り返し抜け落ちる場合、解決策はたいていより良い指示ではありません。その工程をスキップできないワークフローにすることです。

ここでレビュアーが概念的に行っているのは、ミューテーションテスティングです。アサーションごとに、コンポーネントに変更を加えたらテストが失敗するかを問うています。Strykerのようなミューテーションテスティングツールをテストパイプラインに組み込めば、こうした問題の検出に役立ち、エージェントに手作業でやらせるコストも削減できるでしょう。

エージェントのワークフローを設計する時間も、仕事の一部です。エージェントによる生産性向上を評価する際には、この時間も考慮すべきです。それが、信頼できるアウトプットと、一見信頼できそうに見えるだけのアウトプットの違いを分けるのです。

原文は Alex O'Callaghan により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。