Storybookで始めるVisual Regression Test

この記事は [NSSOL Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nssol) 6日目の記事です。

## はじめに

私が現在所属しているチームでは、React + TypeScriptを用いてフロントエンドの開発を行っています。しかしながら、私も含めてチームメンバーはフロントエンドの経験が浅いため、多くの課題にぶつかりながら開発を進めています。

そこで本記事では、私が感じている課題と、課題を解決するために今後取り入れていきたい技術要素の検証を行っていきます。

## 解決したい課題

私たちのチームでは、Adobe XDでUIデザインを行った後で、Reactを使用してデザイン通りに実装を進めています。その際のスタイリングには `CSS　Modules` を採用しており、Pure CSSをガリガリ書いていきながら開発をおこなっています。

しかしながら、CSSを使用した開発経験が少ないメンバーが多いため、試行錯誤しながらベストな記述方法を探っている状態です。

将来的な技術負債を残さないためにも、CSSも含めてリファクタリングをおこなっていますが、レビュー時にはリファクタリング結果を目視で確認しているため、不必要な箇所にまでスタイルを変更した影響が反映されていないのか十分に担保ができていない状態です。

この状態がさらに継続すると、迂闊にUI実装を変更することが難しくなり、開発速度やリリース頻度などが悪化していくことは目に見えているため、何かしらの対策を実施する必要があります。

## 何が原因なのか

ここで一度、「リファクタリング」の定義を振り返っておきたいと思います。

> 外部から見た時の振る舞いを保ちつつ、理解や修正が簡単になるように、ソフトウェアの内部構造を変化させること
> 
> Martin Fowler,「リファクタリング(第2版): 既存のコードを安全に改善する (OBJECT TECHNOLOGY SERIES)」

つまり今の状態では、「外部から見た時の振る舞いを保ちつつ」という部分を目視によるレビューに頼っている状況であるため、この部分を何かしらの方法で解決していく必要があります。

## 課題を解決する技術

今回は以下の2つの要素を取り入れることで、リファクタリング時の課題を解決していきたいと考えています。

|What|How|
|:--|:--|
|コンポーネント化を通じた影響範囲のカプセル化|Storybook|
|Visual Regression Testing (画像回帰テスト)|storycap, reg-suit|

CSSをリファクタリングする際に重要なことは、変更による影響範囲を特定することだと思います。ある1つのプロパティの変更が画面全体に及んでしまう場合、リファクタリングに手を出すことに躊躇してしまいかねません。そこで、`Storybook` を使用してコンポーネント化を進めることで、その影響範囲を限定的なものにしていきます。

また、自動的なVisual Regression Testingをレビューのプロセスに組み込むことで、目視による見落としを防ぎ、振る舞いに変化があった場合に想定していたものであったのか制御できる様にしていきます。

### 導入手順

今回はあくまでも検証であるため、Reactが公式で出している [チュートリアル](https://ja.reactjs.org/tutorial/tutorial.html) を題材に、StorybookとVisual Regression Testingを導入していきたいと思います。

ちなみに公式のチュートリアルは、以下のように三目並べを0から作るものになっています。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194085/380fcb0c-c1bf-8f68-c822-0c1b0713d4fd.gif)

今回はStorybookを導入する前までの状態のコードを下記に配置してします。

