はじめに
前回の記事(導入編)で、Playwright Agents を Claude Code に入れました。
Claude Codeと壁打ちをしながらやった内容をまとめました。
AIが書いて自分がチェックする方法で書いてます。
みなさんがもし個人開発などでPlayWright Agentを使う時の参考にしてください。💪
AIが自動でE2Eテストを計画・実装・修正する Playwright Agents × Claude Codeで E2Eテスト自動化の検証(導入編)
今回は実際に PlayWright Agentsを使って、E2E テストがなかった「いいね」と「ストック」のテストを作りました。対象は、Next.js(App Router)で作っている技術記事の共有アプリです。
先に結果をまとめます。
| 機能 | planner(計画) | generator(テスト生成) | healer(修正) |
|---|---|---|---|
| いいね | 3件の計画。人が4点手直し | 3件とも一発で通過 | 出番なし |
| ストック | 3件の計画。人が4点手直し | 3件とも通過(時々1件タイムアウト) | 呼んだが原因を特定できず |
AIがテストの「計画・実装・修正」を全部やってくれるというよりかは、AIが下書きをし、
それを人が判断というやり方を採用しました。
環境
- Playwright 1.57.0(
npx playwright init-agents --loop=claudeで導入) - Claude Code(エージェントは
.claude/agents/に置いた planner / generator / healer) - Next.js 15 / PostgreSQL(Docker)/ NextAuth v5
進め方
1つの機能ごとに、次の流れで進めました。
-
planner がブラウザを操作してアプリを探索し、テスト計画(
specs/<機能>.md)を書く - 人が計画をレビューして直し、コミットする
-
generator が計画の手順をブラウザで1つずつ実行しながら、テストコード(
e2e/<機能>.spec.ts)を書く - 人がコードをレビューし、テストを実行する
- 落ちたら healer に直してもらう
段階ごとにコミットを分け、コミットの本文に「Agents にどう頼んだか」と「生成後に人が何を直したか」を書きました。あとから履歴を見るだけで、AI と人の分担が追えます。
test: planner でいいね機能のテスト計画を作成
test: generator でいいね機能のE2Eテストを生成
chore: いいねの生成結果をもとにgeneratorの定義とE2Eルールを修正
test: planner でストック機能のテスト計画を作成
test: generator でストック機能のE2Eテストを生成
planner:ブラウザを操作してテスト計画を作る
planner は実際のブラウザを操作してアプリを探索し、テスト計画を Markdown で書きます。この段階で作るのは計画だけで、テストコードはまだ作りません。
こんな感じで動きます。私はブラウザに一切触っていません。
Playwright Agents の planner の動作確認
観点は絞って頼んだ
最初は観点を盛って頼んだところ、探索がかなり長くなりました。そこで、いいね・ストックとも3件程度に絞って頼みました。
- いいね:他人の記事にいいねする/いいねを取り消す/自分の記事ではいいねボタンが押せない
- ストック:ストックすると一覧に出る/解除すると消える/再読み込み後もストック状態が保たれる
計画はそのまま使えない
planner の計画は「いまアプリがどう動いているか」をもとに書かれます。なので、アプリのバグも仕様として書かれうるので、人のレビューが必要です。実際に直した点は次のとおりです。
いいね
- テストファイルを3つに分けていた → 1ファイル(
e2e/like.spec.ts)にまとめた - 押したかどうかを CSS クラス(色)で確認していた → 件数と、押せるかどうか(enabled / disabled)で確認するようにした。見た目の変更でテストが壊れないように
- disabled のボタンをクリックする手順があった → 「押せないこと」の確認だけにした
- 2人目のユーザーに切り替えるためにログアウトを使っていた → 別のブラウザコンテキストで投稿者を作るようにした(理由は次の節)
ストック
- ボタンのラベル「ストック」は「ストック済み」にも部分一致する → ボタンの特定とラベルの確認は完全一致で行う、と明記した
- 「/stocks に他のテストの記事が混ざるかも」という前提が不正確だった → 各テストは新規ユーザーで始めるので混ざらない、と直した
- 解除のシナリオで、ボタンが「ストック」に戻ることを確かめる手順がなかった → 追加した
- 空のときの表示が「〜など」と曖昧だった → 「ストックした記事はありません」と決め打ちにした
planner のおかげでバグが見つかった
いいねの計画づくりで、planner が「詳細ページにログアウトがない」と報告してきました。コードを確かめると、テスト用の logout() ヘルパーが、存在しない data-testid を使っていて壊れていました。
ヘルパーを直して動かしたところ、今度はアプリ側のログアウトの不具合まで見つかりました。ログアウトしたのに、Cookie が書き戻されてログイン状態に戻ってしまう、というものです。
この不具合は別で直すことにして、いいねのテストではログアウトを使わず、投稿者を別のブラウザコンテキストで作る形にしました。
generator:計画からテストコードを書く
generator は、計画の手順をブラウザで1つずつ実行しながらロケーター(要素の指定方法)を確かめ、その操作ログからテストコードを書きます。
いいね:3件とも一発で通った
生成されたコードの一部です。
test('他人の記事にいいねする', async ({ page, browser }) => {
// 1. browser.newContext() で投稿者用のコンテキストとページを作る
const authorContext = await browser.newContext();
const authorPage = await authorContext.newPage();
// 2. 投稿者用のページで、投稿者ユーザーを新規登録・ログインする
const author = createTestUser();
await signup(authorPage, author);
// 3. 投稿者用のページで記事を公開する
const article = createTestArticle();
await createArticle(authorPage, article);
await authorContext.close();
// 5. テストの page で、2人目のユーザーを新規登録・ログインする
const liker = createTestUser();
await signup(page, liker);
await navigateToArticle(page, article.title);
// いいねボタンはアクセシブルネームが件数の数字のみで、記事詳細ページ内で
// 数字のみの名前を持つボタンは他に存在しないため一意に特定できる
const likeButton = page.getByRole('button', { name: /^\d+$/ });
await expect(likeButton).toHaveText('0');
await likeButton.click();
await expect(likeButton).toHaveText('1');
await expect(likeButton).toBeEnabled();
});
※ 記事用に一部のコメントを省略しています。
よかった点です。
- いいねボタンの名前は件数の数字だけです。generator は「詳細ページで、名前が数字だけのボタンはこれ1つ」と画面で確かめてから、
/^\d+$/を選んでいました。件数が 0→1 に変わっても、同じロケーターで追えます - 既存のヘルパー(
createTestUser()/signup()/createArticle()など)を使っていました
generator がもっともらしい未検証の主張をしていた
一方で、generator は最初、次のように書いていました。
- 「
browser.newContext()は config のbaseURLを引き継がない」 - そのため
http://localhost:3000を直書きする
ところが generator が使う MCP のブラウザは1ページしか操作できないので、newContext() を実際には試していません。確かめていない主張です。
一時的なテストを書いて確かめると、baseURL は引き継がれていました。直書きを消して、正しい形に直しました。
もっともらしく書かれていても、確かめたかどうかは別の話です。生成されたテストは必ず読む必要がある、と実感した場面でした。
generator は書いたテストを実行しない
ここは勘違いしやすい点です。generator は各手順をブラウザで試しながら書きますが、書き上げたテストファイルを実行するツールは持っていません(テストを実行できるのは healer だけです)。
なので、テストとして通るかどうかは、人が実行するか healer に任せて確かめる必要があります。いいねは、人がヘッドレスで流して3件とも通りました。
ストック:部分一致に注意
ストックボタンは「ストック」と「ストック済み」でラベルが切り替わります。name: 'ストック' と書くと「ストック済み」にも部分一致するので、完全一致の正規表現で特定しています。
// 「ストック」は「ストック済み」に部分一致するため、両方の状態にマッチする
// ^...$ の正規表現で完全一致させてボタンを特定し、ラベルはtoHaveText()の
// 完全一致で確認する
const stockButton = page.getByRole('button', { name: /^ストック(済み)?$/ });
await expect(stockButton).toHaveText('ストック');
await stockButton.click();
await expect(stockButton).toHaveText('ストック済み');
テストを流す
前提として、DB(docker compose up db -d)と dev サーバー(npm run dev)を起動しておきます。
# 全部をヘッドレスで流す
npm run test:e2e
# UI モードで流す(テストを選んで実行できる)
npm run test:e2e:ui
# テストで作ったユーザー(メールが test- で始まる)を消す
npm run test:e2e:cleanup
テストユーザーは、テストが終わると globalTeardown で自動的に消えます。途中で止めたときなど、残ってしまった分を消すのが test:e2e:cleanup です。
UI モードはこんな画面です。
1つのファイルだけを流す
npx playwright test ファイル名 --ui --workers=1
# ストックなら
npx playwright test e2e/stock.spec.ts --ui --workers=1
並列で流すと落ちる
UI モードで全ファイルを流したら、15件中8件が落ちました。落ちたのは、Agents で作ったテストだけではありません。手書きの既存テスト(AIに書かせたけど)も同じ場所で落ちていました。
- 原因は並列実行でした。複数のテストが同時に新規登録を行い、dev サーバーの処理が追いつかず、登録後の画面遷移がタイムアウトしていました
-
--workers=1(1つずつ実行)にすると通ります - つまり、テストの作り方ではなく実行のしかたの問題です。CI でどう実行するかと一緒に決めることにしました(結果は「CI で E2E を流す」の章で書きます)
AI で作ったテストが落ちると、つい AI のせいにしたくなります。まずは既存テストも同じ条件で落ちるかを確かめると、切り分けが早くなります。
確認のときは、次のコマンドがおすすめです。
npx playwright test --workers=1 --retries=0
設定ファイルでは retries: 1(失敗したら1回リトライ)にしていますが、確認のときは --retries=0 にしています。1回目の失敗がリトライで隠れてしまわないようにするためです。最終的に全18件(手書きの既存11件+seed 1件+今回の6件)が通りました。
healer:今回はうまくいかなかった
ストックのテストのうち1件が、時々 page.goto('/stocks') の途中でテスト全体の30秒を使い切って落ちました。そこで healer に原因の調査を頼みました。
結果、原因は分かりませんでした。
- healer がテストを15回ほど実行しても、すべて通って失敗が再現しませんでした(1回の実行時間は11〜27秒で、30秒に近い回もありました)
- 再現しないので、healer は
/stocksへ移る直前にawait page.pause()を自分で差し込み、デバッグ実行で止めて調べようとしました - そこで MCP の接続が切れ、調査は途中で終わりました
- ファイルには
page.pause()だけが残りました
page.pause() は、テストをその行で一時停止するデバッグ用の命令です。ブレークポイントのようなもので、ヘッドレスの通常実行では無視されます。そのため残っていてもテストは通ってしまい、気づきにくいです。
ここから分かったことです。
- 時々しか落ちない(flaky な)テストは、healer には向かない。healer はまず失敗を再現しようとするので、再現しないと手がかりがありません
- healer がファイルに書いた調査用のコードは、人が確かめて消す。healer はファイルを直接編集できる唯一のエージェントです
- 再現しないときは、healer に渡す前に実行時間を見る。今回は、dev サーバーが遅いと30秒の上限に近づくのが原因だと見ています(未確認)
なお、healer の定義も導入時に直しています。元の定義には「正しいテストが失敗し続けたら test.fixme() でスキップする」とありましたが、これではアプリのバグが隠れます。そこで、テストを変えずに失敗内容を報告するようにしました。
CI で E2E を流す
ここまでは、E2E を手元で流していました。Agents が自動化するのは「テストを書く・直す」ところまでで、テストを流すこと自体は自動になっていません。そこで、GitHub Actions で E2E を流すようにしました。
workflow を分けた
lint・型チェック・Jest は、もともと ci.yml で流しています。E2E はここに入れず、別の workflow(.github/workflows/e2e.yml)にしました。
-
paths(どのファイルが変わったら動くか)は workflow 単位でしか絞れない。ci.ymlに入れると、ドキュメントだけの変更でも E2E が流れてしまう - E2E だけを手動で流したい(
workflow_dispatch) - 最初は必須チェックにしたくない。安定するのを見てから決める
動くのは、アプリのコード・テスト・DB スキーマ・設定が変わった PR と master への push、それに手動実行です。
on:
push:
branches: [master]
paths:
- 'src/**'
- 'e2e/**'
- 'prisma/**'
- 'playwright.config.ts'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/e2e.yml'
pull_request:
branches: [master]
paths: # push と同じ
workflow_dispatch:
※ 記事では pull_request の paths を省略しています。
DB は migrate deploy で作る
Postgres 16 を services(サービスコンテナ)で起動し、テーブルは prisma migrate deploy で作ります。
prisma db push でもテーブルは作れます。ただ、migrate deploy は本番と同じ経路です。空の DB にマイグレーションが最初から全部当たるか、マイグレーションの作り忘れがないかも、一緒に確かめられます。
環境変数はすべてダミー
必要な環境変数は、.env を見るのではなく、コードが読んでいる変数から洗い出しました。どれもテスト専用のダミー値で、GitHub の Secrets は使っていません。
# すべてテスト専用のダミー値(Cloudinary・Gmail は E2E で呼ばないため未設定)
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/e2e'
AUTH_SECRET: 'e2e-dummy-secret'
AUTH_TRUST_HOST: 'true'
APP_URL: 'http://localhost:3000'
GITHUB_ID: 'dummy'
GITHUB_SECRET: 'dummy'
GOOGLE_ID: 'dummy'
GOOGLE_SECRET: 'dummy'
-
AUTH_TRUST_HOST:CI では dev サーバーではなくnpm start(本番モード)で起動します。本番モードの NextAuth は、これがないとホストを信頼せずエラーになります - OAuth(GitHub / Google):E2E ではメールアドレスで登録するので使いません。プロバイダの初期化に値が要るので、ダミーを入れています。本物の値は入れません。CI で OAuth のログインは自動化できず、漏れるリスクだけが増えるからです
- Cloudinary・Gmail:E2E では呼ばないので設定していません
洗い出しの途中で、アプリのコードは GITHUB_ID を読むのに、デプロイの設定では OAUTH_GITHUB_ID という名前で渡していることにも気づきました。E2E とは別の Issue にしています。
CI では本番ビルドで流す
playwright.config.ts は、CI のときだけ webServer でアプリを起動してからテストします。ローカルでは、これまでどおり起動済みの dev サーバーを使います。
// CI=false などの文字列でもローカル扱いにするため、'true' と厳密に比較する
const isCI = process.env.CI === 'true';
export default defineConfig({
// ...
reporter: isCI ? [['html', { open: 'never' }], ['github']] : 'html',
// CI では本番ビルドを起動してからテストする。ローカルは起動済みの dev サーバーを使う
webServer: isCI
? {
command: 'npm run build:ci && npm start',
url: 'http://localhost:3000',
timeout: 300000,
}
: undefined,
});
- dev サーバーではなく本番ビルドにしたのは、「並列で流すと落ちる」の原因が dev サーバーの遅さだと見ていたからです
-
githubレポーターを足すと、落ちたテストの場所が PR の差分に注釈として出ます - 最初は
process.env.CI ? … : …と、値があるかどうかで判定していました。PR のレビューで「CI=falseという文字列でも CI 扱いになる」と指摘され、'true'との厳密な比較に直しました
失敗したときは、playwright-report/ と test-results/ を Artifact として7日間残します。ダウンロードして npx playwright show-report で開けば、ローカルと同じ画面で確認できます。
結果:並列でも全部通った
| 実行環境 | 並列(既定の workers) | 結果 |
|---|---|---|
| ローカル(dev サーバー) | あり | 15件中8件が落ちる |
| CI(本番ビルド) | あり | 18件すべて通過(テストは1.4〜1.8分) |
ローカルで落ちていたのは、やはりテストの作り方ではなく実行環境の問題でした。CI では workers: 1 にしなくても通ったので、並列のままにしています。
ジョブ全体は約3分25秒でした。10分前後を見込んでいたので、思ったより速かったです。時間の大半は next build です。Playwright のブラウザはキャッシュしていて、2回目からはキャッシュが当たりました。ただ、全体の時間はほとんど変わりませんでした。
つまずいたこと
GitGuardian にパスワードとして検出された
サービスコンテナの POSTGRES_PASSWORD: postgres が、GitGuardian に「Generic Password」として検出されました。CI の中だけで使い捨てる DB のダミー値です。
GitGuardian は PR の全コミットを見るので、あとから修正コミットを積んでも検出は消えません。今回は、PR の Checks 画面からテスト用の認証情報として閉じました。最初から避けるなら、POSTGRES_HOST_AUTH_METHOD: trust にしてパスワード自体をなくす手もあります。
E2E の結果でデプロイを止めようとして気づいたこと
ステージングへのデプロイは、workflow_run で「CI が成功したら」動くようにしています。ここに E2E も足そうと workflows: ['CI', 'E2E'] を考えました。しかしこれは「両方が成功したら」ではなく、どちらかが終わるたびに動きます。
E2E の結果でデプロイを止めたいなら、CI → E2E → デプロイと順につなぐか、E2E を ci.yml に入れる必要があります。今は E2E を必須にしていないので、デプロイの条件は CI のままにしています。
CI 以外で E2E を自動実行する方法
CI のほかにも、E2E を自動で流す方法を比べました。
| 方法 | いつ実行されるか | 向き・不向き |
|---|---|---|
| UI モードの watch |
npm run test:e2e:ui を開き、目のアイコンで watch をオンにしたテストが、ファイル保存のたびに再実行される |
開発中に一番手軽 |
| Git フック(husky / lefthook の pre-push) | push の前 | E2E は遅く、DB と dev サーバーの起動も要るので、毎回は重い。pre-commit は特に不向き |
Claude Code のフック(.claude/settings.json) |
Claude が e2e/** を編集した後(PostToolUse)や、作業の終了時(Stop) |
Claude の作業に限れば自動で確認できる。ただし healer も自分でテストを実行するので、Agents を使っている間は重複する |
定期実行(cron や /schedule) |
決まった時間 | ローカルの DB と dev サーバーに依存するので、このアプリには合わない |
私の使い分けは次のとおりです。
- 開発中:UI モードの watch で十分
- マージ前の確認:CI で行う。毎回同じ環境で流せるので、ここを本命にする(前の章で入れたもの)
- Git フック:E2E が遅いうちは入れない。テストの本数が落ち着いてから、pre-push で検討する
まとめ
| エージェント | 任せられたこと | 人がやったこと |
|---|---|---|
| planner | ブラウザでの探索と、計画の下書き | 観点を絞る。計画のレビュー(ファイル分割・確認方法・前提の誤り) |
| generator | ロケーターを確かめながらのコード生成 | 未検証の主張の確認。テストの実行 |
| healer | 失敗の再現と調査 | flaky な失敗の切り分け。調査用コードの後始末 |
- Agents は「テストを書く・直す」を自動化するものです。テストを流すこと自体の自動化は別に考える必要があり、今回は GitHub Actions で流すようにしました
- ローカルで並列だと落ちていたテストも、CI の本番ビルドでは並列のまま全部通りました。AI で作ったテストが落ちたら、まず実行環境を疑う
- 生成物は必ず読む。特に、もっともらしいけれど確かめていない主張に注意する
- うまくいかなかったことは、エージェント定義やルールに書き足して育てる。次の機能ではそれが効きます
次はコメント・通知のテストを作ります。どちらも2人のユーザーが必要なので、今回の「別のブラウザコンテキストで2人目を作る」形が活きるはずです。
使用したAI
Claude Code

