PlaywrightでPC/タブレット/スマホを一括でE2Eテストし、スクショを取得する方法

概要

今回はPlaywrightでdesktop / tablet / mobileの3つのビューポートを一発でテストして、各テストの操作フローをスクリーンショット連番として自動収集する方法と構成について紹介していきます。

個人開発でレスポンシブ対応をすると、「PCでは問題ないがスマホだけレイアウトが崩れる」というバグが定期的に発生します。

毎回手で確認するのは、さすがに今どきでは無く現実的でないので、E2Eテストで3画面サイズを自動で回し、操作フローのエビデンスをまとめて取得する仕組みを入れています。

自分が作ったcopype.shinpinoshi.comこぴぺったりでは、6 spec × 3ビューポート = テスト1回で189枚のスクリーンショットが取れる構成にしています。

個人開発の方や業務の自動化をガンガン回したい方は是非見ていってください!

それではやっていきましょう(^^!

目次

なぜ3ビューポートで回したいのか

レスポンシブWebアプリでは、「desktop」「tablet」「mobile」で異なるレイアウト・操作系を持つことが普通です。

  • desktop: サイドバー常時表示・マウスホバー操作前提
  • tablet: 中間幅でレイアウト崩れが起きやすい
  • mobile: ドロワーUI・タップ操作・SafeArea

E2Eを1ビューポートだけで回しても、tablet/mobile固有のレイアウトバグが見逃されるので、テストとしてはナンセンスだと思ってます。

しかも3回コマンド叩くのも面倒…
なので「同じspecを3ビューポートそれぞれで回す」設定が欲しくなります。

projectsで3ビューポートを定義する

Playwrightではprojects配列で複数の実行プロファイルを持てます。

playwright.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/.artifacts",
fullyParallel: false,
workers: 1,
reporter: [["list"], ["html", { outputFolder: "evidence/_report", open: "never" }]],
globalTeardown: "./scripts/build-review.mjs",
timeout: 30_000,
expect: { timeout: 5_000 },

use: {
baseURL: "http://localhost:5173",
locale: "ja-JP",
timezoneId: "Asia/Tokyo",
permissions: ["clipboard-read", "clipboard-write"],
trace: "retain-on-failure",
video: "off",
},

projects: [
{
name: "desktop",
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
},
{
name: "tablet",
use: { ...devices["Desktop Chrome"], viewport: { width: 820, height: 1180 } },
},
{
name: "mobile",
use: { ...devices["Desktop Chrome"], viewport: { width: 390, height: 844 } },
},
],

webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: true,
timeout: 60_000,
},
});
  • projects[].nameがそのままevidenceの親ディレクトリ名になる
  • viewportでdesktop=1440x900, tablet=820x1180, mobile=390x844を指定
  • permissions: ["clipboard-read", "clipboard-write"]でクリップボード操作を許可(コピー機能テスト用)
  • webServerでVite devサーバを自動起動。reuseExistingServer: trueなので開発中のサーバも再利用

npx playwright test一発で、同じspecを3プロジェクト分実行します。

カスタムフィクスチャでスクショ命名を自動化

各テスト中の操作ごとにスクショを撮ると、命名がブレてレビューしづらくなります。

そこでtest.extendスクショ用のカスタムフィクスチャを作り、app.shot('label')で連番付きで撮れるようにします。

e2e/helpers.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { test as base, expect } from "@playwright/test";
import path from "node:path";
import fs from "node:fs";

export { expect };

const EVIDENCE_ROOT = path.resolve("evidence");

function slug(s) {
return s.replace(/[^\w-]+/g, "_").replace(/^_+|_+$/g, "");
}

