nextjs + typescript + jest環境にStoryShotsを導入する



個人的にめちゃくちゃハマったので自分用にメモ
（後述の通り、優先度は低いところだがまだ解消していないところもある）

各パッケージのバージョンが上がっていくと事態は変わっていくと思われるので注意
（とりあえずnext@12.3.0, React@18.2, storybook@6.5.10, TypeScript@4.8.3, jest@29.0.3くらいまでで確認）

# TL;DR;

- `package.json`の`resolutions`を見直す[(参考)](https://github.com/storybookjs/storybook/issues/17985):

```json:package.json(抜粋)
"resolutions": {
    "react-test-renderer": "18.1.0"
}
```

- または、[custom-renderer](https://github.com/storybookjs/storybook/tree/v6.5.10/addons/storyshots/storyshots-core#using-a-custom-renderer)を使う **(追記)**

```typescript:storyshots.test.ts
// testing-libraryによる例:
import initStoryshots from '@storybook/addon-storyshots';
import { render } from '@testing-library/react';

const reactTestingLibrarySerializer = {
  print: (val, serialize, indent) => serialize(val.container.firstChild),
  test: (val) => val && val.hasOwnProperty('container'),
};

initStoryshots({
  renderer: render,
  snapshotSerializers: [reactTestingLibrarySerializer],
});
```

- `***.stories.mdx`に対してエラーが出る
    * 解消法は現時点で確認出来ていない
    * （mdxに対するエラーがあってもテスト自体はパスするので必要性が無ければパスしても良さそう)

# 動機

- 作成したcomponentsやページなどが意図せずデグレしていないか、snapshotテストを行うことで担保したい
    * 差分がある場合も具体的にどのように変わるかが確認しやすくなりそう
- storybookを使う予定であり、各ストーリーと上記のsnapshotを取っておきたくなるような対象はほぼイコールと考えられるため、一緒に管理出来るようにしたい
    * 作成したstoryからそのままsnapshotを取れれば一番楽そう
    * StoryShotsを使えばゼロコンフィグで自動的にすべての`*.stories.(ts|tsx)?`からsnapshotテストを生成出来そうなので便利そう

# 前提条件: 検証環境

- Ubuntu22.04 @WSL2(Windows11)
- node@v16.16.0
- yarn@1.22.19

# 検証

## Next.js環境セットアップ

ベースの環境として、nextjsのexamplesにある[with-jest](https://github.com/vercel/next.js/tree/v12.3.0/examples/with-jest)を使う
→個人的にNext.jsのプロジェクトで使おうと思っていたのと、TypeScript+Jestの環境を自動で作成出来るため

```sh
# 今回は`storyshots-test`ディレクトリにプロジェクトを作成する
yarn create next-app --example with-jest storyshots-test

# 作成したプロジェクトに移動
cd storyshots-test
```
2022/9/10現在だと、以下のような感じのファイルが生成される:

<details><summary>(詳細)</summary><div>

```sh
tree -a -I ".git|node_modules"
.
├── .eslintrc.json
├── .gitignore
├── .swc
│   └── plugins
│       └── v3
├── README.md
├── __mocks__
│   ├── fileMock.js
│   └── styleMock.js
├── __tests__
│   ├── __snapshots__
│   │   └── snapshot.tsx.snap
│   ├── index.test.tsx
│   └── snapshot.tsx
├── jest.config.js
├── jest.setup.js
├── next-env.d.ts
├── package.json
├── pages
│   ├── _app.tsx
│   ├── index.module.css
│   └── index.tsx
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── styles
│   └── global.css
├── tsconfig.json
├── types.d.ts
└── yarn.lock
```
</div></details>

## storybook導入

https://storybook.js.org/docs/react/get-started/install

```sh
npx storybook init
```

などでstorybookをインストール出来る

<details><summary>インストール後のディレクトリ、ファイル</summary><div>

```sh
tree -a -I ".git|node_modules"
.
├── .eslintrc.json
├── .gitignore
├── .storybook
│   ├── main.js
│   └── preview.js
├── .swc
│   └── plugins
│       └── v3
├── README.md
├── __mocks__
│   ├── fileMock.js
│   └── styleMock.js
├── __tests__
│   ├── __snapshots__
│   │   └── snapshot.tsx.snap
│   ├── index.test.tsx
│   └── snapshot.tsx
├── jest.config.js
├── jest.setup.js
├── next-env.d.ts
├── package.json
├── pages
│   ├── _app.tsx
│   ├── index.module.css
│   └── index.tsx
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── stories
│   ├── Button.stories.tsx
│   ├── Button.tsx
│   ├── Header.stories.tsx
│   ├── Header.tsx
│   ├── Introduction.stories.mdx
│   ├── Page.stories.tsx
│   ├── Page.tsx
│   ├── assets
│   │   ├── code-brackets.svg
│   │   ├── colors.svg
│   │   ├── comments.svg
│   │   ├── direction.svg
│   │   ├── flow.svg
│   │   ├── plugin.svg
│   │   ├── repo.svg
│   │   └── stackalt.svg
│   ├── button.css
│   ├── header.css
│   └── page.css
├── styles
│   └── global.css
├── tsconfig.json
├── types.d.ts
└── yarn.lock

12 directories, 41 files
```
`stories`ディレクトリと`.storybook`ディレクトリが生成されている。

</div></details>

また、ここまでで`package.json`の中身は以下のような感じ:

<details><summary>package.json</summary><div>

```json
{
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "jest --watch",
    "test:ci": "jest --ci",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "dependencies": {
    "next": "latest",
    "react": "^18.1.0",
    "react-dom": "^18.1.0"
  },
  "devDependencies": {
    "@babel/core": "^7.19.0",
    "@storybook/addon-actions": "^6.5.10",
    "@storybook/addon-essentials": "^6.5.10",
    "@storybook/addon-interactions": "^6.5.10",
    "@storybook/addon-links": "^6.5.10",
    "@storybook/builder-webpack5": "^6.5.10",
    "@storybook/manager-webpack5": "^6.5.10",
    "@storybook/react": "^6.5.10",
    "@storybook/testing-library": "^0.0.13",
    "@testing-library/jest-dom": "5.16.4",
    "@testing-library/react": "13.2.0",
    "@testing-library/user-event": "14.2.0",
    "@types/react": "18.0.9",
    "babel-loader": "^8.2.5",
    "jest": "28.1.0",
    "jest-environment-jsdom": "28.1.0",
    "typescript": "4.6.4"
  }
}
```
</div></details>

## StoryShots導入(失敗)

[公式の説明](https://storybook.js.org/addons/@storybook/addon-storyshots)に従って導入してみる

```sh
yarn add -D @storybook/addon-storyshots
```

次に、`__tests__/storyshots.test.ts`を以下の内容で作成

```typescript:__tests__/storyshots.test.ts
import initStoryshots from '@storybook/addon-storyshots';

initStoryshots();
```

公式によればこれだけでOKらしい？ので、テストを実行してみる

```sh
yarn jest

 PASS  __tests__/index.test.tsx
 PASS  __tests__/snapshot.tsx
 FAIL  __tests__/storyshots.test.ts
  ● Console

    console.error
      Unexpected error while loading ./Introduction.stories.mdx: Cannot use import statement outside a module
       Jest encountered an unexpected token

      Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to su
pport such syntax.

      Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

      By default "node_modules" folder is ignored by transformers.

      Here's what you can do:
       • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
       • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
       • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
       • If you need a custom transformation specify a "transform" option in your config.
       • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

      You'll find more details and examples of these config options in the docs:
      https://jestjs.io/docs/configuration
      For information about custom transformations, see:
      https://jestjs.io/docs/code-transformation

(...)

  ● Storyshots › Example/Page › Logged Out

    TypeError: Cannot read properties of undefined (reading 'current')

      at warnIfNotScopedWithMatchingAct (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:14999:31)
      at updateContainer (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:16413:7)
      at create (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:17149:3)
      at getRenderedTree (node_modules/@storybook/addon-storyshots/dist/ts3.9/frameworks/react/renderTree.js:24:18)
      at node_modules/@storybook/addon-storyshots/dist/ts3.9/test-bodies.js:9:24
      at Object.<anonymous> (node_modules/@storybook/addon-storyshots/dist/ts3.9/api/snapshotsTestsTemplate.js:29:24)

  ● Storyshots › Example/Page › Logged In

    TypeError: Cannot read properties of undefined (reading 'current')

      at warnIfNotScopedWithMatchingAct (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:14999:31)
      at updateContainer (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:16413:7)
      at create (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:17149:3)
      at getRenderedTree (node_modules/@storybook/addon-storyshots/dist/ts3.9/frameworks/react/renderTree.js:24:18)
      at node_modules/@storybook/addon-storyshots/dist/ts3.9/test-bodies.js:9:24
      at Object.<anonymous> (node_modules/@storybook/addon-storyshots/dist/ts3.9/api/snapshotsTestsTemplate.js:29:24)

Test Suites: 1 failed, 2 passed, 3 total
Tests:       8 failed, 2 passed, 10 total
Snapshots:   1 passed, 1 total
Time:        2.916 s
Ran all test suites.
error Command failed with exit code 1.
```

のような感じで大量にエラーが発生する。

なお、よくよく見るとエラーは2種類あり、

- 自動生成された`Introduction.stories.mdx`に対するエラー
    * 要するに`.mdx`形式のファイルをうまく扱えていない
    * 実はこのエラーが残っていてもテストは通るので気持ち悪いが残していてもOK。または、不要であればファイルを消してしまうなど。（対処が面倒かつ支障が出ない場合）
- 各`*.stories.tsx`に対する`TypeError: Cannot read properties of undefined (reading 'current')`
    * エラー詳細: b`at warnIfNotScopedWithMatchingAct (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:14999:31)`
    * 要するに`react-test-renderer`周りのエラー


最低限、後者は対処しなくてはいけない

## StoryShotsのエラー解消

### react-test-rendererのエラーへの対処

それっぽい[issue](https://github.com/storybookjs/storybook/issues/17985)があるので、それを参考にしていく

結論、`react-test-renderer`のバージョンに原因がある？らしく、`package.json`の`resolutions`で条件を指定することで解消出来るらしい

```diff:package.json(追記)
+  "resolutions": {
+    "react-test-renderer": "18.2.0"
+  }
```

こうするとsnapshotテストはちゃんと通るようになる（※`mdx`ファイルに対するエラーは残る）

```
yarn jest

 PASS  __tests__/snapshot.tsx
 PASS  __tests__/index.test.tsx
 PASS  __tests__/storyshots.test.ts
  ● Console

    console.error
      Unexpected error while loading ./Introduction.stories.mdx: Cannot use import statement outside a module
       Jest encountered an unexpected token

      Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to su
pport such syntax.

      Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

      By default "node_modules" folder is ignored by transformers.

      Here's what you can do:
       • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
       • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
       • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
       • If you need a custom transformation specify a "transform" option in your config.
       • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

      You'll find more details and examples of these config options in the docs:
      https://jestjs.io/docs/configuration
      For information about custom transformations, see:
      https://jestjs.io/docs/code-transformation

Test Suites: 3 passed, 3 total
Tests:       10 passed, 10 total
Snapshots:   9 passed, 9 total
Time:        3.159 s
Ran all test suites.
Done in 4.26s.
```

一見NGっぽく見えるが、ちゃんとテストはすべて成功し、各ストーリのsnapshot: `__tests__/__snapshots__/storyshots.test.ts.snap`も無事に作成されている。

`*.mdx`形式のファイルを扱う必要性が無ければとりあえずこれでOK。


#### （追記）custom rendererを使う場合

エラーの原因が`react-test-renderer`を使っていることに起因しているなら、冷静に使わなければ良いのではという。
ちょうど、

https://github.com/storybookjs/storybook/tree/v6.5.10/addons/storyshots/storyshots-core#using-a-custom-renderer

に、rendererを自前のものを指定できるオプションが紹介されている。
今回のセットアップ(nextjsの[with-jestテンプレート](https://github.com/vercel/next.js/tree/v12.3.0/examples/with-jest))でははじめから`@testing-library/react`がインストールされており、実際unittestの作成で入れているケースが多いと思われるため、これを使うので良さそう。

```diff:package.json(元に戻す)
-  "resolutions": {
-    "react-test-renderer": "18.2.0"
-  }
```

```sh
# yarnコマンドを実行して、yarn.lockの内容を更新（元に戻す）
yarn
```

`__tests__/storyshots.test.ts`の内容をアップデートする:

```diff:__tests__/storyshots.test.ts(差分)
import initStoryshots from '@storybook/addon-storyshots';
+ import { render } from '@testing-library/react';
 
+ const reactTestingLibrarySerializer = {
+   print: (val, serialize, indent) => serialize(val.container.firstChild),
+   test: (val) => val && val.hasOwnProperty('container'),
+  };
 
- initinitStoryshots();
+ initStoryshots({
+ renderer: render,
+   snapshotSerializers: [reactTestingLibrarySerializer],
+ });
```

この方法でも各storyのsnapshotは無事に作成される。
ただし、rendererを変えているぶんsnapshotの中身は変わるので注意

<details><summary>snapshotの差分(抜粋)</summary><div>

```diff:__tests__/__snapshot__/storyshots.test.ts.snap
(...)
@@ -30,12 +20,7 @@ exports[`Storyshots Example/Button Primary 1`] = `

 exports[`Storyshots Example/Button Secondary 1`] = `
 <button
-  className="storybook-button storybook-button--medium storybook-button--secondary"
-  style={
-    Object {
-      "backgroundColor": undefined,
-    }
-  }
+  class="storybook-button storybook-button--medium storybook-button--secondary"
   type="button"
 >
   Button
@@ -44,12 +29,7 @@ exports[`Storyshots Example/Button Secondary 1`] = `

 exports[`Storyshots Example/Button Small 1`] = `
 <button
-  className="storybook-button storybook-button--small storybook-button--secondary"
-  style={
-    Object {
-      "backgroundColor": undefined,
-    }
-  }
+  class="storybook-button storybook-button--small storybook-button--secondary"
   type="button"
 >
   Button
@@ -59,7 +39,7 @@ exports[`Storyshots Example/Button Small 1`] = `
 exports[`Storyshots Example/Header Logged In 1`] = `
 <header>
   <div
-    className="wrapper"
+    class="wrapper"
   >
     <div>
       <svg
@@ -70,7 +50,7 @@ exports[`Storyshots Example/Header Logged In 1`] = `
       >
         <g
           fill="none"
-          fillRule="evenodd"
+          fill-rule="evenodd"
         >
           <path
             d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
(...)
```

</div></details>

### mdx形式のstoryも扱う場合(未完)

**TODO（余裕があれば検証&追記）**

それらしいissue: 
https://github.com/storybookjs/storybook/issues/7223

などもあるが、手元ではうまくいっていない。


## おまけ パッケージのバージョンアップ

使用するライブラリのバージョンを上げても同様に動くか検証した。
厳密にはsnapshotの形式が変わったり、というのはあったものの、storyshotsが無事に動くという意味では検証が取れた。

最終的に検証したときの`package.json`:

<details><summary>package.json</summary><div>

```json:package.json
{
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "jest --watch",
    "test:ci": "jest --ci",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "dependencies": {
    "next": "^12.3.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.19.0",
    "@storybook/addon-actions": "^6.5.10",
    "@storybook/addon-essentials": "^6.5.10",
    "@storybook/addon-interactions": "^6.5.10",
    "@storybook/addon-links": "^6.5.10",
    "@storybook/addon-storyshots": "^6.5.10",
    "@storybook/builder-webpack5": "^6.5.10",
    "@storybook/manager-webpack5": "^6.5.10",
    "@storybook/react": "^6.5.10",
    "@storybook/testing-library": "^0.0.13",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/react": "^18.0.19",
    "babel-loader": "^8.2.5",
    "jest": "^29.0.3",
    "jest-environment-jsdom": "^29.0.3",
    "typescript": "^4.8.3"
  },
  "resolutions": {
    "react-test-renderer": "18.2.0"
  }
}
```
</div></details>

↑react, typescript, jestなどのバージョンを少しアップデートしている。


`mdx`に対するエラーは出るが、各storyに対して正常にsnapshotの作成と比較テストが実施出来る。

## その他

StoryShotsの詳細なオプションは

https://github.com/storybookjs/storybook/tree/v6.5.10/addons/storyshots/storyshots-core

などを参照のこと