決定的テスト(record & replay / lait test)
lait run --record/--replay と lait test で、API を呼ばずに制御フローを検証する方法。
ワークフローが複雑化(分岐・ループ・サブワークフロー)すると、リグレッション検知の手段が lint だけでは足りなくなります。lait run --record/--replay と lait test は、記録済みの LLM 応答を再利用してモデル API へのリクエストを再送せず、ワークフローの制御フローを検証する仕組みです。
テスト定義と、それが参照する workflow・設定ファイルの読み込み中も Ctrl-C で実行を中断できます。 実行時に読み込む各ファイルは最大16 MiBです。
lait run --record <DIR>
ワークフロー実行中に送信した全ての LLM リクエスト/レスポンスを、<DIR> 以下にカセットファイルとして保存します。
$ lait run workflow.yml "プロンプト" --record ./cassettes
カセットファイルは、ベース URL・モデル・サンプリングパラメータ・メッセージ履歴・ツール定義・response format から計算したハッシュをファイル名にして保存されます(--cache が使うキーと同じ計算方法)。API キーはハッシュにもファイル内容にも含まれません。
lait run --replay <DIR>
--record で作った <DIR> を指定すると、LLM API を呼ばずに、記録済みレスポンスをリクエスト内容に基づいて返します。ワークフロー内の MCP サーバー、run ステップ、shell tool、ファイル操作などは通常どおり実行されるため、それらも含めて外部 I/O を止めたい場合はテスト用の fixture/mock を用意してください。
$ lait run workflow.yml "プロンプト" --replay ./cassettes
- リクエストの内容(ベース URL・モデル・メッセージ履歴・ツール定義・response format)が記録時と完全に一致した場合のみカセットがヒットします。ワークフローや
--inputを変更してリクエスト内容が変わると、以前のカセットはヒットしなくなります。 - 未記録の LLM リクエストはエラーになります。LLM API へのネットワーク fallback はありません — 「記録し忘れたリクエストが静かに実モデルへ飛んでしまう」ことはありません。MCP などワークフロー側の外部 I/O はこの制限の対象外です。
--record/--replayは同時に指定できません。
lait test <FILE_OR_DIR>...
テスト定義 YAML を一括実行し、pass/fail を報告します。ファイル/ディレクトリの両方を指定でき、ディレクトリは再帰的に .yml/.yaml を探索します(隠しファイル・隠しディレクトリ、および target/node_modules ディレクトリはスキップ)。探索結果はパス順に並び、同じ実体を複数の引数(ディレクトリとファイルの重複など)から指定しても1回だけ実行されます。
探索時は通常ファイルだけを対象にし、シンボリックリンクをたどりません。明示したファイルは拡張子に関係なくテスト定義として扱いますが、明示したパスがシンボリックリンクまたは特殊ファイルの場合はエラーになります。ディレクトリ内で見つかったシンボリックリンクと特殊ファイルは無視されるため、循環リンクによって探索が終わらなくなったり、FIFO・ソケットなどを誤ってテストとして読み込んだりすることはありません。
複数のテストファイルは最大8件まで同時に実行されます。各ファイルはそれぞれ独立したワークフロー実行なので、あるファイルの workflow: に write: ステップがあり、かつ別のテストファイルの workflow: も同じ相対パスに書き込む場合、両者が同時に走ると競合します(for_each の max_concurrency が2以上の本体で固定パスへの write を禁止しているのと同種の問題ですが、こちらは静的検査の対象外です)。テスト定義ごとに書き込み先を分けるか、ScratchDir 相当の使い捨てディレクトリを使ってください。
テスト定義ファイルのスキーマ
# tests/summarize-case1.yml
workflow: ../summarize.yml # このファイルからの相対パス
input: "要約したい長文..." # 省略可(既定は null)。ワークフローの最初の入力
inputs: # 省略可。ワークフローの inputs: に渡される({{ inputs.<name> }})
lang: ja
replay: ./cassettes/case1 # `lait run --record` で事前に作ったディレクトリへの相対パス(必須)
assert:
- type: equals # 完全一致
value: "期待する完全な出力"
- type: jq # jq 式が真になること
expr: 'contains("結論")'
| フィールド | 説明 |
|---|---|
workflow |
テスト対象のワークフローファイル(このテスト定義ファイルからの相対パス)。 |
input |
省略可(既定は null)。ワークフローへ渡す初期値。YAML の値がそのまま(型付きで)渡され、ワークフローの input_schema: で検証されます。 |
inputs |
省略可。ワークフローが宣言した inputs: に渡す値のマップ。YAML の値がそのまま(型付きで)渡され、各値は inputs: の宣言で検証されます。 |
replay |
必須。lait run --record で事前に作ったカセットディレクトリへの相対パス(このテスト定義ファイルからの相対パス)。 |
assert |
出力・実行軌跡に対するアサーションのリスト。 |
assert: の各項目の type: に指定できる値は次のとおりです。
type |
主なフィールド | 説明 |
|---|---|---|
equals |
value |
出力が value と完全一致すること。 |
contains |
value |
出力に value が部分文字列として含まれること。 |
jq |
expr |
expr を出力に対して評価し、真(jq の真偽値ルール)であること。出力テキストがそのまま有効な JSON であればその値に対して、そうでなければ JSON 文字列としてラップした値に対して評価されるので、contains("...") のような文字列アサーションと .title | length > 0 のような構造化アサーションのどちらも書けます。 |
llm_judge |
criteria、任意で model/threshold |
LLM による採点(出力品質の評価 を参照)。 |
tool_called |
name、任意で min/max/args_jq |
ツールが呼ばれたこと(trajectory アサーション、後述)。 |
usage |
任意で max_prompt_tokens/max_completion_tokens/max_total_tokens/max_cost_usd |
トークン使用量・コストの上限(trajectory アサーション、後述)。 |
step_output |
id、assert |
名前付きステップ自身の出力に、ネストした assert: を適用(trajectory アサーション、後述)。 |
assert:の各項目は上から順にすべて評価されます。- 実行はワークフロー全体を
--replay相当で走らせるので、記録されていない LLM リクエストに当たった場合はそのテストファイル自体が失敗として報告されます(他のテストファイルの実行は継続します)。MCP や command などの外部 I/O を使うワークフローは、決定的にするために fixture/mock を参照する構成にしてください。
実行軌跡(trajectory)のアサーション
最終出力だけでなく、ワークフロー実行中に何が起きたか(実行トレースが記録する内容)も
assert: で検査できます。lait test(1テストファイルにつき専用の RunContext)・lait eval
(各ケース/リピートごとに専用の RunContext、出力品質の評価を参照)のどちらでも、
これらのアサーションは常にその1回の実行だけを見ます。
assert:
- type: tool_called # ツールが呼ばれたこと
name: tool__ripgrep # 修飾済みツール名(mcp__<server>__<tool> / agent__<name> / tool__<name>)
min: 1 # 省略時 1
max: 3 # 省略時無制限
args_jq: '.pattern == "TODO"' # 省略可。呼び出し引数の生 JSON に対する jq 式(いずれか1回の呼び出しが満たせばよい)
- type: usage # トークン使用量・コストの上限
max_prompt_tokens: 2000
max_completion_tokens: 500
max_total_tokens: 2500 # いずれも省略可(省略したフィールドは検査しない)
max_cost_usd: 0.01 # 省略可。該当モデルに pricing:([設定ファイル](/config#コスト概算pricing))が
# 設定されていない場合は検査されない(コスト0ではなく不明として扱う)
- type: step_output # 名前付きステップ自身の出力を検査
id: summarize # そのステップの id
assert: # ネストした assert: (equals/contains/jq/llm_judge/更なる step_output など)
- type: contains
value: "結論"
tool_called/usage/step_outputはどれも 実行トレース が記録する情報を参照します。--trace-fileを指定していなくてもlait test/lait eval自体は常に内部でトレースを収集している ので、これらのアサーションを使うのに--trace-fileは不要です。step_outputのassert:にさらにtool_called/usageをネストした場合、それはそのステップに 限定されず実行全体を対象に評価されます(ツール呼び出しのトレースは現状どのステップから呼ばれた かまではassert:から絞り込めません)。lait evalでも同じ語彙が使えます。--repeat・複数ケースは並行実行されますが、各 (ケース, リピート) は専用の実行コンテキストを持つため、他のケース/リピートのイベントやトークン使用量が混ざることは ありません(詳しくは出力品質の評価を参照)。
実行例
$ lait test tests/
tests/summarize-case1.yml: PASS
tests/summarize-case2.yml: FAIL
assertion 1: jq expression `contains("結論")` was false for output "まだ途中です"
1 passed, 1 failed, 2 total
--format json
$ lait test --format json tests/ | jq '.[] | {file, status}'
各テストファイルにつき file/status("pass"/"fail")/failures(失敗理由の配列。pass なら空)を持つ配列を返します。
終了コード
1件でも fail があれば非ゼロで終了します(CI 向け)。
典型的なワークフロー
- 実際のモデルに対して一度
lait run workflow.yml "入力" --record tests/cassettes/case1を実行し、期待する応答をカセットとして記録する。 tests/case1.ymlにそのカセットを参照するテスト定義を書き、assert:で期待する出力を固定する。- 以降はモデルを呼ばずに
lait test tests/で、ワークフローの変更が制御フロー・テンプレート・jq 加工などを壊していないかを検証する(CI で毎回実行できる)。MCP や command などを含む場合は、外部 I/O を fixture/mock に固定する。 - モデル自体の出力品質(要約が的確か等)の回帰検知は、record & replay とは別の観点として、別途
lait eval相当の仕組みで扱います。