概要
ピースを枠にぴったり埋めるようなパズルを React + TypeScript + Vite で作りました。
問題は自動生成で、難易度はピース数で 3〜10 個の5段階です。
最低限の要件としては以下の通りになります
- 難易度が5段階 × 問題は毎回ランダム生成
- 操作はドラッグ&ドロップ、タップで回転、キーで反転
- スマホ幅とPC幅でレイアウトが変わる
「生成された問題が本当に全部解けるのか?」を人力で確認するのは現実的ではありません。
そこで初めて Playwright を触り、テストコード自身にパズルを解かせるところまでやりました。
そもそもPlayWrightってなにもの?状態から始まったので、勉強がてらその解説も入れつつ記事にしていければと思います。
本題
1. そもそもE2Eテストとは
最初に用語の整理です。テストにはいくつか層があります。
| 種類 | 対象 | 例 |
|---|---|---|
| 単体テスト | 関数やクラス単体 | 「回転関数にL字を渡すと正しい形が返るか」 |
| 結合テスト | 複数部品の組み合わせ | 「コンポーネントにpropsを渡すと期待通り描画されるか」 |
| E2Eテスト | 実際のブラウザでアプリ全体 | 「ピースをドラッグして枠を埋めるとクリア表示が出るか」 |
E2E(End to End)は、ユーザーと同じようにブラウザを操作して確かめるテストです。
今回のゲームでいうと、「形を回転させる関数が正しい」ことを単体テストで確かめても、「画面上でドラッグしたピースが狙ったマスに正しく置かれる」ことは分かりません。
つなげた状態でしか見つからないバグを捕まえるのがE2Eテストの役割です。
2. Playwright入門
Microsoft 製のブラウザ自動操作・E2Eテストのフレームワークです。
Chromium / Firefox / WebKit を同じ API で動かせます。
-
自動待機(auto-waiting):page.click() は、要素が表示され・有効になり・安定するまで自動で待ってくれます。
Selenium で書いていた sleep や明示的 wait が、ほとんど要りません。 -
ロケータという考え方:page.locator(".btn-hint") は「要素」ではなく「要素の探し方」を表すオブジェクトです。
使う瞬間に毎回探し直すので、再レンダリングで DOM が入れ替わる React と相性がいいです。 -
デバッグ手段が揃っている:後述しますが、操作を録画してコードを吐く codegen、実行を巻き戻せる Trace Viewer など、詰まったときの逃げ道が多いです。
導入
新規プロジェクトなら、下記手順でテストランナー・設定ファイル・ブラウザまで入ります。
対話形式で、TypeScriptを使うか、テストをどこに置くか(デフォルトは tests/)などを聞かれます。
npm init playwright@latest
既存プロジェクトに手で足す場合はこちら。
npm i -D @playwright/test
npx playwright install # ブラウザ本体を取得
npx playwright install-deps # Linux でシステム依存が足りないとき
Node.js は 20 以上が必要です。
テストは動くのにブラウザが起動しないときは、だいたい npx playwright install の実行漏れです。
最初のテスト
import { test, expect } from "@playwright/test";
test("難易度を切り替えるとピース数が変わる", async ({ page }) => {
await page.goto("http://localhost:5173/");
await page.getByRole("radio", { name: /かんたん/ }).click();
await expect(page.locator("[data-piece]")).toHaveCount(3);
});
expect(...).toHaveCount(3) は「3個になるまで待ってから」判定してくれます。
実行は以下の通り。
npx playwright test # 全テストをヘッドレスで実行
npx playwright test --headed # ブラウザを表示しながら実行
npx playwright test --ui # UIモード(おすすめ)
npx playwright test tests/game.spec.ts # ファイル指定
npx playwright test -g "かんたん" # テスト名で絞り込み
最初は --ui(UIモード) から入るのが圧倒的におすすめです。
テスト一覧・各ステップのスクリーンショット・DOMスナップショットが1画面に出て、「3行目のクリックの時点で画面がどうなっていたか」をクリックで遡れます。
ロケータの選び方
要素の指定方法はいくつもありますが、公式が推奨しているのはユーザーから見える情報で探す方法です。
getByRole で書いておくと、テストがそのまま「アクセシビリティが担保されているかの確認」も兼ねます。
// おすすめ順
page.getByRole("button", { name: "ヒント" }); // ①役割+名前(アクセシビリティ準拠)
page.getByLabel("メールアドレス"); // ②フォームのラベル
page.getByText("クリア!"); // ③表示テキスト
page.getByTestId("hint-button"); // ④テスト専用属性(data-testid)
page.locator(".btn-hint"); // ⑤CSSセレクタ(最後の手段)
アサーション(確認)の書き方
Playwrightの expect は条件を満たすまで自動でリトライします。
ここが普通の expect との大きな違いです。
アニメーションや非同期の状態更新があっても、await を付けておけば勝手に待ってくれます。
await expect(page.locator(".cleared")).toBeVisible(); // 表示されるまで待つ
await expect(page.locator("[data-piece]")).toHaveCount(5); // 5個になるまで待つ
await expect(page.locator(".progress")).toHaveText("15 / 15 マス");
await expect(page).toHaveTitle(/パズル/);
await expect(page.locator(".btn-hint")).toBeDisabled();
API早見表
// ページを開く
await page.goto("/");
// クリック
await page.getByRole("button", { name: "ヒント" }).click();
// 文字を入力
await page.getByLabel("名前").fill("テキスト");
// キーを押す
await page.keyboard.press("Enter");
// ブラウザ内でJSを実行(結果がNode側に返る)
const title = await page.evaluate(() => document.title);
// スクリーンショット(fullPage: true でページ全体)
await page.screenshot({ path: "s.png", fullPage: true });
// 画面サイズを変える(レスポンシブの確認に)
await page.setViewportSize({ width: 400, height: 800 });
// ページ内で起きた例外を拾う
page.on("pageerror", (e) => console.log(e));
// マウスを直接操作する(ドラッグなど)
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x2, y2, { steps: 6 });
await page.mouse.up();
今回のユースケースで見てみる
今回の対象の画面は1枚の巨大なSVGとなっており、枠もピースも全部この中にあり、ピースは <g transform="translate(x y)"> で配置されています。
ドラッグはPointer Eventsで自前実装しています。
- 工夫1:テストしやすいようにDOMへ状態を出す
テストはNode.js側で動いていてReactの内部状態を覗くことができません。
そのため、状態をそのままDOMに出しておくことにしました。
HTMLの標準機能である data-* 属性を利用することで実現しています。
<g
className={cls}
transform={`translate(${p.x} ${p.y})`}
onPointerDown={(e) => onPiecePointerDown(e, p)}
data-piece={p.id} {/* ← 追加した3行 */}
data-x={p.x}
data-y={p.y}
>
この処理を追加することでDOMは以下のようになります。
<g class="piece" transform="translate(3 2)" data-piece="p0" data-x="3" data-y="2">
これでテスト時にReactの内部状態を確認することなく、ピースの位置を確認できるようになりました。
const getPiece = (id: string) =>
page.evaluate((id) => {
const g = document.querySelector(`[data-piece=${id}]`) as SVGGElement;
return { x: +g.dataset.x!, y: +g.dataset.y! };
}, id);
const piece = await getPiece("p0"); // → { x: 3, y: 2 }
- 工夫2:SVG内部座標 → 画面座標の変換
page.mouse が受け取るのはビューポート基準のピクセル座標、こちらが知っているのはSVGの viewBox 内の座標(=マス目)。この変換に使うのが getScreenCTM() です。
const screen = (x: number, y: number) =>
page.evaluate(([x, y]) => {
const svg = document.querySelector("svg")!;
const pt = new DOMPoint(x, y).matrixTransform(svg.getScreenCTM()!);
return { x: pt.x, y: pt.y };
}, [x, y]);
const pt = await screen(3.5, 2.5); // マス目(3,2)の中心 → 画面座標
await page.mouse.click(pt.x, pt.y);
逆変換は .inverse() を掛けるだけで、これはアプリ本体のドラッグ処理でもそのまま使っています。
なお getScreenCTM() はスクロール量も織り込むので、スクロールしたら取り直しが必要です。
- 工夫3:ドラッグを手で組み立てる
page.mouse はdown / move / upを個別に叩けるので、そのままPointer Eventsになります。
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 6 }); // ← steps が重要
await page.mouse.up();
ちなみにですが、steps を付けないとマウスは1イベントでワープします。
今回作ったものでは「ほとんど動いていなければタップ=回転」と判定しているため、steps なしだと挙動が変わってしまいました。
途中経過を見ているアプリでは必須になるオプションです。
- 工夫4:テストにパズルを解かせる
問題生成は「ピースを組み合わせて形を作り、その形を枠にする」逆算方式なので、生成時点で解答が手元にあります。
テストからそれを読めば、正解手順をそのまま再生できます。
テストの行程としては①ピースをタップして選択→②解答の向きになるまで R(回転)と F(反転)を押す→③解答の位置へドラッグを全ピース分繰り返すだけです。
向き合わせは回転・反転の全8通りを総当たりし、正規化した座標の文字列(形キー)で一致を判定しました。
const want = shapeKey(answer.cells);
let plan: string[] | null = null;
for (const f of [0, 1]) for (let k = 0; k < 4 && !plan; k++) {
let c = f ? flipH(piece.cells) : piece.cells;
for (let i = 0; i < k; i++) c = rotateCW(c);
if (shapeKey(c) === want) plan = [...(f ? ["f"] : []), ...Array(k).fill("r")];
}
for (const key of plan!) await page.keyboard.press(key);
おわりに
これまで、pytestなどPythonに関するバックエンドに関係するところでテストを実装したことはありましたが、フロントエンドでも同じようにテストする技術が存在するんだなと思いました。(当たり前ですが)
まだまだ使いこなせていないと感じるので、継続して学習を行っていけるようにしたいと思います。