🕌

Storybookのテストランナー

2022/05/29に公開

はじめに

Storybook test runner turns all of your stories into executable tests.
https://github.com/storybookjs/test-runner

Storybookの全てのStoryをテスト可能にする@storybook/test-runnerについてのメモ。
Storybookの公式ドキュメントではv6.5からテストランナーについてのページが設けられている。

何をテストするものか

テスト対象についてはplay関数の有無によって異なる。

  • For those without a play function: it verifies whether the story renders without any errors.
  • For those with a play function: it also checks for errors in the play function and that all assertions passed.

https://storybook.js.org/docs/react/writing-tests/test-runner

@storybook/test-runnerがテストするのは、play関数を持たないStoryであればStoryがエラーなくレンダリングされるかどうかをテストする。
play関数を持つStoryの場合はそのStoryがエラーなくレンダリングされるかどうかに加えてplay関数でエラーが発生しないこととアサーションをパスすることのよう。

エラーなくStoryがレンダリングされる play関数をエラーなく実行できる play関数でのアサーションを全てパスする
play関数なし - -
play関数あり

Storybookで全てのStoryを都度確認するのは非常に手間がかかるし事細かに確認することは現実的でないので、@storybook/test-runnerによって全てのStoryが正常に表示できてインタラクションも正常に動作していることをテストすることで肩代わりする。

また、テストはブラウザをエミュレートした環境ではなく、ヘッドレスブラウザ上でStorybookに対してテストを実施するもののようで内部的にはjest + playwrightを利用して実現されているそう。

試してみる

実際に@storybook/test-runnerを試してみる。
https://github.com/makotot/sb-test-runner-playground がそのリポジトリ。

事前準備

テスト対象のプロジェクトをviteで用意しておく。

$ npm create vite@latest

✔ Project name: … sb-test-runner-playground
✔ Select a framework: › react
✔ Select a variant: › react-ts

Scaffolding project in /path/to/sb-test-runner-playground...

Done. Now run:

  cd sb-test-runner-playground
  npm install
  npm run dev

作成したプロジェクトにStorybookを用意する。

$ cd sb-test-runner-playground
$ npx sb init --builder @storybook/builder-vite

Need to install the following packages:
  sb
Ok to proceed? (y) y

 storybook init - the simplest way to add a Storybook to your project.

 • Detecting project type. ✓
 • Adding Storybook support to your "React" app

attention => Storybook now collects completely anonymous telemetry regarding usage.
This information is used to shape Storybook's roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://storybook.js.org/telemetry

.
.
.

. ✓
🔎 checking possible migrations..

✅ migration check successfully ran

To run your Storybook, type:

   npm run storybook

For more information visit: https://storybook.js.org

npm run storybookでStorybookが起動することを確認できる。

@storybook/test-runnerを実行する

Storybookの環境が用意できたので、@storybook/test-runnerをインストールする。peerDependenciesであるjestも同時にインストールする。

$ npm i -E -D @storybook/test-runner jest

npm run test-storybookでテストランナー実行できるようにscriptsを追加しておく。ウォッチモードも利用するので--watchを利用したscriptsを追加しておく。

package.json
"scripts": {
  ...,
  "test-storybook": "test-storybook",
  "test-storybook:watch": "test-storybook --watch"
}

テストランナーはStorybookに対して実行するので、storybookを起動する。

$ npm run storybook

jest v28

@storybook/test-runnerのv0.1.0を利用して確認しているが、npm run test-storybookを実行するとjestに関するエラーが発生する。

$ npm run test-storybook

> sb-test-runner-playground@0.0.0 test-storybook
> test-storybook

TypeError: Jest: Got error running globalSetup - /path/to/sb-test-runner-playground/node_modules/@storybook/test-runner/playwright/global-setup.js, reason: Class extends value #<Object> is not a constructor or null

これはjest-playwrightの互換性の問題でjest v28には対応できてないのでv27に強制的に留めておく対応を行うと回避できる。

https://github.com/storybookjs/test-runner/issues/99

node_modules/の中を確認してみるとv28で入っているjest関連のnpmパッケージがあることを確認できる。($ npm lsでよかったかもしれない。)

