Closed24

@storybook/addon-storyshots を導入する

nbstshnbstsh

storybook の tutorial をもとに、React + ts な project に @storybook/addon-storyshots を導入しようとしたところ、エラーが出現。解決までの道のりをメモっていく。

参考にした tutorial はこちら
https://storybook.js.org/tutorials/intro-to-storybook/react/en/simple-component/

遭遇したエラーはこちら↓

    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 support 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

何度か遭遇したことあるやつ。おそらく parse がうまくいってない箇所があるな。

=> 今回は .storybook/preview.js 内で import している箇所で parse に失敗している様子。

nbstshnbstsh

ts-jest で ESM support

parse に失敗している箇所が、ESM な js file (.storybook/preview.js) であったため、ts-jest の力を借りて ESM syntax を CommonJS syntax に変換することで対応する。

https://kulshekhar.github.io/ts-jest/docs/guides/esm-support

ESM support を有効化するために、jest.config.js に以下を追加。

jest.config.js
  preset: 'ts-jest/presets/js-with-ts', 
  globals: {
    'ts-jest': {
      useESM: true,
    },
  },

こちらの example で出てくる moduleNameMapper は import される特定の file を mock file にすげ替えるなど、あるモジュールを別のモジュールに map するのに使われるものなのでここでは指定しない。

ts-jest/presets/js-with-ts preset

preset で指定できるものはここで確認できる↓

https://kulshekhar.github.io/ts-jest/docs/getting-started/presets#the-presets

今回は、ts, js file を CommonJs に変換したいので ts-jest/presets/js-with-ts を採用。

You'll need to set allowJs to true in your tsconfig.json file.

allowJs を true にしなさいとのことなので、tsconfig.json も更新。
また、.storybook/preview.js も変換対象に含めたいので、dev 環境用の tsconfig.json を以下のように編集。

tsconfig.json
{
  "extends": "../tsconfig.json",
  "include": ["src", ".storybook"],
  "compilerOptions": {
    "allowJs": true
  }
}

一旦、これで .storybook/preview.js のエラーは解消!
ただ、他のエラー出たので続く。

nbstshnbstsh

.storybook/preview.js 内の 'typeface-roboto' (MUI で使用する font) を import しているとこで エラー起きてる。

    SyntaxError: Invalid or unexpected token

      3 | import React from 'react';
      4 | // material-ui
    > 5 | import 'typeface-roboto';
        | ^

css は mock file を参照するように

typeface-roboto/index.css を parse しようとしちゃってるので、css は mock file を参照するようにしてみる。

__mocks__/style-mock.ts を作成して、css はそちらを参照するよう設定

__mocks__/style-mock.ts
export default {};
jest.config.js
+  // test 実行時に import している css file は mock して、parse されないように
+  moduleNameMapper: {
+   '\\.css$': '<rootDir>/__mocks__/style-mock.ts',
+  },

だめだ...あい変わらず同じエラーでる...

typeface-robot を mock

力技感あるが、typeface-robot 自体を mock して対応してみる。

jest.config.js
  // test 実行時に import している css file は mock して、parse されないように
  moduleNameMapper: {
   '\\.css$': '<rootDir>/__mocks__/style-mock.ts',
+    'typeface-roboto': '<rootDir>/__mocks__/style-mock.ts',
  },

解決!これで parse 関連のエラーは全てクリア。

しかし、テスト実行時に別のエラー出たので続く。

nbstshnbstsh

@storybook/addon-storyshots を実際に呼び出している file でエラー出た。

 FAIL  src/storybook.test.ts
  ● Test suite failed to run

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

      1 | import initStoryshots from '@storybook/addon-storyshots';
    > 2 | initStoryshots();
        |               ^
      3 |

調べてもそれらしいのが出てこない...

nbstshnbstsh

ソースコード見てみるか
version は 6.4.13

@storybook/addon-storyshots@6.4.13

