👏

storybookのVRTでjest-image-snapshotを試す

2022/07/10に公開

始めに

前の記事でChromaticを試してみましたが、pendingステータスが気になったのと、フローが若干冗長になりそうでした。

https://zenn.dev/wintyo/articles/6bea3e999ad537

別な手段としてstorybookからはjest-image-snapshotが紹介されていたので、この記事ではこれを試した内容をまとめました。

For a self-managed alternative to Chromatic, we offer StoryShots. It allows you to run visual tests on stories by integrating with jest-image-snapshot.

https://storybook.js.org/docs/react/writing-tests/visual-testing

導入方法

storyshotsの導入

まずは以下のようなstoryshotsの設定が動くような環境を用意してください。

snapshot.storyshots.ts
import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots';

initStoryshots({
  test: multiSnapshotWithOptions(),
});

今回はReactで環境用意しましたが、意外と導入に苦労したので以下の記事も参考にすると良いと思います(現最新パージョンだとまた微妙に違ったエラーにも出くわしたので、時間あったら僕の方でもまとめるかもしれないです)
https://zenn.dev/ucwork/articles/c331de8917ea5b
https://zenn.dev/nbstsh/scraps/9ab1917ef6f5f3

jest-image-snapshotの導入

ここまで設定できるとあとは簡単で、initStoryshotstestオプションをimageSnapshotに差し替えるだけでOKです。

snapshot-image.storyshots.ts
+import path from 'path';
-import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots';
+import initStoryshots from '@storybook/addon-storyshots';
+import { imageSnapshot } from '@storybook/addon-storyshots-puppeteer';

 initStoryshots({
-  test: multiSnapshotWithOptions(),
+  test: imageSnapshot({
+    // ビルド後の出力ファイルを指定するか、storybookにアクセスできるURLを指定する
+    storybookUrl: `file:${path.resolve(__dirname, '../storybook-static')}`,
+  }),
 })

https://github.com/storybookjs/storybook/tree/main/addons/storyshots/storyshots-puppeteer#imagesnapshots

storyshotsはこのテストコード1つだけで全storyに対して実行されるので、jest自体も分けて実行すると良いと思います。

jest.config.snapshot-image.ts
import type { Config } from '@jest/types';
import baseConfig from './jest.config';

const config: Config.InitialOptions = {
  ...baseConfig,
  testMatch: ['**/snapshot-image.storyshots.ts'],
};

export default config;

configを指定して実行する場合は以下のようにタスクを書くとできます。

package.json
{
  "scripts": {
    "snapshot-image": "jest --config ./jest.config.snapshot-image.ts"
  }
}

あとはpuppeteerなど必要なmodulesをinstallして実行すれば画像のスナップショットが撮れます。

Docker経由で実行させる

これであとはCIにも組み込んで終わりかと思いましたが、1つ問題があります。ローカルの環境とCIの環境でChromiumを使った表示が微妙にずれてしまいます。

例えば以下のような感じで、outlineの色が違っていたり、フォントにずれがあったりします。

このずれを無くすには全く同じ環境でスナップショットを撮る必要があり、つまりDocker経由でスナップショットを撮らなければいけません。
そこで画像のスナップショットが撮れるDocker環境を以下のようなコードで用意しました。とりあえず用意した感じなのでもっと良い書き方があるかもしれませんが、そこはご容赦を。

Dockerfile
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
RUN apt-get install -y nodejs chromium-browser fonts-ipafont
RUN npm install -g yarn
WORKDIR /root
docker-compose.yml
version: '3.0'
services:
  node:
    build: .
    tty: true
    volumes:
      - .:/root

普段使いではあんまりdockerを意識せずに実行させたい(npm scriptsで管理したい)ので、以下のようにshellでタスクをラップしてから呼び出すようにします。

docker-scripts/snapshot-image.sh
# 外部からの引数も引き継がせるため$@をつける
yarn jest --config ./jest.config.snapshot-image.ts $@
package.json
 {
   "scripts": {
-    "snapshot-image": "jest --config ./jest.config.snapshot-image.ts",
+    "snapshot-image": "docker-compose run node /bin/bash ./docker-scripts/snapshot-image.sh"
   }
 }

shell scriptで$@としているのはupdateするときの-uオプションを付与するときに渡せるようにするためです。

$ yarn snapshot-image -u

CIの設定をする

あとはCIの方でも設定します。Dockerfileのもので動かす方法が分かりませんでしたが、docker-composeがデフォルトで使えたのでとりあえず最初から全部docker-composeを使って動かすようにしています。

snapshot.yml
name: Snapshot

on: push

jobs:
    steps:
      - uses: actions/checkout@v2

      - name: Docker build
        run: docker-compose build

      - name: Install
        run: docker-compose run node yarn install

      - name: Storybook Build
        run: docker-compose run node yarn storybook:build

      - name: Snapshot Image
        run: docker-compose run node /bin/bash ./docker-scripts/snapshot-image.sh

その他

CIでdiff画像を保存する

