モノレポ環境でVisual Regression Testを導入

# はじめに

株式会社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で用意しました。ベースのモノレポ環境があるところから説明します。

https://github.com/ueki-tomohiro/storybook-visual-regression-test

## ビルドしたstorybookでスナップショット撮影

用意したリポジトリは、最低限のページとコンポーネントで構成されています。今回は、storybookが用意されているものに対して、テスト行うように構築します。

今回、スナップショットの撮影では、playwrightを使います。流れとしては、

1. storybookをビルド
1. ビルドされた設定ファイルを読み取り、パラレルに撮影
1. 特定のディレクトに画像を出力

までを実装します。

storybookをビルドしている理由ですが、storybookをすべてでスナップショットを撮ると、動作確認用に状態ごとを定義しているのもあり、それを全部出力するのはちょっと多すぎるので、ビルド時に書き出したJSONを使って、間引くことができるようにします。

基本はDefaultの名前でstorybookを書き出しているので、その内容だけを出力するようにします。

### playwrightの設定

テストを実行する際に、他のテストを無視して、スナップショットの撮影だけのテストにしたいので、ConfigでVisual Regression Testのディレクトリだけを実行するようにします。

```TypeScript
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を追加します。

```package.json
{
  "scripts": {
    "vrtest-setup": "storybook build --quiet --output-dir ./build"
  }
}
```

### テストファイルの追加

ビルドされたファイルを読み取って、playwrightを使ってスナップショット撮影を行うテストを追加します。このとき、Story名を見て、Defaultだけを書き出すようにしています。

```snapshot.test.ts
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の設定から外します。

```config.js
{
  testPathIgnorePatterns: ["/node_modules/", "<rootDir>/snapshot"],
}
```

### 起動設定の追加
[記事](https://zenn.dev/toono_f/scraps/2368d03dacca93)を参考にしました。最初はローカルファイル参照で起動していたんですが、CORSが出たので、普通にhttp-serverを起動する形にしました。

```package.json
{
  "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の比較は独自に処理するようにします。

```regconfig.json
{
    "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に渡るようにすれば、対象のスナップショットを参照してくれます。

```visual-regression-test-ui.yaml
      - 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からｇreenになり、pushされると更新されます。

```visual-regression-test-ui.yaml
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のモデルを返すのを割り当ててランダムで変わってしまうので、無視するようにします。

```jest/index.ts
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のサンプルとしても使えるようにしたいなと思って、サンプル環境を作るのにすごい時間がかかってしまいました。そこそこ、動くように作っているので、参考になれば幸いです。

https://github.com/ueki-tomohiro/storybook-visual-regression-test