私は社内ツール用の小さなAPIを作っていました。コードエージェント(この場合はClaude Codeですが、Cursorやopencodeでも同じでした)に、退屈な作業を私の代わりにやらせたかったのです。つまり、追加した各エンドポイントに対してハッピーパスのテストを書き、実行し、何かが壊れたら直す、といったことです。
「write(書く)」の部分は最高でした。Claudeは最初のショットで、妥当なテストを生成してくれました。「run(実行)」も問題ありませんでした。
問題になったのは「fix(直す)」の部分です。
Jestが吐き出す典型的な失敗例がこれです:
AssertionError: expected 200 to equal 404
at Object.<anonymous> (/path/to/test.js:14:23)
at processImmediate (node:internal/timers:483:21)
エージェントはこれを読み、「URLが間違っている」と推測してテストを修正します。うまくいくこともありました。でも実際には、別の問題であることが多い。たとえばサーバーがそもそも動いていない、レスポンスの形が {"uuid": "..."} から {"request": {"uuid": "..."}} にずれている、ボディはJSONとしては合っているのにアサーションのJSONPathが間違っている、あるいはレスポンスが返ってくる前にタイムアウトが発火している、といった具合です。
これらはすべてstderr上では同じように見えます。全部が、文としては「200 != 404」という一文に潰れてしまう。エージェントにはそれらを見分ける手段がないので、同じ修正形(URL変更)を繰り返し、当たるのはたぶん30%くらいでした。
私はいくつかの応急処置を試しました。よりリッチなエラーメッセージを追加したり、スタックトレースを雑に推論して解釈したり。でも、最初の修正での正確さはせいぜい50%程度までしか上がりませんでした。十分ではありません。エージェントを放っておいてループが収束するのを信じられるラインには達していないのです。
アンロックはより良いstderrではなく、構造化されたもの
モデルは、もっと雄弁なエラーメッセージを必要としていません。必要なのはデータです。
テストランナーが、失敗時に次のようなJSONの形で返してくれるなら:
{
"failure_category": "assertion_failed",
"error_code": "TARN-A-STATUS-MISMATCH",
"expected": 200,
"actual": 404,
"request": { ... },
"response": { ... },
"hints": [
"Status 404 often means the URL or HTTP method is wrong.",
"Check the endpoint exists and that the path matches your route registration."
]
}
そうすれば、エージェントの分岐ロジックが明確になります:
-
failure_category == "connection_error"→ サーバーに到達できない。テストは触らず、base_urlを確認して、開発サーバーをkillして再起動する。 -
failure_category == "timeout"→ タイムアウトを延ばすか、サーバーの性能を見に行く。アサーションは変更しない。 -
failure_category == "assertion_failed"かつTARN-A-STATUS-MISMATCH→ レスポンスボディとURLを見る。おそらくエンドポイントかメソッドが間違っている。 -
failure_category == "assertion_failed"かつTARN-A-BODY-SHAPE→ レスポンスの形が変わっている。JSONPathを更新し、URLは触らない。 -
failure_category == "capture_error"→ 前のステップのアサーションは通ったが、$.idを抽出できなかった。そのレスポンスの形が(データが)ドリフトしている。 これに魔法はありません。文章ではなくデータなだけです。エージェントは6状態のenumなら分岐を簡単にできます。文に対しては、確実に分岐することができません。
私はそれを作りました。
Tarn — それが実際に何か
Tarnは、Rustで書いたCLI-firstのAPIテストツールです。賭けの対象は契約です。つまり、すべての失敗は、安定したカテゴリ、安定したエラーコード、そして修復(リメディエーション)のためのヒント一覧と一緒に返ってくる。
テストは.tarn.yamlファイルです。最小構成の例:
name: Health check
steps:
- name: GET /health
request:
method: GET
url: "{{ env.base_url }}/health"
assert:
status: 200
あえてYAMLにしています。モデルはすでにYAMLを知っています。教えるべきDSLはありません。ブートストラップするためのテストフレームワークもありません。LLMが.tarn.yamlファイルを書いて、あなたがtarn runを実行すれば、それで動きます。
もう少し現実的なテスト:
name: User CRUD
env:
base_url: "http://localhost:3000/api/v1"
tests:
create_and_verify:
steps:
- name: ユーザーを作成
request:
method: POST
url: "{{ env.base_url }}/users"
body:
name: "Jane"
email: "jane.{{ $random_hex(6) }}@example.com"
capture:
user_id: "$.id"
assert:
status: 201
body:
"$.id": { type: string, not_empty: true }
- name: ユーザーを検証
request:
method: GET
url: "{{ env.base_url }}/users/{{ capture.user_id }}"
assert:
status: 200
body:
"$.id": "{{ capture.user_id }}"
{{ $random_hex(6) }} は組み込みの faker なので、実行のたびに一意のメールアドレスが生成されます。capture は作成レスポンスから $.id を取り出して型を保ちながら取り込みます(string のまま string、number のまま number——下流の JSONPath のアサーションで重要です)。2つ目のステップでは {{ capture.user_id }} を URL とアサーションの両方に補間します。
デフォルトの人間向け出力:
$ tarn run tests/users.tarn.yaml
● ユーザー CRUD / create_and_verify
✓ ユーザーを作成(123ms)
✓ ユーザーを検証(45ms)
結果: 1 テストが成功(180ms)
エージェントや CI 向けには JSON を要求します:
$ tarn run tests/users.tarn.yaml --format json --json-mode compact
このコマンドにより、failure_category、error_code、リクエスト、レスポンス、キャプチャ、所要時間など、すべてを含む完全な機械可読の実行レポートが得られます。成功したステップは要約され、失敗したステップには完全なリクエストとレスポンスが含まれるため、エージェントは何も再実行せずに問題を診断するために必要なすべてのバイトを手にできます。
エージェントループの実践
こちらが、私が新しいエンドポイントに対して Claude Code とペア作業するときに実際にどのようなループになるかの例です。
私: 「POST /users/:id/avatar を multipart のアバターアップロード用に追加した。テストを書いて。」
Claude Code は tests/avatar.tarn.yaml に multipart アップロードのステップを書き込みます。そして tarn run tests/avatar.tarn.yaml --format json を実行します。
出力(失敗):
{
"tests": [{
"name": "アバターをアップロード",
"status": "failed",
"steps": [{
"name": "POST avatar",
"failure_category": "assertion_failed",
"error_code": "TARN-A-STATUS-MISMATCH",
"request": {
"method": "POST",
"url": "http://localhost:3000/api/v1/users/abc-123/avatar",
"multipart": [{"name": "file", "filename": "avatar.png", "size": 4321}]
},
"response": {
"status": 400,
"body": {"error": "missing field 'avatar'"}
},
"hints": [
"ボディに 'missing field' を含む 400 をサーバーが返しました。multipart フィールド名がサーバーの期待と一致していることを確認してください。"
]
}]
}]
}
Claude は failure_category: "assertion_failed" を読み、フィールドがないというヒントを見て、レスポンスボディ — missing field 'avatar' — とリクエスト — name: "file" — を確認します。YAML を name: "avatar" を使うように修正します。再実行。グリーン。
合計往復: おそらく 30 秒。途中に人は介在しません。面白いのは、エージェントが 推測 する必要がなかったことです。分岐に使える failure_category があり、最初に読むべきヒントがあり、そして確認のためのリクエスト/レスポンスがありました。
MCP — making it tool-native
次に作ったのは tarn-mcp で、Model Context Protocol を実装するサーバーです。Claude Code がシェルアウトして tarn run --format json を実行し、stdout をパースする代わりに、型付きの MCP ツールを直接呼び出せます。
ツール:
-
tarn_run— テストまたはディレクトリを実行し、構造化された JSON を返す -
tarn_validate— 実行前の構文チェック -
tarn_fix_plan— 障害レポートを消費し、構造化された修正提案を出力 -
tarn_inspect— 特定の失敗 (file::test::step) を、レポート全体をパースせずに掘り下げる -
tarn_rerun_failed— 失敗している(file, test)ペアだけを再実行 -
tarn_diff— 2 つの実行レポートを比較し、失敗をnew/fixed/persistentに分類 - その他いくつか
これを
.mcp.jsonで設定します:
{
"mcpServers": {
"tarn": {
"command": "tarn-mcp"
}
}
}
これで、Claude Code、opencode、Cursor、Windsurf — MCP を話せるものなら何でも — これらをツールとして呼び出せます。より高速で壊れにくく、エージェントが毎回同じ stdout 形式を再パースしてトークンを無駄にしなくなります。
What surprised me
始めたときに想定していなかった、実際の利用から見えてきたことがいくつかあります:
1. YAML は、構造化された失敗よりも重要だった。 構造化-JSON-失敗のやつが本題になると期待していました。それはその通りです。でも、エージェントの初手の正確さを大きく押し上げたのは、テスト形式を Jest スタイルの DSL から素の YAML に切り替えたことでした。初回で正しくできる割合が多分 60% から多分 90% へ。モデルは、どんなテストフレームワークの DSL よりも、よりきれいな YAML を生成します。これは私が見積もっていた以上に大きかったです。
2. 単発の失敗ではなく、失敗の連鎖が本当の問題。 手順 3 が失敗したのが手順 2 が capture: $.id をできなかったためだとします。すると手順 4、5、6 は、無関係な理由で全部失敗として表示されます(存在しない user_id を使おうとしていたからです)。素朴なエージェントはまず手順 6 を直そうとします — しかし手順 6 は一見ちゃんと見えます。混乱が雪だるま式に増えます。Tarn はこれらの連鎖を、単一の根本原因エントリへ潰し込みます — 個別の 5 つの失敗ではなく cascades: 5 としてです。このたった 1 つの変更で、ループが明確に効率的になりました。
3. tarn_fix_plan ツールは、最も不確実な部分。 これは、失敗レポートを消費して構造化された修正 提案 を出力する MCP ツールとして作りました。でも率直に言うと、それが正しい抽象化の粒度なのか分かりません。モデルが単に生の失敗レポートを見て、自分で修正を計画すべきで、tarn_fix_plan は過剰設計なのかもしれません。まだ決めていません。同様のエージェントツールを作ったなら、あなたはどちら側に着地したのかぜひ聞きたいです。
What it deliberately doesn't do
スコープについては率直に言いたいです:
- XPath / HTML アサーションなし。 HTML のスクレイピングなら Hurl の方が向いています。
- Hurl 風のフィルタ DSL 全体なし。 フィルタの深さなら Hurl が勝ちます。
- OpenAPI-first なテスト生成なし。 人々はずっと聞いてきますが、これはエージェントのループに適したやり方だとはまだ確信できていません。モデルはとにかく非形式的な仕様からテストを生成するので。
- GUI なし。 Bruno はとても優秀です。GUI が欲しいなら Bruno を使ってください。Tarn は CI とエージェントループのためです。
- レコード・リプレイなし。 それ用のトレースベースのテスティングツールは存在します。 Tarn の賭けは、AI コーディングエージェントが駆動する write-run-fix のスライスに特化していることです。人間として手書きでテストを書いているなら、Hurl や Bruno の方が幸せになれるはずです。
Try it
API テストが一部として含まれる状況でエージェントループを動かしているなら、Tarn は合うかもしれません。インストールは 1 行です:
curl -fsSL https://raw.githubusercontent.com/NazarKalytiuk/tarn/main/install.sh | sh
tarn init
tarn run
単一の静的バイナリで、musl リンク済みなので Alpine から RHEL まであらゆる Linux で動き、さらに macOS(Intel + Apple Silicon)と Windows にも対応しています。MIT ライセンスです。install.sh は、リリースアーカイブで利用可能な場合に tarn-mcp と tarn-lsp(.tarn.yaml ファイルに対するエディタ内診断のための Language Server)も配置します。
- リポジトリ: https://github.com/NazarKalytiuk/tarn
- ドキュメント: https://nazarkalytiuk.github.io/tarn/
- MCP セットアップ: https://nazarkalytiuk.github.io/tarn/mcp.html 私は特に、3 つの点についてフィードバックを知りたいです:
- JSON の失敗スキーマ(
schemas/v1/report.json) — 失敗カテゴリの分類法は、完全だと感じますか? それとも粗すぎる/細かすぎるでしょうか? tarn_fix_plan(MCP の修正提案ツール)は適切な抽象化でしょうか?それとも、生の失敗だけを出して、修正はモデル側に計画させるべきでしょうか。- あなた の特定のエージェントループにとって足りないのは何ですか?現在のセットアップがあるなら、どんな点があれば乗り換えますか? もしそれで何か作ったなら、GitHub issues に一言書くか、どこかで私に会いに来てください。私は連絡可能です。


