こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。
昨年、Integration TestツールをCypressからPlaywrightに移行しました。その際、Visual Regression Test(以下、VRT)を始め、1年ほど経ったので弊社で行っているPlaywrightを用いたVRTの運用方法についてご紹介します。

前提
PR TIMESは、React + Vite製のアプリケーション(主に企業様の管理画面)とNext.js製のアプリケーション(SEOが重要なページ)が存在します。
本エントリーで紹介するのは、React + Vite製のアプリケーションに対するVRTとなります。
VRTの実行環境
VRT(Playwright)は公式のDocker Imageを用いて、Docker上で実行するようにしています。
ワンライナーで実行できるように以下のようなscriptsを定義し、 pnpm run test を実行するとVRT(Playwright)がDocker上で実行されるようにしています。
{
"scripts": {
"_docker": "docker run --rm --ipc=host -v $(pwd):/workspace mcr.microsoft.com/playwright:v$(node -e 'console.log(require(\"./package.json\").devDependencies[\"@playwright/test\"])')-jammy",
"_test": "playwright test",
"_test:ci": "CI=1 playwright test",
"_test:u": "playwright test --update-snapshots",
"serve": "ts-node -r tsconfig-paths/register server.ts",
"test": "pnpm run _docker npm run _test",
"test:ci": "pnpm run _docker npm run _test:ci",
"test:u": "pnpm run _docker npm run _test:u"
},
"devDependencies": {
"@hono/node-server": "1.4.0",
"@playwright/test": "1.37.1",
"@types/node": "20.11.0",
"hono": "4.0.4",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "5.3.3"
}
}Docker上でVRTを実行する理由は以下のとおりです。
- 実行環境のOSを合わせるため
- 文字のフォントを揃えるため
弊社ではVRTをローカル環境(Macbook Pro)とGitHub Actions(Linux)で実行しています。そのため、ローカル環境で直接VRTを実行してしまうと、GitHub ActionsとVRTを実行するOSが異なってしまいます。Playwrightのデフォルトの設定ではOS毎にVRTのスナップショットが作成されるため、OSに差異があるとスナップショットの比較が正しくされません。
MacOSでスナップショットを撮ると末尾に -darwin と付き、Linuxでスナップショットを撮ると -linux と付きます。

Playwrightの設定を変更することで、OSの名前をファイル名に含めないようにすることができますが、弊社ではDocker上で実行することにより、OSをLinuxに統一しています。
また、OSが違う場合、デフォルトでインストールされているフォントも違うため、Webページで表示される文字にズレが生じる可能性があります。
PR TIMESはWebフォントAPIなどを使用せず、 Helvetica Neue や Hiragino Kaku Gothic Pro などのフォントを使用しています。そのため、MacOSやLinux、またWindows上で表示されるフォントが変わってきます。この差異をなくすためにもDocker上でVRTを実行しています。
ReactアプリケーションのVRT向けの配信方法
VRTを行う際は普通 vite preview や serve といったツールを用いてReactアプリケーションをVRT用に配信することが多いと思います。しかし、PR TIMESでは以下の理由から hono を用いて配信を行なっています。
まず、PR TIMESのReactアプリケーションはReact Routerなどのルーティングライブラリを使用しておらず、Reactを使用したMPA(Multi Page Application)のような形になっています。
ただ、MPAといってもバックエンドが行なっていることは、ルーティングだけでDOM自体はReactによって構築されます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/js/vite/style.css" />
</head>
<body>
<div id="root"></div>
<!-- ページ毎に読み込むバンドルが変わる -->
<script type="module" src="/js/vite/pc/dashboard/index.js"></script>
</body>
</html>また、PR TIMESの管理画面では以下のようにクエリパラメータを用いてルーティングを行なっている箇所が多く存在します。

