StorybookとLost Pixel を用いたビジュアルリグレッションテスト

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

## はじめに

Storybookを用いたビジュアルリグレッションテスト（VRT）はChromaticやStorycap + reg-suitなどを用いることが多いかと思います。
しかし、ChromaticはGitHub Enterprise Serverを利用している場合は、Enterpriseプランの契約が必要となり、reg-suitは外部のクラウドストレージが必要です。

上記の制約がある中、ゆるくVRTを検証してみたかったので、リポジトリにベースイメージを保存してVRTを実行できるLost Pixelを使用してみました。

## Lost Pixelとは

Lost PixelはOSSのビジュアルリグレッションテストツールで、Storybook、Ladle、Historie、ページスクリーンショット、Playwrightなどを用いたカスタムスクリーンショットをサポートしています。

OSS版の他に有料版のLost Pixel Platformというマネージドサービスも提供されています。
他のビジュアルリグレッションテストツールとは違い、リポジトリにベースイメージのスクリーンショットを保存して比較するのが特徴です。

## 使用環境

以下ライブラリのバージョンを使用します。

- typescript: 5.6.3
- storybook: 8.3.6
- lost-pixel: 3.21.0

## 導入

次のコマンドを実行します。

```shell
npx lost-pixel init-ts
npm i -D lost-pixel
```

プロジェクトルートにlostpixel.config.tsが作成されます。

```typescript
import type { CustomProjectConfig } from "lost-pixel";

export const config: CustomProjectConfig = {
  storybookShots: {
    storybookUrl: "examples/storybook-build/storybook-static",
  },
  generateOnly: true,
};
```

これだけでLostPixelの導入は完了です。
プロジェクトの要件に応じて設定を変更してください。

## ベースラインイメージの作成とリグレッションテスト

事前にStorybookをビルドします。

```
npx storybook build
```

現在のストーリを正としたいため、まずは次のコマンドでベースラインイメージを作成します。

```shell
npx lost-pixel update
```

コマンドを実行すると`.lostpixel/baseline`にベースラインイメージが作成されます。
例えば次の画像をベースラインイメージとします。
![components-button--vrt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176795/072bca31-ea86-1e30-bcbd-35b5fd18947b.png)

ボタンのテキストを`Button`から`ボタン`変更してみます。

次のコマンドを実行すると、現在のスクリーンショットを `.lostpixel/current`に出力し、差分が発生した場合は `.lostpixel/difference`に差分を出力します。

```
npx lost-pixel
```

![components-button--vrt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176795/7042a953-289f-b956-ca22-5aa4d99d9ef4.png)

差分の画像はこちらです。
![components-button--vrt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176795/13ac95a9-8974-2246-3da4-d364964de6c6.png)

差分が意図したものである場合は、`npx lost-pixel update`を実行してベースラインイメージを更新します。

### FlakyなUIをマスクする

FlakyなUIでVRTの実行毎に差分が発生する場合は、特定のUIをマスクすることが可能です。
https://docs.lost-pixel.com/user-docs/recipes/general-recipes/masking-page-elements

```typescript
export const config: CustomProjectConfig = {
  storybookShots: {
    mask: [
      {
        selector: '[data-mask]'
      }
    ]
  }
};
```


例えばボタンのテキストをマスクした場合は、このような感じになります。

![components-button--vrt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176795/4dea5497-ec89-53c7-2e77-051f40aca857.png)

マスクを使用するほどでもない場合は、thresholdsで調整することも可能です。
https://docs.lost-pixel.com/user-docs/recipes/general-recipes/thresholds

## 特定のStoryのみを対象にする

全てのStoryではなく、VRT用のStoryのみをテストの対象にしたいことがあります。
LostPixelではfilterShotというオプションを使用します。

```typescript
export const config: CustomProjectConfig = {
  filterShot: ({ id, story, kind }) => {
    return true;
  },
};
```

filterShotの引数に渡されるStorybookのid、story、kindを使用してVRTの対象にするか判定できます。
以下がStoryとfilterShotに渡される引数のサンプルです。

```typescript
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta = {
  title: "Components/Button",
  component: Button,
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {};
export const VRT: Story = {};
```

```jsonc

// Primary
{
 id: "components-button--primary",
 story: "Primary",
 kind: "Components/Button"
}

// VRT
{
 id: "components-button--vrt",
 story: "VRT",
 kind: "Components/Button"
}
```

例えばサフィックスがVRTのStoryのみ対象にしたい場合は、次のような実装になります。

```typescript
export const config: CustomProjectConfig = {
  filterShot: ({ id, story, kind }) => {
    // S名がVRTで終わる
    return story.endWith("VRT");
  },
};
```


## 画像比較のエンジンを切り替える

Lost Pixelの画像の比較には[odiff](https://github.com/dmtrKovalenko/odiff)と[pixelmatch](https://github.com/mapbox/pixelmatch)が使用されており、オプションで切り替えることができます。デフォルト値はpixelmatchです。

[ベンチマーク](https://github.com/dmtrKovalenko/odiff?tab=readme-ov-file#benchmarks)ではpixelmatchよりodiffの方が高速なため、比較画像が多く、パフォーマンスの問題が発生する際にはodiffの使用を検討してください。

```typescript
export const config: CustomProjectConfig = {
  compareEngine: "odiff", // 'pixelmatch'　or 'odiff'
};
```

## まとめ

今回はベースラインイメージをリポジトリ管理して、VRTを実行できるLost Pixelを紹介しました。
画像をリポジトリ管理することの是非はあるかと思いますが、依存が少なく検証も簡単なため、ゆるくVRTを運用していきたい場合は導入してみるのもありかと思います。

## 参考
- [Lost Pixel](https://docs.lost-pixel.com/user-docs)
- [ChromaticとGitHub Enterprise Server利用時のプランについて](https://github.com/chromaui/chromatic-cli/issues/586)

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。