CIでエラーが起きた時はこのままだと以下のようなエラーしか出ず具体的にどういう差分が出ているのか分かりません。

  ● Storyshots › Example/Counter › Base
    Expected image to match or be a close match to snapshot but was 0.16958333333333334% different from snapshot (814 differing pixels).
    See diff for details: /root/stories/__image_snapshots__/__diff_output__/snapshot-image-storyshots-ts-storyshots-example-counter-base-1-diff.png
      at node_modules/@storybook/addon-storyshots-puppeteer/dist/ts3.9/imageSnapshot.js:26:31
      at fulfilled (node_modules/@storybook/addon-storyshots-puppeteer/dist/ts3.9/imageSnapshot.js:5:58)
  ● Storyshots › Example/Counter › Local
    Expected image to match or be a close match to snapshot but was 0.169375% different from snapshot (813 differing pixels).
    See diff for details: /root/stories/__image_snapshots__/__diff_output__/snapshot-image-storyshots-ts-storyshots-example-counter-local-1-diff.png
      at node_modules/@storybook/addon-storyshots-puppeteer/dist/ts3.9/imageSnapshot.js:26:31
      at fulfilled (node_modules/@storybook/addon-storyshots-puppeteer/dist/ts3.9/imageSnapshot.js:5:58)
 › 2 snapshots failed.

もちろんローカルで実行したら__diff_output__ディレクトリに差分画像が出力されるのでそれをみてもらうでも良いですが、GitHubのArtifactを利用することで差分を保存して確認することができます。

snapshot.yml
 jobs:
     steps:
       - uses: actions/checkout@v2

       - name: Docker build
         run: docker-compose build

       - name: Install
         run: docker-compose run node yarn install

       - name: Storybook Build
         run: docker-compose run node yarn storybook:build

        - name: Snapshot Image
          run: docker-compose run node /bin/bash ./docker-scripts/snapshot-image.sh

+      - name: Output diff
+        uses: actions/upload-artifact@v2
+        if: failure()
+        with:
+          name: diff-output
+          path: ./stories/__image_snapshots__/__diff_output__

この設定をするとGitHub ActionsのSummaryのところをクリックして、下のArtifactsセクションからダウンロードすることができます。

ローカルのstorybookにアクセスしてスナップショットを撮る方法

storybookUrlに稼働中のstorybookのURLを指定すれば良いのですが、localhost指定はDockerからではアクセスできません。ただ、storybook起動時に出てくるOn your networkを指定するとアクセスすることができます。

╭──────────────────────────────────────────────────╮
│                                                  │
│   Storybook 6.5.9 for React started              │
│   6.15 s for manager and 7.81 s for preview      │
│                                                  │
│    Local:            http://localhost:6006/      │
│    On your network:  http://192.168.0.3:6006/    │
│                                                  │
╰──────────────────────────────────────────────────╯
snapshot-image.storyshots.ts
 initStoryshots({
   test: imageSnapshot({
     // ビルド後の出力ファイルを指定するか、storybookにアクセスできるURLを指定する
-    storybookUrl: `file:${path.resolve(__dirname, '../storybook-static')}`,
+    storybookUrl: 'http://192.168.0.3:6006/',
   }),
 })

ただこれだと毎回コードを書き換える必要があるので以下のようにimport用ファイルを用意して、そこを参照すると良いです。

storybook-url.js
module.exports = 'http://192.168.0.3:6006/';
snapshot-image.storyshots.ts
 initStoryshots({
   test: imageSnapshot({
     // ビルド後の出力ファイルを指定するか、storybookにアクセスできるURLを指定する
-    storybookUrl: 'http://192.168.0.3:6006/',
+    storybookUrl: require('../storybook-url'),
   }),
 })

使ってみた感想

良いところ

運用フローはスナップショットテストと同じ

既にスナップショットテストをやっている場合は、書き方・やり方はほぼ同じでできるので、扱いやすいと思いました。スナップショットと同じようにdiffのエラーが出て、更新したければ-uオプションをつけて実行して更新します。diffもスナップショットと同じようにGitHub上で差分を確認できます。

気になるところ

Docker実行のコストが毎回かかる

同じ環境にするため、CIもDockerを毎回呼び出す必要があり、その時間は少し勿体無いなと思いました。少なくとも既にnodeが入っているDocker Imageを使ったらもう少しインストールステップを減らせるのですが、chromium-browserが上手くインストールできませんでした。この辺はもう少し調整の余地がありそうです。

ローカルでのstorybook対象はビルド済みファイルにすべきか起動中のものにするか

基本的にスナップショットの更新はビルド済みのものにするとビルド忘れで古いファイルでスナップショットを撮ってしまう可能性があるので起動中のstorybookの方が良いが、Docker上だとホスト側のlocalhostにアクセスできません。storybookの起動自体もDockerにする案もありそうですが、パフォーマンスは明らかにローカルの方が早く、個人的にはあんまりDockerに頼りたくはないのでIP指定になりますが、毎回設定ファイルにIPを指定するのも面倒です。storybook起動時に設定ファイルを出力する方法もありそうですが、どんどんゴリゴリの設定を書くことになってしまい、この辺の落とし所が見つかってないです。

まとめ

以上がjest-image-snapshotを使ってみた内容です。個人的にはフローが普通のsnapshotテストと同じでチームに受け入れやすいかなと思いますが、Docker経由で実行したり、storybookが起動中またはビルド済みでないと画像のスナップショットはできないという制約はあったりと多少気にしなければいけないポイントがあるなと感じました。とはいえ新しいサービスを導入するわけでもなく、スナップショットテストを既に取り入れている場合はそこまで設定は大変ではないので導入はしやすいのかなと感じました。
こちらの検証もChromatic導入と同じレポジトリで試しており一緒くたになっておりますが、興味がある方はこちらもご参照してみてください。

https://github.com/wintyo/storybook-react

Discussion