- [https://github.com/shimopino/tdd-react/tree/init](https://github.com/shimopino/tdd-react/tree/init)
- [環境構築手順](https://github.com/shimopino/tdd-react/blob/init/ENVIRONMENT.md)

#### 1. Storybookの導入

今回はプロジェクトを作成する上で [vitejs](https://vitejs.dev/) を採用しているため、[公式サイト](https://storybook.js.org/blog/storybook-for-vite/) の手順に従い Storybook を導入します。

```bash
# コンポーネントテスト用のライブラリを追加する
npx sb@next init --builder storybook-builder-vite
>
...
✅ Configuring eslint rules in .eslintrc.js
✅ Adding Storybook to extends list
✅ fixed eslintPlugin
```

これで `.storybook` フォルダが作成され、走査対象のファイルやアドオンなどの設定が記載されているファイルが作成されているはずです。

```js
// .storybook/.main.js
module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
  framework: '@storybook/react',
  core: {
    builder: 'storybook-builder-vite',
  },
};
```

#### 2. .stories ファイルの作成

次に対象となるコンポーネントのバリエーションに応じて `.stories` ファイルを作成します。このストーリーがVisual Regression Testingのテスト対象となります。

例えば以下は三目並べで使用している 3x3 のボードのバリエーションを記述したものであり、Storybookを使用すればページ全体を描画することなく、ストーリー単位でコンポーネントを描画することが可能です。

```js
import { Story, Meta } from '@storybook/react/types-6-0';
import { action } from '@storybook/addon-actions';
import { Board, Props } from './Board';

export default {
  component: Board,
  title: 'Board',
} as Meta;

const Template: Story<Props> = ({ squares, onClick }: Props) => (
  <Board {...{ squares, onClick }} />
);

// 初期状態のボード
export const Default = Template.bind({});
Default.args = {
  squares: Array(9).fill(null),
  onClick: action('Square Area is clicked'),
};

// 全てのマスが "X" で埋められている状態
export const AllX = Template.bind({});
AllX.args = {
  ...Default.args,
  squares: Array(9).fill('X'),
};

// 全てのマスが "O" で埋められている状態
export const AllO = Template.bind({});
AllO.args = {
  ...Default.args,
  squares: Array(9).fill('O'),
};
```

> まだ CSF3.0 には対応させていません。

Storybookを起動すれば、これらのストーリーは下記のように描画されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194085/8618f085-cb4d-4ff8-bf5e-87c8314fbe6a.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194085/12482ba5-1424-9cd4-06ce-0a52d80f6c8f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194085/4b0a1454-3512-1cc9-dfac-61d7abfe910d.png)

これでStorybookの導入は完了です。

開発時には該当するコンポーネントの実装やCSSを修正することで、ページ全体を描画させることなく、対象となるコンポーネントに絞ってどのように変更されているのか確認することが可能となります。

#### 3. コンポーネントの画像を取得する

Visual Regression Testingを導入するには、まずはコンポーネントの画像を取得する必要があります。

そこでStorybookの各ストーリーごとにスクリーンショットを撮影することのできる `storycap` というアドオンを追加します。

```bash
npm install --save-dev storycap puppeteer
```

あとは以下のコマンドを追加するだけで準備は完了です。

```json
{
  "scripts": {
    "storycap": "storycap --serverCmd \"npm run storybook\" http://localhost:6006 --serverTimeout 60000"
  }
}
```

実際にコマンドを実行すると、以下のように 1 つ 1 つのストーリーに対してスクリーンショットが撮影されていることがわかります。

```bash
./__screenshots__
├── Board
│   ├── All\ O.png
│   ├── All\ Triangle.png
│   ├── All\ X.png
│   └── Default.png
├── Game
│   ├── Default.png
│   ├── Tuen\ No\ 8\ Winner\ O.png
│   ├── Turn\ No\ 1.png
│   ├── Turn\ No\ 2.png
│   └── Turn\ No\ 5.png
├── Move
│   ├── Default.png
│   └── Second\ Tern.png
├── Square
│   ├── Default.png
│   ├── O.png
│   └── X.png
└── Status
    ├── Default.png
    ├── Draw.png
    └── Winner.png
```

#### 4. コンポーネントの画像を比較する

次に各ストーリーごとに撮影されたスクリーンショットの画像を比較するために `reg-suit` を導入します。

```bash
npm install --save-dev reg-suit
```

プロジェクトで使用する場合には、撮影したスクリーンショットをAWS S3などのバケットに追加したり、Githubのプルリクにテスト結果を通知したりする必要がありますが、今回はローカル環境で検証することが目的であるため、簡易的な設定のみを指定します。

```bash
❯❯❯ npx reg-suit init

[reg-suit] info version: 0.11.1
? Plugin(s) to install (bold: recommended)  reg-keygen-git-hash-plugin : Detect the snapshot key to be compare
 with using Git hash.
[reg-suit] info Install dependencies to the local directory. This procedure takes some minutes, please wait.
? Working directory of reg-suit. .reg
? Append ".reg" entry to your .gitignore file. Yes
? Directory contains actual images. __screenshots__
? Threshold, ranges from 0 to 1. Smaller value makes the comparison more sensitive. 0
[reg-suit] info Configuration:
[reg-suit] info {
  "core": {
    "workingDir": ".reg",
    "actualDir": "__screenshots__",
    "thresholdRate": 0,
    "addIgnore": true,
    "ximgdiff": {
      "invocationType": "client"
    }
  },
  "plugins": {
    "reg-keygen-git-hash-plugin": true
  }
}
? Update configuration file Yes
? Copy sample images to working dir Yes
[reg-suit] info Initialization ended successfully ✨
[reg-suit] info Execute 'reg-suit run' ⭐
```

あとは下記のコマンドを実行すれば画像の比較が実行されます。

```bash
npx reg-suit run
```

これで下記のフォルダに画像が追加されていることがわかります。

```bash
./.reg
└── actual
    ├── Board
    │   ├── All\ O.png
    │   ├── All\ Triangle.png
    │   ├── All\ X.png
    │   └── Default.png
    ├── Game
    │   ├── Default.png
    │   ├── Tuen\ No\ 8\ Winner\ O.png
    │   ├── Turn\ No\ 1.png
    │   ├── Turn\ No\ 2.png
    │   └── Turn\ No\ 5.png
    ├── Move
    │   ├── Default.png
    │   └── Second\ Tern.png
    ├── Square
    │   ├── Default.png
    │   ├── O.png
    │   └── X.png
    └── Status
        ├── Default.png
        ├── Draw.png
        └── Winner.png
```

#### 5. 変更差分の検知

これでコンポーネントの外観に何かしらの変更があった場合にその検出を自動的に行う準備が完了しました。

そこで下記のように `src/components/Square/Square.module.scss` のスタイルの実装を変更してどのように検知されるのか確認してみます。（対象のコンポーネントの枠線の外観を太くする変更を発生させています。）

```diff
.square {
- border: 1px solid #999; 
+ border: 5px solid #999;
}
```

スクリーンショットを撮影する前に、以前のスクリーンショットと比較できるように、下記のフォルダに前回のスクリーンショットを保存しておきます。

```bash
cp -R __screenshots__/* .reg/expected
```

この後で再度スクリーンショットの撮影と画像の比較を実施すると、以下のようなレポートが作成され、コンポーネントの外観がどの様に変化してしまったのか、ストーリー単位で把握することが可能です。

![Visual Regression Testing.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194085/c9f7c96e-69e5-7948-c334-896432710c2a.gif)

### 留意点

急いで実装したのでサンプルコードの実装は甘々です。例えばコンポーネントなのに `margin` で他のコンポーネントとの余白調整を行なっていたりします。（リファクタリング自体は以前よりは楽になっているかとは思いますが。）

またプロジェクトで採用する際には変更を検出する際の閾値の設定や、CI環境でのフォント設定など、追加で実施しなければならない作業が残っているので注意が必要ですね。

### 感想

今回サンプル実装を進めて改めて感じましたが、Storybookのエコシステムは素晴らしいですね。Visual Regression Testingの導入もOSSを組み合わせることでサクッとできてしまいました。

今後は単純な差分レビューをする必要がなくなり、余った時間でより気楽にリファクタリングを行えそうな感触です。

## 参考資料

- [vitejs](https://vitejs.dev/)
- [storybook](https://storybook.js.org/)
- [storycap](https://github.com/reg-viz/storycap)
- [reg-suit](https://github.com/reg-viz/reg-suit)
- [伝えたい人に届ける技術記事の書き方](https://qiita.com/MinoDriven/items/6718b5e70e3fb321ff9b)