type error がでてるのはここ だな

  globalWindow.__STORYBOOK_STORY_STORE__.initializationPromise.then(() => {

この globalWindow が原因っぽいな...

import global from 'global';

const { describe, window: globalWindow } = global;

global て package から import してる window object だな。

global を見にいくか

nbstshnbstsh

https://github.com/Raynos/global

window.js
var win;

if (typeof window !== "undefined") {
    win = window;
} else if (typeof global !== "undefined") {
    win = global;
} else if (typeof self !== "undefined"){
    win = self;
} else {
    win = {};
}

module.exports = win;

見たところ、node の global object を win として export してるだけだな...

ってことは、@storybook/addon-storyshots を実行している環境の global object に __STORYBOOK_STORY_STORE__ が生えていなきゃいけない訳だな

console.log((global as any).__STORYBOOK_STORY_STORE__);
// undefined

console.log してみたけど、そんなもの生えてないぞ....

nbstshnbstsh

どこで __STORYBOOK_STORY_STORE__ 生やしてるか調べるか

ここ だな


export function start<TFramework extends AnyFramework>(

/*~~ 省略 ~~*/

  if (globalWindow) {
    globalWindow.__STORYBOOK_CLIENT_API__ = clientApi;
    globalWindow.__STORYBOOK_ADDONS_CHANNEL__ = channel;
    // eslint-disable-next-line no-underscore-dangle
    globalWindow.__STORYBOOK_PREVIEW__ = preview;
    globalWindow.__STORYBOOK_STORY_STORE__ = preview.storyStore;
  }

/*~~ 省略 ~~*/

@storybook/core-client@6.4.13start() function 内で __STORYBOOK_STORY_STORE__ を global に生やしてる。

そんで、この start() を呼び出してるので @storybook/react@6.4.13ここ

import { start } from '@storybook/core/client';

/*~~ 省略 ~~*/

const api = start(renderToDOM, { render });

@storybook/core@storybook/core-client を re-export してるだけ。

nbstshnbstsh

ここまでのまとめ

  • @storybook/addon-storyshots@storybook/core-client に依存
    • @storybook/core-clientstart() が呼び出されることを前提にしている (= global に__STORYBOOK_STORY_STORE__ がセットされている前提)
  • @storybook/core-clientstat()@storybook/react で呼び出される
nbstshnbstsh

依存している package が正しいバージョンではない疑惑...?

調べる

まずは storyshots

yarn list --pattern  @storybook/addon-storyshots
└─ @storybook/addon-storyshots@6.4.13

version は 6.4.13
念の為、 @storybook/addon-storyshots@6.4.13 の package.json みる。

{
  "name": "@storybook/addon-storyshots",
  "version": "6.4.13",

  "dependencies": {
    "@jest/transform": "^26.6.2",
    "@storybook/addons": "6.4.13",
    "@storybook/babel-plugin-require-context-hook": "1.0.1",
    "@storybook/client-api": "6.4.13",
    "@storybook/core": "6.4.13",
    "@storybook/core-client": "6.4.13",
    "@storybook/core-common": "6.4.13",
    "@storybook/csf": "0.0.2--canary.87bc651.0",
    "@types/glob": "^7.1.3",
    "@types/jest": "^26.0.16",
    "@types/jest-specific-snapshot": "^0.5.3",
    "core-js": "^3.8.2",
    "glob": "^7.1.6",
    "global": "^4.4.0",
    "jest-specific-snapshot": "^4.0.0",
    "preact-render-to-string": "^5.1.19",
    "pretty-format": "^26.6.2",
    "react-test-renderer": "^16.8.0 || ^17.0.0",
    "read-pkg-up": "^7.0.1",
    "regenerator-runtime": "^0.13.7",
    "ts-dedent": "^2.0.0"
  },

"@storybook/core": "6.4.13" なので、@storybook/core6.4.13 であるべき。

さぁ確認。

yarn list --pattern  @storybook/core-client
└─  @storybook/core-client@6.3.7

うおぉぉぉぉぉぉぉぉい

やっぱり version 違いでしたか

nbstshnbstsh

@storybook/** 全て 6.4.13 に揃える。

package.json
    "@storybook/addon-actions": "6.4.13",
    "@storybook/addon-essentials": "6.4.13",
    "@storybook/addon-links": "6.4.13",
    "@storybook/addon-storyshots": "6.4.13",
    "@storybook/react": "6.4.13",

よし、テスト実行

エラー出た...

TypeError: Cannot convert undefined or null to object
nbstshnbstsh

とりあえず @babel/core, babel-loader を最新に上げたら、上記エラーは消えた。

package.json
 "@babel/core": "^7.16.10",
 "babel-loader": "^8.2.3",

が、storybook 自体が動かなくなったぞ...

ERR! TypeError: (0 , _coreCommon.getStorybookBabelConfig) is not a function
nbstshnbstsh

https://github.com/storybookjs/storybook/issues/17280

issue あがってるみたいだけど、結論は現状(2022/1/20)出てない
どうやら、@storybook の依存 module が正しくない version でインストールされちゃってることが原因ぽい

my suggestion is delete the root node_module and install it again from the root.

”node_module を消して再インストール" を提案されてたので、やってみたら Storybook 動くようになった...
釈然としないが先に進む

nbstshnbstsh

test 実行したら別のエラー出た

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

ソースコード読む限り、このエラーも global window が undefined になっているエラー

nbstshnbstsh

試しに、console.log してみる

storybook.test.ts
import initStoryshots from '@storybook/addon-storyshots';

console.log('window', typeof window);
// window undefined
console.log('global', typeof global);
//  global object

initStoryshots();

やはり window が存在してない

nbstshnbstsh

既存 prroject への導入中断

埒が明かないので、一旦作業中断。
そもそも最新の version で問題なく動くのか検証したいので、tutorial 通り (最新の version 使う) に進めていく。

https://storybook.js.org/tutorials/intro-to-storybook/react/en/get-started/

nbstshnbstsh

https://storybook.js.org/tutorials/intro-to-storybook/react/en/simple-component/

storyshots の導入まで行ったがうまく動いてるな
package.json と install されている @storybook 関連の module 貼っとく。

package.json
{
  "name": "intro-storybook-react-template",
  "version": "0.1.0",
  "description": "Starter template to get up and running quickly with React and Storybook",
  "author": "Chromatic <https://chromatic.com/>",
  "repository": {
    "type": "git",
    "url": "https://github.com/chromaui/intro-storybook-react-template"
  },
  "bugs": {
    "url": "https://github.com/chromaui/intro-storybook-react-template/issues"
  },
  "license": "MIT",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-scripts": "4.0.1",
    "web-vitals": "^0.2.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ],
    "overrides": [
      {
        "files": [
          "**/*.stories.*"
        ],
        "rules": {
          "import/no-anonymous-default-export": "off"
        }
      }
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@storybook/addon-actions": "^6.4.8",
    "@storybook/addon-essentials": "^6.4.8",
    "@storybook/addon-interactions": "^6.4.8",
    "@storybook/addon-links": "^6.4.8",
    "@storybook/addon-storyshots": "^6.4.13",
    "@storybook/node-logger": "^6.4.8",
    "@storybook/preset-create-react-app": "^3.2.0",
    "@storybook/react": "^6.4.8",
    "@storybook/testing-library": "^0.0.7",
    "@storybook/testing-react": "^1.2.2",
    "react-test-renderer": "^17.0.2"
  }
}
$ yarn list --pattern @storybook
yarn list v1.22.4
├─ @storybook/addon-actions@6.4.13
├─ @storybook/addon-backgrounds@6.4.13
├─ @storybook/addon-controls@6.4.13
├─ @storybook/addon-docs@6.4.13
├─ @storybook/addon-essentials@6.4.13
├─ @storybook/addon-interactions@6.4.13
│  └─ @storybook/instrumenter@6.4.13
├─ @storybook/addon-links@6.4.13
├─ @storybook/addon-measure@6.4.13
├─ @storybook/addon-outline@6.4.13
├─ @storybook/addon-storyshots@6.4.13
├─ @storybook/addon-toolbars@6.4.13
├─ @storybook/addon-viewport@6.4.13
├─ @storybook/addons@6.4.13
├─ @storybook/api@6.4.13
├─ @storybook/babel-plugin-require-context-hook@1.0.1
├─ @storybook/builder-webpack4@6.4.13
├─ @storybook/channel-postmessage@6.4.13
├─ @storybook/channel-websocket@6.4.13
├─ @storybook/channels@6.4.13
├─ @storybook/client-api@6.4.13
├─ @storybook/client-logger@6.4.13
├─ @storybook/components@6.4.13
├─ @storybook/core-client@6.4.13
├─ @storybook/core-common@6.4.13
├─ @storybook/core-events@6.4.13
├─ @storybook/core-server@6.4.13
├─ @storybook/core@6.4.13
├─ @storybook/csf-tools@6.4.13
├─ @storybook/csf@0.0.2--canary.87bc651.0
├─ @storybook/instrumenter@6.4.0-rc.5
│  ├─ @storybook/addons@6.4.0-rc.5
│  ├─ @storybook/api@6.4.0-rc.5
│  ├─ @storybook/channels@6.4.0-rc.5
│  ├─ @storybook/client-logger@6.4.0-rc.5
│  ├─ @storybook/core-events@6.4.0-rc.5
│  ├─ @storybook/router@6.4.0-rc.5
│  └─ @storybook/theming@6.4.0-rc.5
├─ @storybook/manager-webpack4@6.4.13
├─ @storybook/node-logger@6.4.13
├─ @storybook/postinstall@6.4.13
├─ @storybook/preset-create-react-app@3.2.0
├─ @storybook/preview-web@6.4.13
├─ @storybook/react-docgen-typescript-plugin@1.0.2-canary.253f8c1.0
├─ @storybook/react@6.4.13
├─ @storybook/router@6.4.13
├─ @storybook/semver@7.3.2
├─ @storybook/source-loader@6.4.13
├─ @storybook/store@6.4.13
├─ @storybook/testing-library@0.0.7
│  └─ @storybook/client-logger@6.4.0-rc.5
├─ @storybook/testing-react@1.2.3
├─ @storybook/theming@6.4.13
└─ @storybook/ui@6.4.13
nbstshnbstsh

