📸

Storyshots を 使わずに Playwright で Storybook の VRT をする

2022/05/06に公開

はじめに

Storybook を使ってコンポーネントをカタログを作成し、様々な状態ごとにスクリーンショットを取得することで変更による視覚的なデグレを検知するビジュアルリグレッションテスト (Visual Regression Testing) が広く実施されている。

これの実現手段としていくつかが存在しているが、最も導入しやすい手段の一つは Storybook が公式でサポートしている addon-storyshots-puppeteer を利用することだと思う。

一方で名前にもあるように puppeteer を使っているため chrome と firefox ブラウザしか使うことができなかったり、なぜか peerDependencies である puppeteer のバージョンがすごい古かったり (結構前から Issue にも挙がっている)、テストランナーが jest という特徴がある。Issue にもあるが、最近は vitest を使いたいというニーズもあるようだ。

私自身も、ソースコードの近くにテストが書けたり、設定の必要が少ないという点から vitest を使いたいと思うことが増えている。

そうなってくると、VRT のために別のテストランナーである jest やそれのトランスフォーマーの設定やパッケージを入れるのがめんどくさくなってきた。

そういった背景があり、addon-storyshots-puppeteer を使わないで VRT をできるようにしたくなった。

ここでは、Playwright を使うことで、chrome や firefox 以外でも VRT が実施できるようにしつつ、@playwright/test を使うことで、設定回りも楽にしたいと思う。

全部終わった状態のリポジトリは以下。

https://github.com/sterashima78/vue-storybook-snapshot-playwright

準備

vite でプロジェクトを作る。利用機会が多いので、Vue.js を選択しているが、ここでは特に関係がない。

$ npm create vite@latest . -- --template vue-ts
$ echo "16.15.0" > .node-version
$ npx sb@next init # Storybook 入れる。 6.5.x を使いたいので next にする
$ npm run storybook # いったん起動

VRT が実行できる状態にする

まずは、Playwright 入れる。

$ npm i -D @playwright/test

test を書くが、テストの内容としては、Storybook の各 Story を開いてスナップショットを取得し、比較するというものになる。

そのためには、存在する Story などのメタデータが欲しくなるが、以下の設定変更をすると、Storybook ではこれを出力できる。

.storybook/main.js
module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions"
  ],
  "framework": "@storybook/vue3",
  "core": {
    "builder": "@storybook/builder-vite"
  },
  "features": {
    "storyStoreV7": true,
+   "buildStoriesJson": true
  }
}

この状態でビルドしてみると、stories.json というメタデータファイルが取れる。

$ npm run build-storybook
$ cat storybook-static/stories.json 
{"v":3,"stories":{"example-introduction--page":{"id":"example-introduction--page","title":"Example/Introduction","name":"Page","importPath":"./src/stories/Introduction.stories.mdx"},"example-button--primary":{"id":"example-button--primary","title":"Example/Button","name":"Primary","importPath":"./src/stories/Button.stories.js"},"example-button--secondary":{"id":"example-button--secondary","title":"Example/Button","name":"Secondary","importPath":"./src/stories/Button.stories.js"},"example-button--large":{"id":"example-button--large","title":"Example/Button","name":"Large","importPath":"./src/stories/Button.stories.js"},"example-button--small":{"id":"example-button--small","title":"Example/Button","name":"Small","importPath":"./src/stories/Button.stories.js"},"example-header--logged-in":{"id":"example-header--logged-in","title":"Example/Header","name":"Logged In","importPath":"./src/stories/Header.stories.js"},"example-header--logged-out":{"id":"example-header--logged-out","title":"Example/Header","name":"Logged Out","importPath":"./src/stories/Header.stories.js"},"example-page--logged-out":{"id":"example-page--logged-out","title":"Example/Page","name":"Logged Out","importPath":"./src/stories/Page.stories.js"},"example-page--logged-in":{"id":"example-page--logged-in","title":"Example/Page","name":"Logged In","importPath":"./src/stories/Page.stories.js"}}}

これを使ってページを巡回させる。

では、テストを書いていく。

tests/snapshots.test.ts
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { StoryIndex } from "@storybook/store";

const storybookDir = resolve(__dirname, "..", "storybook-static")
const data: StoryIndex = JSON.parse(readFileSync(resolve(storybookDir, "stories.json")).toString())
test.describe.parallel("visual regression testing", ()=> {
    Object.values(data.stories).forEach((story) => {
        test(`snapshot test ${story.title}: ${story.name}`, async ({ page })=> {
            await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, { waitUntil: 'networkidle' })
            expect(await page.screenshot({ fullPage: true})).toMatchSnapshot([story.title, `${story.id}.png`])
        })
    })
})

実行する。jest のようにいろいろ設定を書かなくてもTypescript で記述したテストが実行できるのはうれしい。

$ npx playwright install # 必要なら
$ npm run build-storybook
$ npx http-server storybook-static
$ npx playwright test tests/snapshot.test.ts # 別ターミナルで

スナップショットがないので初回は失敗するが、tests/snapshot.test.ts-snapshots 以下にスナップショットが出力されているはずだ。

もう一度実行すればパスするし、変更をすればその Story についてのテストは失敗するだろう。

$ npx playwright test tests/snapshot.test.ts 