そのため、クエリパラメータに応じて返すHTMLを変える必要があり、これは前述した vite preview や serve では対応が困難でした。そのため、以下のように hono を用いてルーティングをするようにしています。
import path from 'node:path';
import fs from 'node:fs';
import {type Context, Hono} from 'hono';
import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
const app = new Hono();
app.get('/', (c) => c.text('Ready for start testing!'));
async function sendFile(c: Context, filePath: string) {
return c.html(
fs
.readFileSync(filePath, {
encoding: 'utf8',
})
.toString(),
);
}
app.get('/company/run.php', async (c) => {
switch (c.req.query('page')) {
case 'price': {
return sendFile(c, path.join(__dirname, '/public/company/price.html'));
}
case 'dashboard': {
return sendFile(
c,
path.join(__dirname, '/public/company/dashboard.html'),
);
}
case 'editor': {
return sendFile(c, path.join(__dirname, '/public/company/editor.html'));
}
default: {
return c.text('no-file-matched');
}
}
});
// public ディレクトリの静的配信
app.use(
serveStatic({
root: './public',
}),
);
serve(
{
fetch: app.fetch,
port: 9090,
},
({port}) => {
console.log(`⚡️ Mock server is running at http://localhost:${port}`);
},
);※ 上記の実装例では HTMLファイルから fs.readFileSync を用いて中身を取り出し、それを c.html() で返すようにしていますが、他に良い実装方法があるかもしれません。ご存じの方はご連絡いただけると嬉しいです。
最終的なVRTの構成としては以下のようになります。

Playwrightの設定について
playwrightの設定は以下のようにしています。
import process from 'node:process';
import type {PlaywrightTestConfig} from '@playwright/test';
const isCi = process.env.CI === '1';
const config: PlaywrightTestConfig = {
webServer: {
// VRT対象となるサーバー(hono)を立ち上げる
command: 'npm run serve',
port: 9090,
reuseExistingServer: true,
},
use: {
baseURL: 'http://localhost:9090',
// GitHub Actions上でvideoを撮りながらVRTをするとpixelShiftが発生するため、リトライした時のみvideoを撮るようにする
video: isCi ? 'on-first-retry' : 'on',
testIdAttribute: 'data-testid',
},
expect: {
toHaveScreenshot: {
animations: 'disabled',
maxDiffPixelRatio: 0,
threshold: 0.075,
},
},
retries: isCi ? 1 : 0,
reporter: [['html', {open: 'never'}], ['dot'], [isCi ? 'github' : 'null']],
fullyParallel: true,
};
export default config;
use.video の設定についてはVRTを始めた当時、たまに発生していた問題なので現在のPlaywrightでは解消しているかもしれません。
retries の回数については当初3回にしていましたが、1回目のテストが落ちた場合、2回目以降のテストもほぼ落ちると実際に運用してみて感じたため、現在は1回のみにしています。Flakyが少ない要因として、PlaywrightのAuto-wait機能や toHaveScreenshot 、次で紹介しているようにAPIを完全にmockし、外部に依存していないためだと考えています。
APIのmock
VRTを実行する際は Playwright の fulfill を使用してAPIをmockしています。
import {expect, test} from '@playwright/test';
test('VRT', async ({page}) => {
await page.route('/api/dashboard', async (route) =>
route.fulfill({
json: {
status: 200,
message: 'OK',
data: {
hello: 'world',
},
},
}),
);
await page.goto('/company/run.php?page=dashboard');
await expect(page).toHaveScreenshot('vrt.png', {
fullPage: true,
});
});fulfill を使用することでネットワークなどの影響を受けずにVRTを実行することができるため、Flakyさの軽減に繋がっています。他にもAPIをmockしている理由は様々ありますが、こちらの記事で紹介していますので、ここでは割愛します。