こちらでも window, global を console.log してみる

storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';

console.log('window', typeof window);
// window object
console.log('global', typeof global);
// global object

initStoryshots();

window が存在する!
やはり、window の有無が鍵だな。jest 実行時に window が存在していれば上述したエラーは解消されるはず...

なぜこちらでは window が存在しているのに、自分の project では window が存在しないんだ...?

nbstshnbstsh

エラーが起きている側の jest の version も確認 => 27.4.7

└─ jest@27.4.7

v26 から v27 で何かしらの breaking change があったのか...?

nbstshnbstsh

Since Jest 27 "jsdom" isn't the default anymore but "node", see github.com/facebook/jest/pull/9874 and here jestjs.io/blog/2021/05/25/jest-27#flipping-defaults.So you have to set "jsdom" explicitly now.
(StackOverflow)

Jest v27 から "testEnvironment" の default 値変わったらしい! "jsdom" から "node" になったと!

https://github.com/facebook/jest/pull/9874

https://jestjs.io/docs/configuration#testenvironment-string

これが原因で window が undefined になっていた可能性高いぞ!

nbstshnbstsh

jest config の testEnvironmentjsdom に設定

jest.config.js
module.exports = {
  roots: ['<rootDir>/src'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/?(*.)+(spec|test).+(ts|tsx|js)',
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
  preset: 'ts-jest/presets/js-with-ts', 
  globals: {
    'ts-jest': {
      useESM: true,
    },
  },
  moduleNameMapper: {
    '\\.css$': '<rootDir>/__mocks__/style-mock.ts',
    'typeface-roboto': '<rootDir>/__mocks__/style-mock.ts',
  },
+  testEnvironment: 'jsdom',
};
nbstshnbstsh

いけた!!!!!!!

testEnvironment を jsdom に設定して jest 実行環境で window object にアクセスできるようになったおかげでこのエラー消えた!↓

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

無事 snapshot testing 実行されたので完了!

nbstshnbstsh

対応まとめ

  • ts-jest で ESM support する
  • css など parse エラーになるものは jest config の moduleNameMapper を使用して mock
  • @storybook 関連の package は version を揃える
  • @babel/core, babel-loader を最新の version にあげる
  • jest config の testEnvironmentjsdom に設定する
nbstshnbstsh
  • styled-components によって生成される class name が毎回変更され差分が大きくなってしまう問題
  • 全ての story の snapshot が一つの file に出力される問題

があったので対応

最終的にはこう↓

storybook.test.ts
import initStoryshots, {
  multiSnapshotWithOptions,
} from '@storybook/addon-storyshots';
import { styleSheetSerializer } from 'jest-styled-components';

initStoryshots({
  snapshotSerializers: [styleSheetSerializer],
  integrityOptions: { cwd: __dirname }, // it will start searching from the current directory
  test: multiSnapshotWithOptions(),
});

styled-components によって生成される class name が毎回変更され差分が大きくなってしまう問題

jest-styled-components の serializer を snapshotserializers option に追加して対応

https://github.com/styled-components/jest-styled-components

https://github.com/storybookjs/storybook/tree/master/addons/storyshots/storyshots-core#snapshotserializers

全ての story の snapshot が一つの file に出力される問題

test optionmultisnapshotwithoptionsoptions を指定

https://github.com/storybookjs/storybook/tree/master/addons/storyshots/storyshots-core#multisnapshotwithoptionsoptions

このスクラップは2022/01/21にクローズされました