$ cat node_modules/jest/package.json | grep version
  "version": "27.5.1",

$ cat node_modules/jest-runner/package.json | grep version
  "version": "28.1.0",

$ cat node_modules/jest-environment-node/package.json | grep version
  "version": "28.1.0",

v28になっているパッケージに関しては個別にv27をインストールする。

$ npm i -E -D jest-runner@27.5.1 jest-environment-node@27.5.1

インストールしたv27のバージョンに固定しておく。

package.json
"resolutions": {
  "jest": "27.5.1",
  "jest-runner": "27.5.1",
  "jest-environment-node": "27.5.1"
}

v27に固定して再度実行するとテストランナーが問題なく動くことを確認できる。

$ npm run test-storybook

> sb-test-runner-playground@0.0.0 test-storybook
> test-storybook

 PASS   browser: chromium  src/stories/Header.stories.tsx
 PASS   browser: chromium  src/stories/Button.stories.tsx
 PASS   browser: chromium  src/stories/Page.stories.tsx

Test Suites: 3 passed, 3 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        11.402 s
Ran all test suites.

インタラクションテストのビジュアルデバッグの設定

インタラクションテストのビジュアルデバッグを可能にしておくため、@storybook/jestもインストールしておく。@storybook/addon-interactions@storybook/testing-libraryも必要となるけど、これらはすでにインストールされているはず。

$ npm i -E -D @storybook/jest

.storybook/main.jsfeaturesinteractionsDebuggerを有効にするように追記する。

main.js
  features: {
    storyStoreV7: true,
+    interactionsDebugger: true,
  },

テストのフィードバックを受けながらコンポーネント開発する

事前準備が完了したので、ここから本題のテストランナーでテストを行う。まずはテスト対象になるコンポーネントとそのStoryを用意する。

状況としては、コンポーネントとしてButtonとそのButtonをトリガーに1ずつ数値を増減させるCounterをStoryとして持っているStorybookがあるという状況下にあると仮定する。

また、ここではそのButtonコンポーネントをStorybookがデフォルトで用意しているstories/Button.tsxとする。以下URLのものと同じはず。

https://github.com/storybookjs/storybook/blob/e9fad373219789488ccae5c423022c7096bd5e06/lib/cli/src/frameworks/react/ts/Button.tsx

その上で、Counterコンポーネントを以下のような形で用意する。

import * as React from "react";
import { Button } from "./Button";

export const Counter: React.FC<{ initialValue: number }> = ({
  initialValue = 0,
}) => {
  const [value, updateValue] = React.useState(initialValue);
  const increment = () => {
    updateValue((prev) => prev + 1);
  };
  const decrement = () => {
    updateValue((prev) => prev + -1);
  };

  return (
    <div
      style={{
        display: "flex",
        gap: "1rem",
        alignItems: "center",
      }}
    >
      <Button data-testid="increment" label="+1" onClick={increment} />
      <div data-testid="counter-value">{value}</div>
      <Button data-testid="decrement" label="-1" onClick={decrement} />
    </div>
  );
};

Storybook上では以下のように表示されていることが確認できる。

CounterコンポーネントのStoryファイルとしてCounter.stories.tsxも用意して、play関数を+1のボタンを押下したら0だったカウンタ表示が1に変わるインタラクションのテストに利用する。

import { expect } from "@storybook/jest";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { userEvent, within } from "@storybook/testing-library";
import * as React from "react";
import { Counter } from "./Counter";

export default {
  title: "Counter",
  component: Counter,
  argTypes: {},
  args: {
    initialValue: 0,
  },
} as ComponentMeta<typeof Counter>;

const Template: ComponentStory<typeof Counter> = (args) => (
  <Counter
    {...args}
  />
);

export const Default = Template.bind({});
Default.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement);
  const value = canvas.getByTestId("counter-value");
  expect(value.innerText).toEqual("0");
  await userEvent.click(canvas.getByTestId("increment"));
  expect(value.innerText).toEqual("1");
};