export const test = base.extend({
app: async ({ page }, use, testInfo) => {
const projectName = testInfo.project.name; // desktop / tablet / mobile
const file = slug(testInfo.titlePath[0] || "spec");
const title = slug(testInfo.title);
const evDir = path.join(EVIDENCE_ROOT, projectName, file, title);
fs.mkdirSync(evDir, { recursive: true });

let counter = 0;

await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector(".app");

// アニメーション無効化(スクショの安定化)
await page.addStyleTag({
content: `*,*::before,*::after{
animation-duration:0s !important;
transition-duration:0s !important;
}`,
});

const helper = {
page,
evDir,
async shot(label, description) {
counter += 1;
const seq = String(counter).padStart(2, "0");
const base = `${seq}_${slug(label)}`;
await page.screenshot({
path: path.join(evDir, `${base}.png`),
fullPage: false,
});
// レビュー用にメタ情報も書き出す
fs.writeFileSync(
path.join(evDir, `${base}.meta.json`),
JSON.stringify({
seq: counter,
label,
description: description || "",
testTitle: testInfo.title,
viewport: projectName,
}, null, 2),
"utf-8"
);
return `${base}.png`;
},
};
await use(helper);
},
});
  • 各テスト開始時にlocalStorage.clear()reloadでクリーンな状態で開始
  • addStyleTagでアニメ・トランジションをゼロにしてスクショを安定化
  • app.shot('label')で連番付きPNG+meta.jsonをペアで書き出し
  • evidenceパスはevidence/<viewport>/<spec>/<test>/01_label.pngの階層構造

evidenceディレクトリの構造

このフィクスチャを使うと、テスト結果が以下のように自動構造化されます。

evidence/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
evidence/
├── desktop/
│ ├── 01-empty-and-folder_spec_js/
│ │ ├── 01_empty_no_folders.png
│ │ ├── 01_empty_no_folders.meta.json
│ │ ├── 02_new_folder_modal.png
│ │ └── ...
│ ├── 02-item-crud_spec_js/
│ └── ...
├── tablet/
│ └── (同じ構造)
├── mobile/
│ └── (同じ構造)
└── _report/ (Playwright HTMLレポート)

同じ操作の同じステップがviewport別のディレクトリに並ぶので、横並び比較がしやすくなります。

189枚(6 spec × 3 viewport × 平均10ステップ)あっても、ディレクトリで整理されているので迷子になりません。

specの書き方

カスタムフィクスチャを使ったspecはこうなります。

e2e/02-item-crud.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { test, expect } from "./helpers.js";

test("アイテムを作成・編集・削除する", async ({ app }) => {
const { page } = app;

// フォルダ作成
await page.click("button:has-text('+ フォルダ')");
await page.fill("input[placeholder='フォルダ名']", "開発");
await page.click("button:has-text('作成')");

// アイテム追加
await page.click("button:has-text('+ 新規')");
await page.fill("input[name='title']", "useDebounce");
await page.fill("textarea[name='body']", "function useDebounce(v, d=300){ /* ... */ }");
await page.click("button:has-text('保存')");
await app.shot("item_added", "useDebounceスニペットを追加");

// コピー
await page.click("[data-testid='copy-button']");
await app.shot("after_copy", "コピー成功時のチェックアイコン表示");

// 編集
await page.click("[data-testid='edit-button']");
await page.fill("input[name='title']", "useDebounce (updated)");
await page.click("button:has-text('保存')");
await app.shot("item_updated");
});

app.shot() の呼び出し順がそのまま操作フローのドキュメントになるので、後から見返すときの可読性が高いです。

globalTeardownでレビュー画面を自動生成

189枚のPNGをdesktop/tablet/mobileを横並びで比較できるHTMLレビュー画面に自動変換すると、レスポンシブ確認が一気に楽になります。

globalTeardownで全テスト完了後にスクリプトを走らせます。

scripts/build-review.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import fs from "node:fs";
import path from "node:path";

const EVIDENCE_ROOT = path.resolve("evidence");
const VIEWPORTS = ["desktop", "tablet", "mobile"];
const OUTPUT = path.join(EVIDENCE_ROOT, "review.html");

