はじめに
手動で確認している画面操作をPlaywrightで書けば、E2Eテスト自体は作れます。
ただ、操作手順をそのままコードにすると、画面変更のたびに多くのテストを直すことになります。
この記事では、E2Eテストを継続して運用するための基本構造として、Page Object Modelを整理します。
TL;DR
- テストには「何を確認するか」を書く
- Page Objectには「どう操作するか」を書く
- POMの目的は、UI変更の影響を狭い範囲に閉じ込めること
1. 手動テストをそのままコードにすると何が起きるのか
例えば、ECサイトで次の操作を確認するとします。
- 商品一覧を開く
- 商品をカートに追加する
- カートを開く
- 商品が追加されたことを確認する
これをそのままPlaywrightで書くと、次のようになります。
import { test, expect } from "@playwright/test";
test("商品をカートに追加できる", async ({ page }) => {
await page.goto("/products");
await page
.locator(".product-card")
.first()
.locator("button")
.click();
await page.locator("#cart-button").click();
await expect(page.locator(".cart-item")).toHaveCount(1);
});
このコードは動きます。
E2Eテストを数本作るだけなら、これでも大きな問題は起きないかもしれません。
しかし、同じ操作を別のテストでも使い始めると、少しずつ困るようになります。
await page
.locator(".product-card")
.first()
.locator("button")
.click();
この記述が10本のテストに書かれていたとします。
商品カードのHTML構造が変わったら、10本すべてを修正しなければなりません。
さらに、テストコードの中には次の2種類の情報が混ざっています。
確認したいシナリオ
・商品を選ぶ
・カートに追加する
・カートに商品が入ったことを確認する
画面の実装詳細
・.product-card
・button
・#cart-button
・.cart-item
本当に確認したいのは「商品をカートに追加できること」です。
一方で、.product-cardや#cart-buttonは、現在の画面を操作するための都合にすぎません。
この2つが同じ場所に書かれていると、画面の変更がそのままテストシナリオの修正につながります。
問題は、テストを自動で実行できるかどうかではありません。
画面が変わったあとも、無理なく直せるかどうかです。
2. テストシナリオと画面操作を分ける
先ほどのテストを、もう少しシナリオに近い形で書いてみます。
test("商品をカートに追加できる", async ({ page }) => {
const productsPage = new ProductsPage(page);
const cartPage = new CartPage(page);
await productsPage.open();
await productsPage.addFirstProductToCart();
await productsPage.openCart();
await expect(cartPage.items).toHaveCount(1);
});
このコードからは、商品の追加に使っているCSSセレクタが見えなくなりました。
代わりに、次の操作がそのまま読めます。
商品一覧を開く
最初の商品をカートに追加する
カートを開く
商品が1件入っていることを確認する
ここで分けたのは、単にファイルやクラスではありません。
役割を分けています。
テスト
何を操作し、何を確認するかを書く
Page Object
画面をどのように操作するかを書く
テストはシナリオを知っています。
Page Objectは、ロケータやクリック方法といった画面の詳細を知っています。
この境界を作る基本的な設計がPage Object Modelです。
3. Page Object Modelとは何か
Page Object Modelは、テストが画面を直接操作するのではなく、画面に対応するオブジェクトを通して操作する設計です。
Seleniumの公式ドキュメントでは、Page Objectをテスト対象画面へのインターフェースとして扱い、テストコードとページ固有のコードを分離するものと説明しています。Playwrightの公式ドキュメントでも、アプリケーションに合った高水準のAPIを作り、セレクタを一か所にまとめる方法として紹介されています。
先ほどの商品一覧をPage Objectにすると、次のようになります。
import { type Locator, type Page } from "@playwright/test";
export class ProductsPage {
private readonly page: Page;
private readonly productCards: Locator;
private readonly cartButton: Locator;
constructor(page: Page) {
this.page = page;
this.productCards = page.locator(".product-card");
this.cartButton = page.locator("#cart-button");
}
async open() {
await this.page.goto("/products");
}
async addFirstProductToCart() {
await this.productCards
.first()
.getByRole("button", { name: "カートに追加" })
.click();
}
async openCart() {
await this.cartButton.click();
}
}
カート画面も同じように表現します。
import { type Locator, type Page } from "@playwright/test";
export class CartPage {
readonly items: Locator;
constructor(page: Page) {
this.items = page.locator(".cart-item");
}
}
テスト側は、これらを使います。
import { test, expect } from "@playwright/test";
import { ProductsPage } from "./pages/products-page";
import { CartPage } from "./pages/cart-page";
test("商品をカートに追加できる", async ({ page }) => {
const productsPage = new ProductsPage(page);
const cartPage = new CartPage(page);
await productsPage.open();
await productsPage.addFirstProductToCart();
await productsPage.openCart();
await expect(cartPage.items).toHaveCount(1);
});
この構造では、商品カードのHTMLが変わっても、基本的にはProductsPageだけを修正すれば済みます。
変更前
複数のテストが.product-cardを直接知っている
変更後
ProductsPageだけが.product-cardを知っている
POMの価値は、コードを短くすることではありません。
画面の実装を知る場所を限定することです。
4. ロケータを集めるだけでは不十分
POMを導入するとき、ロケータをクラスに移しただけで終わることがあります。
export class ProductsPage {
readonly productCards: Locator;
readonly cartButton: Locator;
constructor(page: Page) {
this.productCards = page.locator(".product-card");
this.cartButton = page.locator("#cart-button");
}
}
テスト側は次のようになります。
await productsPage.productCards
.first()
.locator("button")
.click();
await productsPage.cartButton.click();
ロケータは一か所に集まりました。
しかし、テスト側はまだ次のことを知っています。
- 商品カードの中にボタンがある
- 最初の商品を選ぶには
first()を使う - 商品追加にはボタンをクリックする
つまり、画面操作の詳細がテスト側に漏れたままです。
POMでは、ロケータだけでなく、画面が提供する操作として公開します。
await productsPage.addFirstProductToCart();
await productsPage.openCart();
addFirstProductToCartの内部では、クリックでもキーボード操作でも構いません。
テスト側が知る必要があるのは、「商品をカートに追加できる」ということだけです。
ロケータを集める
画面の部品を一か所に置く
操作として公開する
画面の使い方を一か所に閉じ込める
POMはロケータ一覧ではなく、テストから画面を操作するための窓口です。
5. テストとPage Objectには何を書くのか
POMを導入すると、次に迷うのが「どちらに何を書くのか」です。
基本的には次のように分けます。
テストに書くもの
テストには、シナリオと期待結果を書きます。
test("商品をカートに追加できる", async ({ page }) => {
const productsPage = new ProductsPage(page);
const cartPage = new CartPage(page);
await productsPage.open();
await productsPage.addFirstProductToCart();
await productsPage.openCart();
await expect(cartPage.items).toHaveCount(1);
});
ここで重要なのは、toHaveCount(1)です。
「商品が1件入っているべき」という期待は、このテストが決めています。
Page Objectに書くもの
Page Objectには、画面を操作するための知識を書きます。
async addFirstProductToCart() {
await this.productCards
.first()
.getByRole("button", { name: "カートに追加" })
.click();
}
このメソッドは、商品を追加するためにどの要素を操作するかを知っています。
一方で、追加後に商品が何件になっているべきかは決めません。
アサーションは原則としてテストに残す
例えば、次のようなPage Objectを作ることもできます。
async verifyItemWasAdded() {
await expect(this.items).toHaveCount(1);
}
しかし、このメソッドには「カートの商品は1件であるべき」というテスト固有の期待が含まれています。
2件追加するテストでは使えません。
商品が追加されたことを名前で確認したいテストにも使えません。
await expect(cartPage.items).toHaveCount(2);
await expect(cartPage.itemNames).toContainText("キーボード");
期待結果はシナリオによって変わります。
そのため、Page Objectは検証に必要な要素を提供し、具体的な期待はテスト側で表現する方が使い回しやすくなります。
Seleniumの公式ドキュメントでも、Page Objectは原則としてアサーションを持たず、ページが正しく読み込まれたかを確認する場合などを例外として挙げています。
6. 「1ページ1クラス」にこだわらない
Page Object Modelという名前から、URLごとにクラスを一つ作るものだと考えがちです。
しかし、画面が大きくなると、一つのPage Objectも大きくなります。
例えば商品一覧画面には、次のような機能があるかもしれません。
ProductsPage
・商品検索
・カテゴリ絞り込み
・商品カード
・並び替え
・ページネーション
・カート
・共通ヘッダー
これらをすべてProductsPageに入れると、画面内のあらゆる操作を抱えた巨大なクラスになります。
そこで、意味のある画面部品を分けます。
例えば商品カードです。
import { type Locator } from "@playwright/test";
export class ProductCard {
private readonly root: Locator;
constructor(root: Locator) {
this.root = root;
}
async addToCart() {
await this.root
.getByRole("button", { name: "カートに追加" })
.click();
}
get name() {
return this.root.getByTestId("product-name");
}
}
ProductsPageは、商品カードを返します。
import { type Locator, type Page } from "@playwright/test";
import { ProductCard } from "./product-card";
export class ProductsPage {
private readonly page: Page;
private readonly productCards: Locator;
constructor(page: Page) {
this.page = page;
this.productCards = page.getByTestId("product-card");
}
async open() {
await this.page.goto("/products");
}
firstProduct() {
return new ProductCard(this.productCards.first());
}
}
テスト側は次のように書けます。
const firstProduct = productsPage.firstProduct();
await firstProduct.addToCart();
この形なら、商品カードの変更はProductCardに閉じ込められます。
同じ商品カードが検索結果やおすすめ商品にも表示されるなら、そのまま再利用できます。
Seleniumの公式ドキュメントでも、Page Objectは必ずしもページ全体を表す必要はなく、ページ内の部品をPage Component Objectとして分けられると説明されています。
分割するときに見るべきなのはURLではありません。
独立して操作でき、独立して変更される画面領域かどうかです。
7. POMを入れてもテストが安定するとは限らない
POMを導入すると、画面変更時の修正箇所は集約しやすくなります。
ただし、それだけでE2Eテストの失敗がなくなるわけではありません。
例えば、次のようなテストです。
async addToCart() {
await this.addButton.click();
await this.page.waitForTimeout(3000);
}
待機処理をPage Objectに隠したので、テスト側はきれいに見えます。
しかし、処理が3秒以内に終わる保証はありません。
逆に、処理が100ミリ秒で終わっても毎回3秒待ちます。
POMは、不安定な待機処理を正しい処理に変えてくれる仕組みではありません。
Playwrightでは、操作対象が操作可能になるまで待つ仕組みや、条件が満たされるまで再試行するアサーションが用意されています。ロケータも操作のたびに現在のDOMから対象を探すため、固定的な要素参照より画面変化を扱いやすくなっています。
例えば、カートに商品が追加されたことを確認するなら、固定時間を待つのではなく、期待する状態を待ちます。
await firstProduct.addToCart();
await expect(cartPage.items).toHaveCount(1);
POMが主に扱うのは、次の問題です。
画面操作の詳細が複数のテストに散らばる
一方で、次の問題には別の設計が必要です。
非同期処理の完了を正しく待てていない
テストデータを共有している
前のテストの状態が残っている
外部サービスの応答に依存している
POMはE2Eテスト全体を安定させる万能な仕組みではありません。
保守しやすいコード構造を作るための仕組みです。
8. 最初からすべてをPOM化しない
POMが有効だからといって、テストを書く前にすべての画面をクラス化する必要はありません。
例えば、まだ1本しかない短いテストなら、直接書いた方が分かりやすいこともあります。
test("利用規約を表示できる", async ({ page }) => {
await page.goto("/signup");
await page.getByRole("link", { name: "利用規約" }).click();
await expect(
page.getByRole("heading", { name: "利用規約" }),
).toBeVisible();
});
この操作が他で使われず、画面もほとんど変わらないなら、無理にPage Objectを作る必要はありません。
POMを導入する候補になるのは、次のような場所です。
同じ操作が複数のテストに現れる
同じロケータが複数箇所に現れる
画面変更のたびに多くのテストを直している
テストを読んでも何を確認しているのか分かりにくい
例えば、複数のテストに次の処理が現れたとします。
await page.goto("/products");
await page
.getByTestId("product-card")
.first()
.getByRole("button", { name: "カートに追加" })
.click();
この重複が見えた段階で、Page Objectへ切り出します。
await productsPage.open();
await productsPage.addFirstProductToCart();
流れとしては、次のくらいで十分です。
まずテストを書く
↓
重複や変更箇所が見える
↓
Page Objectへ切り出す
↓
大きくなったらComponent Objectへ分ける
最初から完成した抽象化を作ろうとすると、まだ存在しない変更を想像して設計することになります。
POMは先回りしてクラスを増やすためのルールではありません。
実際に見えてきた変更コストを、小さな範囲へ閉じ込めるために使います。
まとめ
手動テストの操作手順をコードにすれば、E2Eテストは動かせます。
しかし、そのままテストを増やしていくと、シナリオと画面の実装詳細が混ざります。
操作手順をそのままコードにする
↓
シナリオとUI詳細が混ざる
↓
画面変更で多くのテストが壊れる
↓
Page Objectで役割を分ける
↓
UI変更の影響を狭い範囲に閉じ込める
テストには「何を確認するか」を書きます。
Page Objectには「どう操作するか」を書きます。
POMは、E2Eテストを動かすために必須のものではありません。
画面が変わったときに、どこを直せばよいか分かる構造を作るためのものです。
E2Eテストを継続して運用するなら、操作を自動化するだけでなく、変更の影響をどこへ閉じ込めるかまで設計する必要があります。
その最初の境界として使えるのが、Page Object Modelです。