このplay関数のテスト結果は@storybook/addon-interactionsのアドオンのInteractionsのパネルで確認できて、テストに成功していることが分かる。

@storybook/test-runnerをウォッチモードで起動してみると同じようにテストは成功する。テスト対象を絞ってないので実行時間は若干かかる。

$ npm run test-storybook:watch
 PASS   browser: chromium  src/stories/Header.stories.tsx
 PASS   browser: chromium  src/stories/Button.stories.tsx
 PASS   browser: chromium  src/stories/Page.stories.tsx
 PASS   browser: chromium  src/stories/Counter.stories.tsx

Test Suites: 4 passed, 4 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        7.242 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

テストを失敗させる

例えばここでButtonコンポーネントがクリックできない状態になったとする。現実的には考えにくいケースだけどもテストが失敗するケースとして。

Button.tsx
    <button
      type="button"
      className={['storybook-button', `storybook-button--${size}`, mode].join(
        ' '
      )}
-      style={{ backgroundColor }}
+      style={{ backgroundColor, pointerEvents: 'none' }}

修正を加えたButtonコンポーネントのStory自体はテストに失敗しないしStorybookでも表示だけ見れば特に問題は見当たらない(ボタンをクリックできないことに開発者が確認していて気づかないとは考えにくいがここでは考慮しない)けど、Counterコンポーネントのテストは失敗する。CounterのStoryの画面を再読み込みするとテストに失敗していることが分かる。

一方で@storybook/test-runnerの方ではテストに時間が若干かかるもののCounterのStoryを特に確認せずともテストに失敗することが分かる。
Storybookがデフォルトで用意しているPageのStoryのテストもButtonのクリック不可になった影響を受けて失敗するようになったのは考慮が漏れていた。

 PASS   browser: chromium  src/stories/Header.stories.tsx
 FAIL   browser: chromium  src/stories/Counter.stories.tsx
  ● Counter › Default › play-test

    page.evaluate: StorybookTestRunnerError:
    An error occurred in the following story. Access the link for full output:
    http://localhost:6006/?path=/story/counter--default&addonPanel=storybook/interactions/panel

    Message:
     unable to click element as it has or inherits pointer-events set to "none".

      at Object.<anonymous> (<anonymous>:82:13)
      at http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:212:14
          at Array.forEach (<anonymous>)
      at Channel2.handleEvent (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:211:19)
      at handler2 (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:141:16)
      at Channel2.emit (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:146:9)
      at PreviewWeb2.renderException (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:2823:20)
      at Object.showException (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:2782:25)
      at StoryRender2._callee9$ (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:424:32)
      at tryCatch (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-7K2HGKM4.js?v=26268201:45:44)
      at testFn (src/stories/Counter.stories.tsx:35:37)
      at Object.<anonymous> (src/stories/Counter.stories.tsx:50:17)

 PASS   browser: chromium  src/stories/Button.stories.tsx
 FAIL   browser: chromium  src/stories/Page.stories.tsx
  ● Example/Page › LoggedIn › play-test

    page.evaluate: StorybookTestRunnerError:
    An error occurred in the following story. Access the link for full output:
    http://localhost:6006/?path=/story/example-page--logged-in&addonPanel=storybook/interactions/panel

    Message:
     unable to click element as it has or inherits pointer-events set to "none".

      at Object.<anonymous> (<anonymous>:82:13)
      at http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:212:14
          at Array.forEach (<anonymous>)
      at Channel2.handleEvent (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:211:19)
      at handler2 (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:141:16)
      at Channel2.emit (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-QQ3TMKM5.js?v=26268201:146:9)
      at PreviewWeb2.renderException (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:2823:20)
      at Object.showException (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:2782:25)
      at StoryRender2._callee9$ (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-HTWPY52G.js?v=26268201:424:32)
      at tryCatch (http:/localhost:6006/node_modules/.vite-storybook/deps/chunk-7K2HGKM4.js?v=26268201:45:44)
      at testFn (src/stories/Page.stories.tsx:84:37)
      at Object.<anonymous> (src/stories/Page.stories.tsx:99:17)