// 1. 全PNGを再帰的に集める
function walk(dir, list = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === "_report") continue;
walk(path.join(dir, entry.name), list);
} else if (entry.name.endsWith(".png")) {
list.push(path.join(dir, entry.name));
}
}
return list;
}

// 2. spec / test / step 単位にグルーピング
// 3. 各stepを VIEWPORT 別の3カラムで並べたHTMLを生成
// 4. evidence/review.html に書き出し

// ... 略 ...

これでevidence/review.htmlを開くと、同じステップのdesktop / tablet / mobileが横3列で並ぶレビュー画面が出来上がります。

レスポンシブの崩れを目視チェックするときに圧倒的に楽です。

実行コマンド

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 全プロジェクト(desktop/tablet/mobile)を実行
npx playwright test

# 特定 viewport のみ
npx playwright test --project=mobile

# 特定 spec のみ
npx playwright test e2e/02-item-crud.spec.js

# UIモードで対話的に実行
npx playwright test --ui

# HTMLレポートを開く
npx playwright show-report evidence/_report
  • --project=mobileで問題のあるviewportだけ素早く再テスト
  • --uiモードはステップ実行できるので、デバッグ時に便利
  • evidence/_reportにはPlaywright標準のレポートも残る

個人的には、npx playwright testだけでもいいと思ってますが、一応紹介します!

沼りポイント

fullyParallel: trueだとlocalStorageが衝突しごちゃごちゃになる

並列実行を有効にすると、同一baseURLに複数のブラウザが同時アクセスしてlocalStorageが混線することがあります。

workers: 1, fullyParallel: falseにして直列実行にする方が安定します。

3 viewport × 6 spec × 数十ステップでも、自分の環境だと2〜3分で終わるので問題ありません。

Clipboard APIがブラウザ起動オプションで必要

navigator.clipboard.writeText()をテストするにはpermissions: ["clipboard-read", "clipboard-write"]useに渡す必要があります。

これが無いとNotAllowedErrorで落ちます。
地味に沼るので忘れずに!

スクショが毎回微妙に違ってDiffが取りにくい

  • addStyleTagでアニメーション・transitionを無効化(必須)
  • フォーカスインジケータの点滅もスクショに混じることがあるので、document.activeElement.blur()で消す
  • 日付・時刻表示があるならDateのモック化を検討

tabletビューポートの定義が悩ましい

iPad縦 (820x1180) を取りましたが、Androidタブレットや横向きまでカバーしたい場合はさらに分岐が必要です。

最初は「desktop / mobileの2つだけ」でも十分価値があります。3
tablet必要に応じて足せばOKです。

まとめ

いかがでしたか?
今回はplaywrightで個人開発のレスポンシブサイトのテストを全自動化してみました!

今回紹介した点をまとめと以下の通り(^^

  • projects配列でdesktop / tablet / mobileを定義
  • カスタムフィクスチャapp.shot()でスクショ命名とmeta書き出しを自動化
  • evidenceディレクトリはviewport / spec / test / step.pngの階層
  • globalTeardownで横並びレビューHTMLを自動生成
  • fullyParallel: falseでlocalStorage衝突を回避

個人開発でレスポンシブの崩れを毎回目視で追うのが辛くなったら、ぜひ試してみてください。

自分は仕事でも使っている民なので、色々な書き方を試して、もっとマスターしていきたいですね!

ちなみにこのE2Eテストを使用して完成したツールである「こぴぺったり」は、コピー&ペーストを簡単に行えるツールになっております。
コピーしたいデータを登録し、登録したデータを押すと文字列がコピーされる仕組みです!
メールアドレスやメールの定型文、PGのよくある書き方集など使い道は様々、ディレクトリ階層がアプリ内にあり、分けやすさも兼ね備えております!

そしてなんと、「完全無料」「登録不要」「コピペデータのデータ送信なし」というセキュリティ的にも安心の一品です。
copype.shinpinoshi.comこぴぺったり
ここから使用できるので、是非使用してみてください!

それでは、よきE2Eライフを(^^b