Structured Playwright (1) —— 継続性から設計するE2Eテストの4層構造とハーネス
第2回で用語の使い方を変更したのに合わせて記載を差し替えています(「局所宇宙」→「探索スコープ」)。記事の趣旨は変えていません。
末尾の変更履歴をご参照ください。
はじめに:E2Eテストは大きくなる
E2Eテストは運用を続ければ必ず大きくなります。スプリントごとに要件が積み上がるのだからそれ自体は健全なことです。
問題はその大きくなり方。
Playwrightのデフォルト構成では、テストが増えるときに同じ記述——Locator、操作手順、待機、検証——がコピーで広がっていき、結果2つの状態に誘引されていきます。
- 変更追随性が下がる —— UIや手順のひとつの変更が、散らばった記述の数だけ同時に届く。1つの機能修正で変更箇所が各所にわたる。
- レビュー性が下がる —— どこで何が使われているかが追いにくくなり、レビューが「コードの良し悪しの主観判断」になってしまう。
この2つが下がった先で起きやすいのが「Failしたテストの原因を調べるより、作り直したほうが早い」という判断です。
その判断は間違っていませんし、実際に正しい局面が多いのですが、問題は「その判断が正しくなってしまう」構造です。「Failしたら作り直そう」はともすると「Passするテストを作る」という本末転倒気味な状態にもなりかねません。
前回、リグレッションベース という記事で「テスト管理の軸」の話を書きました。機能単位で積み上げるのではなく、ユーザーシナリオという吸収性のある軸を一本立てて、そこに新規要件をマージしていく、という考え方です。
今回はその一段下、自動テストによるリグレッションテストの実装の軸の話です。やっていることは同じで、テスト本体に手順を積み上げるのではなく、層という吸収性のある軸を立てて、そこに変化をマージしていくことで、保守コストを下げて継続性のある自動リグレッションテストを作ろうという話です。
先に立場を書いておくと、継続性は意志や体制で維持するものではなく、「捨てる」が合理的にならない構造を保つことだと思っています。この記事では、その構造として4層構成を紹介します。
不定期になると思いますが、何回かに分けて書きます。今回は概念の全体像まで。Locatorの設計思想、偽陽性のカタログ(「Passと報告されるFail」に変更)、機械ゲートの中身は続きの回で扱います。
構造 —— 4層に分ける
何が散るのか
一般的な書き方から始めます。
// ❌ tests/search.spec.ts
test('商品を検索して詳細を開く', async ({ page }) => {
await page.fill('input[placeholder="キーワード"]', 'ノート');
await page.click('button:has-text("検索")');
await expect(page.locator('.result-list')).toBeVisible();
await page.click('.result-item:first-child');
await expect(page.locator('.product-detail')).toBeVisible();
});
1本だけなら何も問題ありません。問題は、この5行が他のspecにもコピーされていくことです。
1つ目:要素が散ります。 検索を前提とするテストは検索以外にもあるので、button:has-text("検索") は自然に増殖します。「検索」ボタンのラベルが「検索する」に変わったときは、grepして全部直すことになります。
2つ目:手順が散ります。 たとえば削除の操作。
// 削除を含むspec全部に、同じ2行が並ぶ
await page.click('button:has-text("削除")');
await page.click('[role="dialog"] button:has-text("OK")');
ここで削除の仕様が変わって確認モーダルがもう1枚挟まると、削除を含むspec全部に操作を足して回ることになります。要素が変わったのではなく、フローが1ステップ増えただけでこれが起きる。
散るものが2種類ある、というのがポイントです。要素と手順。よく知られたPage Objectパターンは要素の置き場所を与えますが、手順の置き場所は与えません。だから置き場所は、要素・手順それぞれに責務として切り出す必要があります。それが4層構成の出発点です。
4層の責務
責務を4つの層に分けます。分け方の基準は機能ではなく、変更の発生源です。E2Eテストのコードが変更を迫られる理由はほぼ4種類に集約されるので、その種類ごとに置き場所を決めます。
| 層 | 責務 | 吸収する変更 |
|---|---|---|
| Layer 4: Config/Env | 環境設定・定数 | 環境の変更 —— URL、タイムアウト値 |
| Layer 3: Tests | 期待結果の検証(AAAパターン) | テスト観点の変更 —— 検証項目の追加 |
| Layer 2: Actions | 業務フロー・複数画面の制御 | 業務手順の変更 —— 確認モーダルが挟まる |
| Layer 1: Page Objects | UI要素の定義・基本操作 | UI構造の変更 —— ラベル、DOM位置 |
どの種類の変更が来ても、直す場所は1つの層に閉じます。単一責任の原則——「変更理由が1つであること」——をE2Eテストに適用した形です。
コードベース上ではこう並びます。
src/
├── config/ # Layer 4
├── pages/ # Layer 1(Locatorはここだけ)
├── actions/ # Layer 2
├── fixtures/ # 層ではなく、層をつなぐ配線(後述)
└── tests/ # Layer 3
要素の置き場所がLayer 1、手順の置き場所がLayer 2。
Layer 3は「何を検証するか」の表現に専念し、UIの構造も操作の手順も知りません。
4層で書き直す
先ほどのテストを4層で書き直します。
Layer 1 — Page Object。Locatorはここにしか存在しません。
// src/pages/SearchPage.ts
export class SearchPage {
readonly keywordInput: Locator;
readonly searchButton: Locator;
readonly resultList: Locator;
constructor(private readonly page: Page) {
this.keywordInput = page.getByLabel('キーワード');
this.searchButton = page.getByRole('button', { name: '検索' });
this.resultList = page.getByRole('list', { name: '検索結果' });
}
async search(keyword: string): Promise<void> {
await this.keywordInput.fill(keyword);
await this.searchButton.click();
}
async openResult(index: number): Promise<void> {
// 引数由来の index。呼び出し側が「何件目か」を意図して指定する
await this.resultList.getByRole('listitem').nth(index).click();
}
}
Layer 2 — Action。業務フローを組み立てます。expect() は書きません。
// src/actions/SearchAction.ts
export class SearchAction extends BaseAction {
async searchAndOpenFirst(keyword: string): Promise<void> {
this.beginAction();
await this.step(`キーワード「${keyword}」で検索する`, async () => {
await this.searchPage.search(keyword);
});
await this.step('先頭の検索結果を開く', async () => {
await this.searchPage.openResult(0);
});
}
/** 商品詳細が表示されたか(検証はTest層で行うため、ここでは真偽を返すだけ) */
async isProductDetailVisible(): Promise<boolean> {
try {
await this.productPage.detailPanel.waitFor({ state: 'visible' });
return true;
} catch {
return false;
}
}
}
Layer 3 — Test。Locatorは書きません。
// src/tests/search.spec.ts
test('商品を検索して詳細を開く', async ({ searchAction }) => {
// [Act] キーワード検索して先頭を開く
await searchAction.searchAndOpenFirst('ノート');
// [Assert] ✅ 商品詳細が表示される
expect(await searchAction.isProductDetailVisible()).toBe(true);
});
行数だけ見れば増えています。1本目を書くコストは確実に上がる。それでも分ける理由は次章にあります。
この構造を保つためのハーネス
この4層構成を、Playwright + TypeScript のテンプレートとして公開しています。
https://github.com/Mov-ish/structured-playwright
ここには「サンプルコード」ではなく、この構造を守らせるためのハーネスを置いています。層境界の規範、禁止パターンの機械ゲート、Claude Code向けの運用セット(rules/ と skills/)を一式入れていて、AIが生成するコードに適用することを目的としています。もちろん人間の書いたコードもこのハーネスでレビューさせれば逸脱を指摘してくれます。
この記事でこの先に触れる境界ルールやゲートの実体はここです。
変更追随性 —— 変更が届く範囲を設計する
4層にすると、変更の種類ごとに届く層が変わります。
| 変更の種類 | 具体例 | 届く層 | 触るファイル |
|---|---|---|---|
| UI要素が変わった | ボタンのラベル変更、入力欄のlabel変更 | Layer 1 | Page Object 1つ |
| 業務フローが変わった | 削除時に確認モーダルが挟まった | Layer 2 | Action 1つ |
| 期待結果が変わった | 検証する項目が増えた | Layer 3 | 該当specのみ |
| 環境が変わった | タイムアウト値、URLパターン | Layer 4 | constants.ts |
層がないと、この全部がテスト本体(Layer 3)に届きます。それが「E2Eは壊れやすい」と言われるものの正体で、壊れやすいのではなく、変更が届く範囲が設計されていないだけです。
「何が散るのか」で見た2つの散在を、それぞれのケースで回収します。
ケース1:ボタンのラベルが「検索」→「検索する」に変わった
要素の変更は、Layer 1の1箇所だけで止まります。
// Before: 各specに散在しているので、grepして全部直す
await page.click('button:has-text("検索")'); // search.spec.ts
await page.click('button:has-text("検索")'); // search-filter.spec.ts
await page.click('button:has-text("検索")'); // purchase.spec.ts
await page.click('button:has-text("検索")'); // ...以下同様
// After: SearchPage の1行だけ
this.searchButton = page.getByRole('button', { name: '検索する' });
これはPage Objectの活用で解決するよく知られた話です。ここからが本題です。
ケース2:削除に確認モーダルが挟まるようになった
手順の変更です。Page Objectではこれは救えません。Page Objectは要素の置き場所であって、手順の置き場所ではないからです。「削除ボタンを押す」と「OKを押す」の2つのメソッドを、呼ぶ側(テスト本体)が順番に並べている構造だと、変更は結局テスト本体に届きます。
Action層は手順の置き場所で、動作変更はActionの中だけで閉じます。
// After: Action の中だけで閉じる
async deleteItem(name: string): Promise<void> {
this.beginAction();
await this.step(`「${name}」を削除する`, async () => {
await this.listPage.clickDelete(name);
await this.confirmDialog.confirm(); // ← 追加はここだけ
});
}
呼び出し側のテストは1行も変わりません。
await itemAction.deleteItem('テスト用アイテム');
PageとActionの責務分担が変更追随性の核心。
Page Objectだけの2層構成でよく詰まるのがここで、実際のプロダクト変更は「要素が変わる」より「手順が変わる」ほうが多い。要素の変更はPageが吸収し、手順の変更はActionが吸収する。この分担があって初めて、テスト本体が変更から隔離されます。
ケース3:検証したい項目が増えた
ここはLayer 3(Tests)だけで完結すべき変更になります。
test('商品を検索して詳細を開く', async ({ searchAction }) => {
await searchAction.searchAndOpenFirst('ノート');
expect(await searchAction.isProductDetailVisible()).toBe(true);
expect(await searchAction.getProductTitle()).toBe('ノート A5'); // ← 追加
});
検証の追加でActionやPage Objectを触ることになるなら、責務の置き場所が間違っています。
「どこを触ることになったか」は設計の答え合わせになります。
変更のたびに表と照らし、どの層を触ったかを見る。想定外の層を触っていたら設計を直すシグナルです。もし人間が書いてAIにレビューさせるなら、そこだけを見させてもよいでしょう。
ここからは、AI(Claude Code)ハーネスが規定する内容の説明です。
レビュー性 —— どこで何をしているかを追える状態
レビュー性は「コードが読みやすいこと」ではありません。
レビュアーが手順書とテストを照合できること。
これが成立していないと、レビューは「コードの良し悪しの判断」に偏っていきます。主観の入りやすいポイントで、忙しければ通ります。劣化が入る場所はレビューなので、ここが崩れると構造は守れません。
照合できる状態を保つ仕掛けを4つ置いています。
層間の絶対境界
4層は、線を引いただけでは守られません。守るべき境界を明示しておきます。
| ルール | 理由 |
|---|---|
| Test層にLocatorを書かない | テストは意図と期待結果の表現。UI構造を知る必要がない |
Action層に expect() を書かない |
expectはテスト固有のアサーション。Actionは再利用可能なフロー |
| Page Objectにビジネスロジックを書かない | POはUI要素と単一責務のみ。フローはAction層 |
@playwright/test から直接importしない |
Fixture経由で test / expect を取る |
Actionを手動で new しない |
Fixture引数で受け取る(依存の明示化) |
上2つがキツく見えると思います。実際キツいのですが、この2つが同時にあることで、テストがUIの構造を一切知らない状態が保てます。
(両方禁止すると検証が書けないだろう、と思われますが、ここは後述します。)
境界を明確にすると、レビューは「この層にこれがあってよいか」の照合になります。層ごとに見るポイントが定まる分、主観の入る余地は減ります。これがレビュー性向上に向かいます。
定数の帰属を判定する
どこで何が使われているかを追えなくする原因の1つが、定数の置き場所が曖昧なことです。判定は1問だけにしています。
その値は複数ファイルで使うか?
Yes →constants.tsに置いてimportする
No → Page Object / Action の中で定義する
TIMEOUTS.SPA_RENDERING や URL_PATTERNS.LOGIN のように複数箇所から参照されるものは共通化する。逆に、特定画面でしか使わないLocatorをconstantsに上げてはいけません。共通ファイルは何でも置ける場所なので、放置すると一番早く肥大します。
何ができるかを、実装の隣に置く
Actionが増えてくると「その操作、既にありませんでしたか」が起きます。既にあるのに気づかず似たActionを足すのが、同じものが2箇所に書かれる典型パターンです。
対策として、Fixtureの型定義の直前にActionカタログをコメントで維持しています。
/**
* ■ searchAction (SearchAction)
* - searchAndOpenFirst(keyword) キーワード検索して先頭の結果を開く
* - isProductDetailVisible() → boolean 商品詳細の表示確認
*
* ■ itemAction (ItemAction)
* - deleteItem(name) アイテムを削除(確認モーダルまで込み)
* ...
*/
type AppFixtures = { ... };
別のドキュメントに書くと人間でもAIでもドリフトします。
Fixtureは新しいActionを追加するとき必ず開くファイルなので、そこに置いておけば更新忘れが起きにくい。「ドキュメントを正しく保つ」のではなく「ドリフトしない場所に置く」という考え方です。
手順書をコードの中に持つ
すべての .spec.ts に、ヘッダーのJSDocでテスト手順を自然言語で書きます。
/**
* TC-01: 商品検索スモーク
*
* ■ [Arrange] ログイン
* 1. 一般ユーザーでログインする
*
* ■ [Act] Phase 1: キーワード検索(✅ 結果一覧の表示)
* 2. キーワード「ノート」で検索する
* 3. ✅ 結果一覧が表示されることを検証
*
* ■ [Act] Phase 2: 詳細表示(✅ 商品詳細の表示)
* 4. 結果の1件目を開く
* 5. ✅ 商品詳細が表示されることを検証
*
* ■ 検証ポイント(expect)
* - Phase 1: 結果一覧の表示
* - Phase 2: 商品詳細の表示
*
* ■ 元手順書との意図的差分
* - なし
*/
最後の**「元手順書との意図的差分」欄**が効きます。
原本(テスト手順書・チェックリスト・受け入れ条件)とテストが食い違っているとき、それが意図的な差分なのか実装ミスなのかを、レビュアーは普通わかりません。手順書に「3件表示されること」と書いてあってテストが1件しか見ていないとき、判断材料がなければ「たぶん意図があるんだろう」と通すしかない。
差分欄があると、ここが「書いてあるか / 書いていないか」の照合になります。書いてなければ差し戻す。理由が書いてあれば理由の妥当性だけ見る。判断が主観から照合に変わる、というのがこの欄の役割です。
そして、JSDocと実装は同一PR内で必ず同期させます。乖離を1件許すと「どうせ古いかも」という前提で読まれるようになり、手順書としての機能を失うからです。
継続性 —— Failを信用できる状態に保つ
変更追随性とレビュー性が保たれていても、最後にもう1つ条件があります。Failが信用できることです。個人的には、ここが継続性の核心だと思っています。
なぜテストが捨てられるのか
Failしたテストを捨てるのは、面倒だからだけではありません。
Failが「本当に壊れた」のか「バグを見つけた」のか「またflakyか」を判別するコストが、作り直すコストを超えたからです。
判別に30分かかって、書き直しなら10分。それが何度か続けば、誰でも書き直しを選びます。そして書き直されたテストは元の検証意図を失っているので、次に落ちたときはもっと判別しにくい。
そうすると、偽陽性(と偽陰性)の排除は、保守性の問題ではなく継続性の問題になってきます。
テストを信用できなくすることの本当のコストは、実行のたびに発生するのではなく、捨てたときに払われます。
このテンプレートで禁止しているものは、ほとんどがここに帰着します。
-
.catch(() => false)—— タイムアウトを「無かったこと」にする - verifyメソッド内の固定待機 —— 待ち時間が足りたかどうかで判定が変わる
- Silent Skip —— テストが明示的に要求した操作を、できなかったときに黙って飛ばす
- disabled要素への
click()—— actionable待ちでテストタイムアウトまでハングする
どれも「動いているように見える」状態を作るので、入るときに痛みがありません。そして1件でも残ると、それが正解パターンとして模倣されます。
「両方禁止」の矛盾をどう解くか
先ほど保留した点に戻ります。
- Test層にLocatorを書けない
- Action層に
expect()を書けない
この2つを同時に満たすと、検証が書けなくなります。要素の状態を見るにはLocatorが要るが、それはPage Object層にしかない。かといってActionでアサーションはできない。
間にverifyメソッドを挟みます。
// Action層:waitFor() で待って、真偽値を返す
// (waitFor は「待機操作」であって「アサーション」ではない)
async isProductDetailVisible(): Promise<boolean> {
try {
await this.productPage.detailPanel.waitFor({ state: 'visible' });
return true;
} catch {
return false;
}
}
// Test層:戻り値を expect する(Locator は書いていない)
expect(await searchAction.isProductDetailVisible()).toBe(true);
waitFor() は待機操作なのでAction層に置けます。返ってきた真偽値はTest層が判定するので、Locatorを知らないままで検証が成立します。
重要なのは、この形にすると偽陽性が入りにくくなること。
catchが捕まえるのは「要素が visible にならなかった」という状態遷移のタイムアウトだけで、それ以外の失敗は本来のエラーのまま落ちる。
ここに click() や textContent() を混ぜてしまうと「何かが失敗した(原因不明)」が false になり、本物のFailと区別がつかなくなります。
Failした場所が即わかる
判別コストは「何が起きたか」と「どこで起きたか」に分かれます。場所のほうを担うのがログです。
Actionの各ステップを step() ヘルパーで記録します。
await this.step(`キーワード「${keyword}」で検索する`, async () => {
await this.searchPage.search(keyword);
});
これがコンソール出力とHTMLレポートの test.step() を同時に発行します。
| 出力先 | 粒度 | 目的 |
|---|---|---|
| コンソール |
Step N: ActionName - 詳細(ユーザーストーリー粒度) |
CIログでの位置特定 |
| HTMLレポート |
test.step() のネストで内部詳細を階層表示 |
Fail時の詳細デバッグ |
step() は中で起きたエラーを catchせずに伝播させます。ここでcatchしてしまうと偽Pass(flaky pass)の温床になります。
連鎖が閉じる
冒頭に置いた問題の連鎖は、こう反転します。
照合できるからレビューが機能し、レビューが機能するから劣化が入口で止まり、劣化していないから変更に追随でき、追随できているからFailしたときに「直す」を選べる。
継続性は、意志や体制で維持するものではなく、「捨てる」が合理的にならない状態を保つこと。冒頭に書いた立場が、ここで構造として閉じます。
それでも構造は腐る
「こんなんドキュメントに書いておけば済むのでは?」と思われるかも知れませんが
これを全部ドキュメントに書くだけでは守られません。
散文で書かれた規範は必ずと言っていいほど薄まります。半年後の忙しいスプリントで .first() が1個入り、レビューで見逃され、次の人がそれを正解パターンとして参照する。AIを使えばなおのこと。AIの持つ性質の1つ、先行模倣性が発揮されます。構造は腐るときに音を立てません。
なので、grepできるものは機械判定にするのが吉です。npm run gate が exit code で返します。
| 領域 | 何を見るか |
|---|---|
| コード禁止パターン | XPath / 層境界違反 / .catch 隠蔽 / ハードコード など |
| 理由コメント規律 | ordinal・固定待機・部分一致に理由が書かれているか |
| AST検出 | verifyメソッド内の固定待機 |
| メタ層 | 規範ドキュメント自体の肥大・重複 |
個人的に要点としてお伝えしたいのが最後の行。
規範ドキュメント自身も肥大します。
ルールを足すと根本解決したように感じるので、放っておくとどんどん大きくなる。誰も読まない分量になる。生成AIならなおのことで、情報の希薄化やAIの任意選択を招きます。
なので常時ロードされる規範の総量に上限(baseline)を置いて、増やすなら「同量を削るか、引き上げの理由をPRに書くか」の二択を機械的に迫るようにしています。
これらのゲートは、AIが生成したコードも人間が書いたコードも同じように通ります。
生成AIでテストを書かせる場合、規範を守らせる手段は結局ここ、というのが今のところの結論です。先に紹介したリポジトリに「ハーネス構成が丸ごと」入れてあるのは、こういうことです。
欠点
と、良いことを並べてきましたが、当然、良いことばかりではありません。
初期コストが高い。
1本目のテストを書くまでに、Fixture・BaseAction・constants を用意する必要があります。数本で終わる小さいテスト、短命なプロジェクトには過剰でしょう。
1本あたりに触るファイルが増える。
新しい画面のテストを1本足すのに、Page Object・Action・spec の3ファイルを触ります。薄いテストほど割に合いません。「これ、specに直接書けば3行なんだけどな」と思う瞬間は普通にあります。
規範の量そのものが学習コストになる。
新しく入った人は、書き始める前にルールを読むことになります。上限を設けることで学習コストの増加を防止していますが、ゼロにはなりません。
機械判定できるのは一部です。
例えば、ordinalセレクタを許すかどうか、try-catch の境界がどこか、といった判断は人間に残ります。判断が残るということは、判断を甘くする余地も残るということです。
Structured Playwrightは実装の領域に留まります。
何をテストすべきか、どこまでを自動化に載せるかは、この構造の外側です。構造がどれだけ綺麗でも、テストしている対象が的外れなら意味がありません。
ここが一番重要で、この記事を書いている段階ではこの領域は人間の頑張りどころです。
おわりに
E2Eのリグレッションテストが捨てられるとき、たいてい技術的な限界には達していません。
判別コストが作り直しコストを超えただけです。
そのコストは構造で下げられます。変更が届く範囲を設計し、どこで何をしているかを追える状態を保ち、Failを信用できるようにしておく。地味ですが、これをやっておくと「直す」が選べるようになります。
前回のリグレッションベースもそうでしたが、これがベストだと言うつもりはありません。実際、導入先ではそれぞれの事情に合わせてアレンジされています。一つの考え方として受け取っていただければ幸いです。
次回はLocatorの設計思想を単独で掘ります。LocatorはElementHandleではない、という前提の訂正から。
関連リポジトリ
https://github.com/Mov-ish/structured-playwright
MITライセンスです。4層の骨格、Locator規範、機械ゲートが一式入っています。Claude Code向けの運用セットも同梱していますが、rules/ と skills/ は人間がそのまま読める規範集として書いてあるので、チーム規約として直接使うこともできます。
変更履歴
- 2026-09-07 次回予告の用語を第3回に合わせて差し替え(「偽陽性のカタログ」→「Passと報告されるFail」。第3回では偽陽性/偽陰性を使わず、誤検知/見逃しで統一しています)
- 2026-08-26 次回予告の用語を第2回の改訂に合わせて差し替え(「意味空間・局所宇宙で探索するという3つの原則」→「意味ベースで書き、探索スコープを絞るという2つの原則」)
- 2026-08-24 連載の通し番号に合わせてタイトルを変更(「継続性から設計するE2Eテストの4層構造とハーネス」→「Structured Playwright (1) —— 継続性から設計するE2Eテストの4層構造とハーネス」)
文体の調整や誤字の修正は記載していません。
Discussion