GitHubのトレンドで tester-army/e2e を見かけました。READMEの冒頭にあった一文が気になって、そのまま手を動かしています。
An agent step that a later assertion verifies records its actions, and the next run replays them with no model calls until the app changes.
自然言語で「こういう操作をして」と書くとエージェントが画面を動かし、後続の検証が通った操作は記録され、次回からはモデルを呼ばずに再生する、という主張です。
私はQAを7〜8年やっていて、AIにE2Eを任せる話が出るたびに引っかかる点があります。毎回モデルが画面を解釈し直すなら、結果が揺れるし、実行のたびにお金もかかる。逆に記録して再生するなら、それは昔ながらのキャプチャ&リプレイと何が違うのか。そして一番気になるのは、アプリが壊れたときに、記録済みの再生が「通ったこと」にしてしまわないかです。テストは目的ではなく手段で、壊れたものを壊れたと言えなければ意味がありません。なので今回は、正常系を流すより先に「ボタン名を変える」「送信を失敗させる」という2つのいたずらを用意してから試しました。
試した環境
検証はすべて使い捨てのDockerコンテナ(node:22-bookworm、メモリ4GB・CPU2)の中で行い、終わったら破棄しています。
| 項目 | 内容 |
|---|---|
| 対象 | npm の e2e 0.17.0(当日 07:13 UTC 公開)と @e2e-dev/web 0.12.0。リポジトリの HEAD は f99e80e で、packages/e2e の版も 0.17.0 |
| 実行環境 | Debian 12.14 / Node.js v22.23.1 / npm 10.9.8 / playwright-core 1.63.0 / Chrome Headless Shell 153.0.8010.12 |
| モデル | 有料のモデルは使わず、コンテナ内に立てた OpenAI 互換のモックを @ai-sdk/openai-compatible 3.0.62 で接続。モックは台本どおりのツール呼び出しを返し、受け取った要求をすべてファイルに残す |
| テスト対象のアプリ | 自作の1ページだけのニュースレター登録フォーム(メール欄・ボタン・結果表示)。環境変数でボタン名を変えたり、送信を失敗させたりできるようにした |
導入は軽いです。npm install -D e2e@0.17.0 @e2e-dev/web は3.8秒で、node_modules は約47MB。重いのはブラウザのほうで、npx playwright-core install --with-deps chromium に27秒かかり、~/.cache/ms-playwright は688,010,292バイトになりました。中身を見ると、ヘッドレス用の Chrome Headless Shell(約273MB)だけでなく通常の chromium(約410MB)も入っていました。
npx e2e init --yes を叩くと、設定ファイルとサンプルテストのほかに、コーディングエージェント向けのスキル一式(.agents/skills/e2e/ に9ファイル)と MCP の設定ファイルまで生成されます。テストを書く側もAIに任せる前提の作りです。生成された e2e.config.ts は Vercel AI Gateway 経由のモデルを指していたので、ここをモックに差し替えました。
最初の1本で、いきなりつまずいた
まずモデルを使わない普通のテストを1本書きました。Playwright を触ってきた手癖で browser.getByLabel('Email') と書いたら、こう落ちました。
$ npx e2e run tests/plain.e2e.ts
FAIL |web| tests/plain.e2e.ts > subscribe without the agent
ERROR: browser.getByLabel is not a function
❯ tests/plain.e2e.ts:6:17
❯ screen .e2e/artifacts/web/tests_plain.e2e.ts__subscribe_20without_20the_20agent-0ae3cbf7/default/attempt-0/failure/screen.txt
# 失敗時に残った screen.txt の中身
# Screen at failure
url: http://127.0.0.1:8080/
revision: b1
viewport: 1280x720
nodes: 5
#root document "Newsletter"
#n2 heading "Newsletter"
#n3 text="Email"
#n4 textbox "Email"
#n5 button "Subscribe"
同梱のドキュメント(node_modules/e2e/docs)を読むと、画面の操作と検証は screen、browser はリクエストのモックやダイアログ、ダウンロードのような「ブラウザ固有の操作」用、という分担でした。ドキュメントの例ではクリックを tap() で書き、アクセシブルネームは第2引数にそのまま文字列で渡しています。screen.getByLabel('Email').fill(...) と screen.getByRole('button', 'Subscribe').tap() に直したら、282msで通りました。このときモックへの要求は0件です。
今思えば、この失敗で残った screen.txt が一番の手がかりでした。画面をアクセシビリティツリーとして #n4 textbox "Email" のような番号付きの一覧で表していて、後で見るとおり、モデルに渡される画面もまったく同じ形式だったんです。
モデルの代わりにモックを置いた
エージェントを使うテストと、モックへの差し替えはこうしました。
// e2e.config.ts(抜粋)
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
const mock = createOpenAICompatible({ name: 'mock', baseURL: 'http://127.0.0.1:4000/v1', apiKey: 'dummy' });
// agents.default.model に mock.chatModel('mock-model') を渡す
// tests/agent.e2e.ts
test('the agent subscribes', async ({ app, agent, screen }) => {
await app.open('/');
await agent.act('subscribe to the newsletter with the email agent@example.com');
// 後続の検証。この1行が通ったときだけ、上の操作が記録される
await expect(screen.getByRole('status')).toContainText('Subscribed: agent@example.com');
});
// 注意: モックは「type(n4) → tap(n5) → complete_step(passed)」の台本を返すだけ。
// エージェントの判断の質はこの検証では測れていない(ここは自分の使い方次第で、本物のモデルでの確認は別途必要)
モックが受け取った最初の要求は約20.6KBでした。うちシステムプロンプトが4,494バイト、23個のツール定義がJSONにして15,582文字。ツールは tap・type・select・drag・screenshot といった画面操作に、締めの complete_step(passed / failed / blocked を返す)が加わった構成です。ユーザー側のメッセージには「Current screen (revision b2, path /, 5 nodes)」に続けて、さっきの screen.txt と同じ一覧が入っていました。モデルは画面を画像ではなくこの一覧で読んで、n4 のような番号で操作を指示するわけです。
本番前に、わざとツールを呼ばないモック(「noop」という文字列だけを返す)でも流してみました。e2e は返答のたびに「[SYSTEM] Your last reply had no tool call, so nothing happened.」と促し、21回目の要求には「Only 5 turns remain for this step」という警告が加わります。最後の2回はツール定義を complete_step だけに絞っていて(要求が約25.1KBから約11.0KBに縮んだ)、それでも呼ばないと25回目で STEP_NO_CONCLUSION: the agent used 25 of 25 turn(s) without calling complete_step として失敗しました。ソースを読むと、この25回は maxModelCalls という予算で、agents の設定で変えられるようです(変更は試していません)。返事をしないモデルが無限にお金を使い続けない作りになっているのは、運用側としてはありがたいところです。
記録して、もう一度流す
ここからが本題です。台本どおりに動くモックで1回目を流し、同じテストをもう一度流しました。
# 1回目(記録)
✓ the agent subscribes > agent.act "subscribe to the newsletter with the email agent@example.com" 658ms · 3 model calls
Cache 1 missed
# .e2e/cache/8e3108...792.json(1,461バイト。抜粋)
"actions": [
{ "name": "type", "summary": "type \"agent@example.com\" into textbox \"Email\"",
"target": { "role": "textbox", "name": "Email" }, "value": "agent@example.com" },
{ "name": "tap", "summary": "tap button \"Subscribe\"",
"target": { "role": "button", "name": "Subscribe" } }
],
"endAnchors": [
{ "role": "status", "name": "Subscribed: agent@example.com" },
{ "role": "textbox", "name": "Email", "value": "agent@example.com" }
],
# 2回目(再生)。モックに届いた要求は 0 件
✓ the agent subscribes > agent.act "subscribe to the newsletter with the email agent@example.com" 234ms
Cache 1 replayed
記録されるのは座標でもCSSセレクタでもなく、ロールとアクセシブルネーム(textbox “Email”、button “Subscribe”)でした。加えて「操作のあと画面に何が現れたか」を endAnchors として持っています。2回目はモックに要求が1件も届かず、agent.act の所要は658msから234msに縮みました。
READMEの主張どおりです。ただ、QAとして見たいのはここから先なので、アプリのほうをいじりました。
ボタン名を変える、アプリを壊す
各回の結果を並べるとこうなりました。所要時間は agent.act の1ステップ分です。
| 回 | アプリの状態 | キャッシュ | モデル呼び出し | 所要 | テスト |
|---|---|---|---|---|---|
| 1. 記録 | ボタン名 Subscribe | missed | 3回 | 658ms | 合格 |
| 2. 再生 | 変更なし | replayed | 0回 | 234ms | 合格 |
| 3. ボタン名を変更 | Join the list に変更 | handed off(2操作中1つ目の後で target-not-found) | 3回 | 16.96s | 合格 |
| 4. 3の記録を再生 | Join the list | replayed | 0回 | 2.29s | 合格 |
| 5. 対照:記録し直して再生 | Join the list | replayed | 0回 | 245ms | 合格 |
| 6. —no-cache | Join the list | 使わない(ファイルも更新されない) | 3回 | 563ms | 合格 |
| 7. アプリ故障 | 直前に Subscribe 版で記録し、送信すると Error: try again later を出すように変更 | handed off(2操作とも再生した後で end-mismatch) | 3回 | 実行全体で25.96s | 不合格(最後の expect) |
| 8. 修復後 | Subscribe に戻す | missed(7でキャッシュが消えていた) | 3回 | 792ms | 合格 |
ボタン名を変えた3回目は、いきなりエラーにはなりませんでした。メール欄への入力までは再生し、「Subscribe」が見つからないところで待ち、実行開始から約15秒後に最初のモデル呼び出しが来ています。7回目の故障ケースは、操作自体は2つとも再生できたのに、記録したときの最終状態(Subscribed: … の表示)が出ないことを再生側が見つけていました。どちらも、モデルに渡された引き継ぎの文面がよくできています。
# 3回目:ボタン名を変えたとき、モデルへの最初の要求
Cached replay already performed these recorded actions for this step:
1. type "agent@example.com" into textbox "Email"
Replay stopped (target-not-found) after 1 of 2 recorded actions.
Continue the step from the CURRENT screen state shown below — do NOT redo the actions above.
...
#n4 textbox "Email" value="agent@example.com" [focused]
#n5 button "Join the list"
# 7回目:送信が失敗するアプリのとき、モデルへの最初の要求
Replay stopped (end-mismatch) after 2 of 2 recorded actions.
Every recorded action ran, but the screen does not show the recorded end state. Check whether the step actually took effect before doing anything — the recorded flow may have silently failed to commit.
# 7回目:モック(AI役)は complete_step で passed と答えたが、最後の1行で落ちた
ASSERTION_FAILED: expect.toContainText failed
locator: getByRole("status")
expected: text containing "Subscribed: agent@example.com"
observed: text "Error: try again later" (match count 1)
7回目のポイントは、私のモックが画面を見もせずに「passed」と答えていることです。わざとそういう台本にしました。それでもテストは不合格になっています。止めたのはAIの判定ではなく、テストに私が書いた expect の1行でした。しかも、この失敗のあと .e2e/cache/ の記録ファイルは消えていて、アプリを直した8回目はキャッシュなしの状態から記録し直しています。壊れた状態で「通った記録」が残り続けない、というのは確認できました。
正直なところ、ここは一番疑っていたところでした。「AIが合格と言ったら合格」になる作りだったら採用はないなと思っていたんですが、少なくとも今回の構成では、最後に決定的な検証を1行置いておけば、再生でもAIの自己申告でも故障を素通りしませんでした。動いた=品質OK ではない、という当たり前のことを、ツールの側がちゃんと前提にしてくれている感触です。
想定外:余計な一手もそのまま記録される
表の4回目と5回目は、狙って作った比較ではありません。3回目の引き継ぎで、モックは「do NOT redo」と言われたのに台本どおりメール欄にもう一度入力していました。値が同じなので画面は変わらず、e2e は「No listed node changed within the wait after this action」と返して、その待ちで2秒ほど使っています。
困ったのはその後で、3回目の成功をもとに書き直された記録には、この重複した入力がそのまま入っていました。type が2回、tap button "Join the list" が1回の3操作です(ファイルは1,705バイト。2操作だった最初の記録は1,461バイト)。これを再生した4回目は、モデル呼び出しこそ0回でも2.29秒かかりました。キャッシュを消して同じアプリで記録し直した5回目は、操作が2つに戻って245msです。2つの記録の違いはこの一手なので、差の大半はここだと見ています。
本物のモデルなら引き継ぎの指示を守る可能性は高いと思います(今回は確かめていません)。ただ、「後続の検証が通った操作は、無駄な手順も含めて記録される」こと自体は仕組みとして確認できました。.e2e/cache/ は既定で .gitignore に入り、コミットするかどうかは任意(e2e init がそう案内していました)なので、コミットする運用にするなら、この記録ファイルの差分はコードと同じようにレビューしたほうがいいと感じました。npx e2e cache ls は記録ごとの ACTIONS 数を一覧で出してくれるので、数が増えた記録を見つけるだけならすぐできます。
受け入れ基準に落とすなら
チームに入れるとしたら、私は最低限これを受け入れ基準として書きます。ここは好みが分かれるところですが、どれも今回の観測から逆算したものです。
| 受け入れ基準(例) | 根拠にした観測 |
|---|---|
| agent.act のあとには、人が書いた決定的な expect を必ず1行置く | AI役が passed と答えても、故障を止めたのは expect だった(7回目) |
| CIの結果で handed off と missed の件数を見て、増えたら理由を確認する | 実行の最後に replayed / handed off / missed が出る。引き継ぎ1回で約15秒の待ちが乗った(3回目) |
| キャッシュをコミットするなら、記録ファイルの差分(操作の数と対象名)をレビュー対象に含める | 余計な入力1回が記録に残り、再生が245msから2.29sになった(4回目と5回目) |
| 失敗の切り分けでは —no-cache の実行を1回挟む | —no-cache ではモデルを3回呼び、記録ファイルは書き換えなかった(6回目) |
試してみての所感
使い捨てコンテナの生存時間は全部で6分18秒でした。モデルを呼ばない普通のテストなら Playwright とほぼ同じ感覚で書けて、AIを使うステップも「記録が効く条件」と「効かなくなったときの振る舞い」が実行結果とキャッシュファイルから全部見えます。ブラックボックスになりがちなAIテストで、ここまで中身を追えるのは好印象でした。
様子見なのは、本物のモデルで引き継ぎがどれだけ賢く振る舞うかと、モバイル側のエンジンです。今回はモックに台本を読ませただけなので、エージェントの判断力そのものは何も測れていません。次は、手元の検収用シナリオを1本だけ移して、本物のモデルで missed と handed off がどのくらいの頻度で出るかを数えてみるつもりです。テストは目的ではなく手段なので、数えた結果で「どのステップをAIに任せて、どこに決定的な検証を置くか」を決めたいと思います。