はじめに
株式会社MG-DXでWebフロントエンド兼Flutterアプリエンジニアを業務を行なっています。自社のサービスとして、医療機関、薬局向けに薬急便のサービスを提供しています。
薬急便では複数のWebサービスを提供していますが、一つのリポジトリで開発して、いわゆるモノレポ環境になっています。packages配下に各サービスと共通のUIコンポーネントを置いた構成です。
└── packages
├── pharmacy
├── admin
├── client
└── ui
UIのテストは、HTMLのスナップショットテストは入っています。しかし、CSSは比較対象にならないので、レイアウトとして問題ないかがわかりません。そこで、テスト環境を強化することになり、Visual Regression Testを導入しました。
この記事で説明する内容
- storybookを利用したスナップショット撮影
- モノレポ環境でのVisual Regression Test
薬急便自体は、Gatsbyで構築しているのですが、今回はサンプルとしてはNext.jsで用意しました。ベースのモノレポ環境があるところから説明します。
ビルドしたstorybookでスナップショット撮影
用意したリポジトリは、最低限のページとコンポーネントで構成されています。今回は、storybookが用意されているものに対して、テスト行うように構築します。
今回、スナップショットの撮影では、playwrightを使います。流れとしては、
- storybookをビルド
- ビルドされた設定ファイルを読み取り、パラレルに撮影
- 特定のディレクトに画像を出力
までを実装します。
storybookをビルドしている理由ですが、storybookをすべてでスナップショットを撮ると、動作確認用に状態ごとを定義しているのもあり、それを全部出力するのはちょっと多すぎるので、ビルド時に書き出したJSONを使って、間引くことができるようにします。
基本はDefaultの名前でstorybookを書き出しているので、その内容だけを出力するようにします。
playwrightの設定
テストを実行する際に、他のテストを無視して、スナップショットの撮影だけのテストにしたいので、ConfigでVisual Regression Testのディレクトリだけを実行するようにします。
import type { PlaywrightTestConfig } from "@playwright/test";
const config: PlaywrightTestConfig = {
timeout: 30000,
globalTimeout: 600000,
testDir: "./snapshot",
snapshotDir: "./snapshot/__snapshots__",
outputDir: "./snapshot/__output__",
use: {
viewport: { width: 720, height: 900 },
},
reporter: process.env.CI ? "line" : [["html", { outputFolder: "./snapshot/__report__" }]],
};
このサンプルではuiとwebがあるので、外部のlibs/playwrightを作って、共通で読み込んで処理しています。パッケージ内の./snapshot
にテストを書いて、その中に結果を保存します。
storybookのビルド
package.jsonにビルドのscriptを追加します。
{
"scripts": {
"vrtest-setup": "storybook build --quiet --output-dir ./build"
}
}
テストファイルの追加
ビルドされたファイルを読み取って、playwrightを使ってスナップショット撮影を行うテストを追加します。このとき、Story名を見て、Defaultだけを書き出すようにしています。
import { expect, test } from "@playwright/test";
import { readFileSync } from "fs";
import { resolve } from "path";
const storybookDir = resolve(__dirname, "..", "build");
const data: any = JSON.parse(readFileSync(resolve(storybookDir, "stories.json")).toString());
test.describe.configure({ mode: "parallel" });
const items = Object.values(data.stories);
items.forEach(async (story: any) => {
test(`snapshot test ${story.title}: ${story.name}`, async ({ page }) => {
if (story.name.match(/Default/)) {
await page.goto(`http://127.0.0.1:3001/iframe.html?id=${story.id}&viewMode=story`, { waitUntil: "networkidle" });
const image = await page.screenshot({ fullPage: true });
expect(image, {}).toMatchSnapshot([story.title, `${story.id}.png`]);
} else {
test.skip();
}
});
});
また、通常のスナップショットテストで動かないようにsnapshotをjestの設定から外します。
{
testPathIgnorePatterns: ["/node_modules/", "<rootDir>/snapshot"],
}
起動設定の追加
記事を参考にしました。最初はローカルファイル参照で起動していたんですが、CORSが出たので、普通にhttp-serverを起動する形にしました。
{
"scripts": {
"vrtest-server": "http-server ./build -p 3001",
"vrtest-snapshot": "start-server-and-test vrtest-server http://127.0.0.1:3001 'yarn playwright test --update-snapshots'"
}
}
これで実行すると、snapshot配下にstorybookの内容を撮影したものが保存されるようになり、Visual Regression Testの準備ができました。
reg-suitを使ってVisual Regression Testを行う
画像の比較には、reg-suitを使います。今回はモノレポ環境でreg-suitを行うので、保存場所もそれぞれで保存されるようにします。
基本的な設定は、よくある設定なのですが、keyの比較は独自に処理するようにします。
{
"core": {
"actualDir": "./snapshot/__snapshots__",
"thresholdRate": 0,
"addIgnore": false,
"ximgdiff": {
"invocationType": "client"
}
},
"plugins": {
"reg-simple-keygen-plugin": {
"expectedKey": "${EXPECTED_KEY}",
"actualKey": "${ACTUAL_KEY}"
},
"reg-notify-github-plugin": {
"clientId": "${REG_NOTICE_CLIENT_ID}",
"prComment": true,
"prCommentBehavior": "new",
"setCommitStatus": true,
"shortDescription": true
},
"reg-publish-s3-plugin": {
"bucketName": "${REG_S3_BUCKET_NAME}",
"pathPrefix": "web"
}
}
}
キャッシュキーの比較方法
通常であれば、reg-keygen-git-hash-plugin
を使えばいいんですが、今回は、スナップショットの撮影は該当のpackageに影響がある場合だけにします。このため、githubの履歴からだとキーがマッチしない場合があるので、使えません。
キーの計算は、Github ActionsのhashFilesを使って、指定するようにします。PR先を落として、影響範囲があるファイルをhashFilesで指定してキーを作成し、その後本来のPR元のキーを落として、スナップショットのテストをします。これをreg-suitに渡るようにすれば、対象のスナップショットを参照してくれます。
- name: set up base repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.base.ref }}
- name: set base env
run: |
echo "EXPECTED_KEY=$KEY" >> $GITHUB_ENV
env:
KEY: ${{ hashFiles('libs/**', 'package.json', 'packages/ui/**', 'yarn.lock') }}
- name: set up head repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
- name: set head env
run: |
echo "ACTUAL_KEY=$KEY" >> $GITHUB_ENV
env:
KEY: ${{ hashFiles('libs/**', 'package.json', 'packages/ui/**', 'yarn.lock') }}
おまけ
Github ActionsでのVisual Regression Test
GitHub Actions上でテストを行う場合、pull_requestとpush時にそれぞれ実行するように指定します。こうすることで、差分が出た時に、PRのapprovedでredからgreenになり、pushされると更新されます。
name: visual regression test
on:
pull_request:
branches:
- main
paths:
- libs/**
- package.json
- packages/ui/**
- yarn.lock
push:
branches:
- master
paths:
- libs/**
- package.json
- packages/ui/**
- yarn.lock
jestの書き出し処理
jestのスナップショットもこのサンプルでは行なっているのですが、Fakeを無視するようにしてます。OpenAPIをorvalを使って書き出しているのですが、MSW用で書き出されたfakeのモデルを返すのを割り当ててランダムで変わってしまうので、無視するようにします。
import { composeStories, StoryFile } from "@storybook/testing-react";
import { render } from "@testing-library/react";
import React from "react";
export const renderFromStories = (stories: StoryFile) => {
const composed = composeStories(stories);
const testCases = Object.values(composed).map((Story: any) => [Story.storyName, Story, Story.args]);
test.each(testCases)("renders %s", (_storyName, Component: any, args) => {
if (_storyName.match(/Fake/)) return;
const tree = render(<Component {...(args || {})} />);
expect(tree.baseElement).toMatchSnapshot();
});
};
最後に
今回の例は、PR単位で確認できるようにしていますが、実際のサービスでは、複数立ち上がっている中、Visual Regression Testはそれなりに時間がかかって、コストになるので、releaseとmainの1日1回の差分だけのテストにして、更新対象範囲がわかるだけでもいいかなと思っています。
Next.JSのサンプルとしても使えるようにしたいなと思って、サンプル環境を作るのにすごい時間がかかってしまいました。そこそこ、動くように作っているので、参考になれば幸いです。