toHaveScreenshotを使用したスナップショットの比較
スナップショットを比較する関数には toHaveScreenshot を使用しています。VRTはアニメーションなどの影響でFlakyになることが多々ありますが、 toHaveScreenshot は以下のような効果があり、Flakyになる要因を減らしてくれます。
This function will wait until two consecutive page screenshots yield the same result, and then compare the last screenshot with the expectation.
和訳(DeepL)
この関数は、2つの連続したページのスクリーンショットが同じ結果を返すまで待機し、最後のスクリーンショットを期待値と比較します。
https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1
これにより、JavaScriptでアニメーションを実装しているUIに対しても、waitForTimeout などの処理を行うことなくVRTをすることができています。
また、 toHaveScreenshot には mask というオプションがあり、Gifアニメーションなどを表示している箇所に対して、以下のようにマスキング処理ができます。
import {expect, test} from '@playwright/test';
test('VRT', async ({page}) => {
await page.goto('/company/run.php?page=dashboard');
await expect(page).toHaveScreenshot('vrt.png', {
fullPage: true,
// imgタグを全てマスキングする
mask: page.locator('img').all()
});
});

このようにmaskオプションを使用することで、VRTが難しい箇所を除外してVRTを実行することが可能となります。
運用フロー
VRTで使用するスナップショットはGitで管理しており、ローカル環境で playwright test —update-snapshots を実行して更新するようにしています。そして、Pull Request毎にGitHub Actionsで playwright test を実行し、スナップショットに差分がないか確認を行っています。
GitHub Actionsのworkflowは以下のようにしています。
name: VRT CI
on:
pull_request:
permissions:
id-token: write
contents: write
pull-requests: write
env:
AWS_REGION: ap-northeast-1
AWS_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-VRT
jobs:
vrt:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- name: Configure AWS credentials from IAM Role
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
with:
node-version: 20
- uses: pnpm/action-setup@v2
- name: Install deps
shell: bash
run: pnpm i --frozen-lockfile
- name: Build
run: pnpm run build
- name: Get installed Playwright version
id: playwright-version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4
id: playwright-config-cache
with:
path: ~/.cache/ms-playwright
key: playwright-config-cache-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
- name: Install Playwright
if: steps.playwright-config-cache.outputs.cache-hit != 'true'
run: pnpm dlx playwright@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} install --with-deps chromium
- name: Run Playwright
run: pnpm test:ci
- uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4
if: success() || failure()
with:
name: playwright-test-results
path: test-results
retention-days: 3
- if: failure()
name: Upload playwright-report
run: |
aws s3 cp --recursive playwright-report s3://prtimes-vrt/${{ github.head_ref }}/playwright-report --region ap-northeast-1
- if: failure()
name: Create comment of playwright-report URL
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
## ❌ VRT Failure
https://prtimes-vrt.s3.ap-northeast-1.amazonaws.com/${{ github.head_ref }}/playwright-report/index.htmlこのworkflowで特徴的な点として、テストの実行レポート(playwright-report)をHTMLで出力し、AWS S3にアップロードしていることが挙げられると思います。
実行レポートとは別に、テストの実行結果(test-results)もGitHubのartifactにアップロードしていますが、artifactから毎回ダウンロードして結果を確認するのが少々手間です。また、ダウンロードしたフォルダには actual, diff, expect の画像とテスト実行時の動画が入っているだけなので、細かい差分が見にくいという問題がありました。

playwright-report は以下のように差分を見ることができ、差分の確認が容易です。また、Pull Request上のコメントに S3 のURLが載るので、他のエンジニアへの結果の共有も簡単にできるというメリットもあります。


まとめ
PR TIMESではPlaywrightを用いてVRTを行っており、全体で約250枚のスナップショットを運用しています(2024年4月25日現在)。Playwrightを使用することで、VRTでよくあるFlakyさの軽減や、シンプルな構成でVRTを行えることで運用負荷の軽減に繋がっていると感じています。
また、renovateを用いたライブラリのバージョンアップなどでも活躍してくれており、VRTが通ることでかなり安心感があります。

We are hiring!
フロントエンドはもちろん、各種ポジションで採用を行っています!