Running 9 tests using 1 worker

  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Introduction: Page (1s)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Button: Primary (758ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Button: Secondary (733ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Button: Large (719ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Button: Small (720ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Header: Logged In (739ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Header: Logged Out (735ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Page: Logged Out (750ms)
  ✓  tests/snapshot.test.ts:9:5 › snapshot test Example/Page: Logged In (746ms)

使いやすいように整える

前章までで、基本的には終わりなのだが、利用しやすいように設定などを書いていく。

テストレポートを出力する

Playwright のテストレポートは割と見やすいため、これを出力させる。

playwright.config.ts
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
    outputDir: "vrt-test/out",
    reporter: [ ['html', { open: 'never', outputFolder: "vrt-test/report" }] ],
};
export default config;

vrt-test/report の下を見るとテストレポートが閲覧できる。

差分表示も閲覧がしやすい。

並列に実行させる

これは、addon-storyshots-puppeteer での課題でもあったが、現状だとテストは直列に行われる。story が増えるにつれて実行時間が伸びてなかなか終わらないという経験は多くの人があると思う。

Playwright はテスト並列実行が可能であるため、この対応をする。

tests/snapshots.test.ts
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { StoryIndex } from "@storybook/store";

const storybookDir = resolve(__dirname, "..", "storybook-static")
const data: StoryIndex = JSON.parse(readFileSync(resolve(storybookDir, "stories.json")).toString())
+test.describe.parallel("visual regression testing", ()=> {
    Object.values(data.stories).forEach((story) => {
        test(`snapshot test ${story.title}: ${story.name}`, async ({ page })=> {
            await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, { waitUntil: 'networkidle' })
            expect(await page.screenshot({ fullPage: true})).toMatchSnapshot([story.title, `${story.id}.png`])
        })
    })
+})

実行すると、結果は変わらないが using 2 workers となっているとおり、二つのテストケースが並列に実行されていることがわかる。

$ npx playwright test tests/snapshot.test.ts 

Running 9 tests using 2 workers

  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/Int (1s)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (924ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (747ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (740ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (749ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (725ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (736ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (732ms)
  ✓  tests/snapshot.test.ts:10:9 › visual regression testing › snapshot test Example/ (739ms)

テストスクリプトの整理

storybook を build ・ serve して、テストを実行するという流れだが、これを一連のスクリプトにまとめておく

まず、利用するパッケージを追加し、

$ npm i -D start-server-and-test http-server

npm script を足す

package.json
{
  "name": "vue-storybook-snapshot-playwright",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook",
+   "preview-storybook": "http-server storybook-static",
+   "vrt": "start-server-and-test preview-storybook http://localhost:8080 'playwright test tests/snapshot.test.ts'",
+   "prepreview-storybook": "npm run build-storybook"
  },
  "dependencies": {
    "vue": "^3.2.25"
  },
  "devDependencies": {
    "@babel/core": "^7.17.10",
    "@playwright/test": "^1.21.1",
    "@storybook/addon-actions": "^6.5.0-beta.5",
    "@storybook/addon-essentials": "^6.5.0-beta.5",
    "@storybook/addon-interactions": "^6.5.0-beta.5",
    "@storybook/addon-links": "^6.5.0-beta.5",
    "@storybook/builder-vite": "^0.1.33",
    "@storybook/testing-library": "^0.0.11",
    "@storybook/vue3": "^6.5.0-beta.5",
    "@vitejs/plugin-vue": "^2.3.1",
    "babel-loader": "^8.2.5",
    "http-server": "^14.1.0",
    "start-server-and-test": "^1.14.0",
    "typescript": "^4.5.4",
    "vite": "^2.9.7",
    "vue-loader": "^16.8.3",
    "vue-tsc": "^0.34.7"
  }
}

以下で、テストできる。

$ npm run vrt

おわりに

利用するツール群が webpack や jest たちから、vite や vitest へと変化しつつあり、その結果、設定の単純化などを目的に webpack と jest に依存しているツール群から離れたくなることが動機で Playwright で Storybook の VRT を実装した。

Storybook も、(まだまだはまりどころはあるが) vite-builder によって webpack を使わなくてよくなったし、Playwright もほとんど設定を書かないでTypescript で書いたテストコードが実行できるため、必要な設定が減ってきていることを実感した。

知っていればそれほど設定には苦労しないのだが、組織的な開発になるとどうしても知っている人と知らない人の格差が大きくなってしまうため、これが少なくなることに越したことはない。

今回の構成にはおおむね満足しているのだが、不満な点が一つある。
VRT のスナップショットは、以下のように story ファイルの近くに置くのが個人的な趣味なのだが、Playwright の制約で、テストファイルの隣にできるスナップショットディレクトリ以下にしか配置できない。

src/
└── components
    ├── __stories__
    │   ├── index.stories.js
    │   └── __snapshots__
    │       └── snapshot.png
    └── HelloWorld.vue

Issue にも同様なものが既に存在するが、まだ対応はされないようだ。

あと、そもそも Chromatic などを使って丸ごと SaaS にお任せするというのもあると思うが、 monorepo の構成だと工夫をしないとうまくハンドリングできない など特定のケースではまらないなどあるので、手元でスナップショットを保存するタイプの VRT には需要があると思っている。

Discussion