Storybookのテストランナー
はじめに
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
を追加しておく。
"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に強制的に留めておく対応を行うと回避できる。
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のバージョンに固定しておく。
"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.js
のfeatures
にinteractionsDebugger
を有効にするように追記する。
features: {
storyStoreV7: true,
+ interactionsDebugger: true,
},
テストのフィードバックを受けながらコンポーネント開発する
事前準備が完了したので、ここから本題のテストランナーでテストを行う。まずはテスト対象になるコンポーネントとそのStoryを用意する。
状況としては、コンポーネントとしてButton
とそのButton
をトリガーに1ずつ数値を増減させるCounter
をStoryとして持っているStorybookがあるという状況下にあると仮定する。
また、ここではそのButton
コンポーネントをStorybookがデフォルトで用意しているstories/Button.tsx
とする。以下URLのものと同じはず。
その上で、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
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でもこのようなテストを実施できることでコンポーネントレベルでの問題を早期に発見することができそう。
@testing-library/react
+ @testing-library/jest-dom
jest + Reactのコンポーネントをテストする場合、恐らく現状最も一般的になっているのはjestをテストランナーとして@testing-library/react
と@testing-library/jest-dom
を利用してテストする形だろうと思うけど、Storybookのテストランナーによるテストとどのように違いがあるのか。
jestとjsdomの組み合わせによるテストはブラウザをエミュレートしたもので実際のブラウザを完全に再現できているわけではなく、そのためレイアウトに関わるテストなどを実施できない。一方で@storybook/test-runner
は本物のブラウザに対してテストを行うのでそのような制限を受けないことが違いとしてはあると思う。
以下のdiscussionでは、実行速度が判断軸になるほど大きな差が出ないことやStorybookを用意する必要の有無の違いなどについても触れている。
Chromaticとの使い分け
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.
@storybook/test-runner
でChromaticにおけるsnapshotによる視覚的な比較でのテストが行えるわけではないというのは明確な違いとしてあると思う。
ChromaticはSnapshotの実行回数が増加すると金銭的なコストを支払う必要性が出てくると思うので、なるべくお金をかけずにという点でも使い分けの判断があるかもしれない。
CI上でテストを実行する場合にはCIサービスによる金銭面でのコストの側面も考える必要はありそうだけど、それはChromaticだろうと@storybook/test-runner
でも大きな違いはなさそう。
他のコンポーネントのテスト関連ツール
CypressやPlaywrightのようなE2Eテストフレームワークでもコンポーネントのテストが機能として含まれるようになったりしていて、コンポーネントを本物のブラウザでテストする選択肢が増え始めているように思う。
一方で従来のtesting-library + jestのテストであっても本物のブラウザを動かしながら確認できるjest-previewのようなツールも登場してきている。
さいごに
@storybook/test-runner
はまだリリースされて間もないので実際に使っていくと恐らく不具合などに遭遇するケースがあると思うけど、恐らく今後Storybook利用している状況下ではテストランナーとして有力な選択肢になるのではないかなと思う。
ただ、本物のブラウザでテスト実行できるもののUIに対するテストという意味でカバーしている範囲が限定的ではあると思うので、Chromaticなど他のテストツールとの組み合わせで信頼性をどのように高めるかは当然考える必要がある。
一方でコンポーネントに対するテスト関連のツールに関しては上記のように選択肢が増えてきたりしている状況にあると思うので、その辺りについても動向を注視するようにしておきたい。
Discussion