Test Suites: 2 failed, 2 passed, 4 total
Tests:       2 failed, 7 passed, 9 total
Snapshots:   0 total
Time:        4.895 s, estimated 5 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

現実的にはテスト対象のStoryのファイルはもっと多くあると思うので、実行時間を考えるとウォッチモードでテストを実行しながらというのはやらないかなと思う。実行時間を短縮するのにテスト対象のファイルをフィルタリングできたりするけども問題の検知という意味ではテスト対象を絞らない方が望ましいはず。ただ、ローカルでもCIでもこのようなテストを実施できることでコンポーネントレベルでの問題を早期に発見することができそう。

jest + @testing-library/react + @testing-library/jest-dom

Reactのコンポーネントをテストする場合、恐らく現状最も一般的になっているのはjestをテストランナーとして@testing-library/react@testing-library/jest-domを利用してテストする形だろうと思うけど、Storybookのテストランナーによるテストとどのように違いがあるのか。
jestとjsdomの組み合わせによるテストはブラウザをエミュレートしたもので実際のブラウザを完全に再現できているわけではなく、そのためレイアウトに関わるテストなどを実施できない。一方で@storybook/test-runnerは本物のブラウザに対してテストを行うのでそのような制限を受けないことが違いとしてはあると思う。
以下のdiscussionでは、実行速度が判断軸になるほど大きな差が出ないことやStorybookを用意する必要の有無の違いなどについても触れている。
https://github.com/storybookjs/storybook/discussions/16861#discussioncomment-2513340

Chromaticとの使い分け

https://www.chromatic.com/

Storybookの開発チームによって作られているビジュアルテストのクラウドサービスとしてChromaticがあるけども、@storybook/test-runnerとChromaticとの使い分けはどのように考えれば良いのか。
ChromaticではUIテストで正常にStoryがレンダリングできているかは確認できると思うし、インタラクションテストもサポートしている。
Storybookのドキュメントには以下のように、ローカル環境とCIでの使い分け、ビジュアル及びインタラクションテストとその他のカスタムテスト(これがどのようなものかはちょっと分からない)による使い分けの言及がある。

  • Use it locally and Chromatic on your CI.
  • Use Chromatic for visual and interaction tests and run other custom tests using the test runner.

https://storybook.js.org/docs/react/writing-tests/test-runner#whats-the-difference-between-chromatic-and-test-runner

@storybook/test-runnerでChromaticにおけるsnapshotによる視覚的な比較でのテストが行えるわけではないというのは明確な違いとしてあると思う。

ChromaticはSnapshotの実行回数が増加すると金銭的なコストを支払う必要性が出てくると思うので、なるべくお金をかけずにという点でも使い分けの判断があるかもしれない。
CI上でテストを実行する場合にはCIサービスによる金銭面でのコストの側面も考える必要はありそうだけど、それはChromaticだろうと@storybook/test-runnerでも大きな違いはなさそう。

他のコンポーネントのテスト関連ツール

CypressやPlaywrightのようなE2Eテストフレームワークでもコンポーネントのテストが機能として含まれるようになったりしていて、コンポーネントを本物のブラウザでテストする選択肢が増え始めているように思う。

https://docs.cypress.io/guides/component-testing/introduction

https://playwright.dev/docs/test-components

一方で従来のtesting-library + jestのテストであっても本物のブラウザを動かしながら確認できるjest-previewのようなツールも登場してきている。
https://www.jest-preview.com/docs/getting-started/intro

さいごに

@storybook/test-runnerはまだリリースされて間もないので実際に使っていくと恐らく不具合などに遭遇するケースがあると思うけど、恐らく今後Storybook利用している状況下ではテストランナーとして有力な選択肢になるのではないかなと思う。
ただ、本物のブラウザでテスト実行できるもののUIに対するテストという意味でカバーしている範囲が限定的ではあると思うので、Chromaticなど他のテストツールとの組み合わせで信頼性をどのように高めるかは当然考える必要がある。
一方でコンポーネントに対するテスト関連のツールに関しては上記のように選択肢が増えてきたりしている状況にあると思うので、その辺りについても動向を注視するようにしておきたい。

GitHubで編集を提案

Discussion