Open38

はじめてのstorybook

kzk4043kzk4043

まずはreactのチュートリアルをやって、それをベースにstorybookやっていく様子。
忘れてるしもっかいやっておく

素の状態でstartできなくて草。
react-scriptsが古い?5系にあげたらいけた。
ここにやっていく

kzk4043kzk4043

eslint入れる。eslintも入れ方とかデフォルトの設定とかライブラリごとのプラグインとかいろいろあって毎回なんとなくでやってる気がする…。prettierとの住み分けもいまいちわかってないからどこかで調べる。

npm init @eslint/config

↑やれば対話形式でいけるっぽい。
マニュアルでも行ける。

kzk4043kzk4043

key はグローバルに一意である必要はなく、コンポーネントとその兄弟間で一意であれば十分です。

kzk4043kzk4043

storybook入れていく

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

npx storybook@latest init

既存プロジェクトに入れる場合は上記らしい。package.json見て最適なやつを入れてくれるらしいが…(こういうのうまくいったためしがない…)

案の定エラー

One of your dependencies, babel-preset-react-app, is importing the
"@babel/plugin-proposal-private-property-in-object" package without
declaring it in its dependencies. This is currently working because
"@babel/plugin-proposal-private-property-in-object" is already in your
node_modules folder for unrelated reasons, but it may break at any time.

babel-preset-react-app is part of the create-react-app project, which
is not maintianed anymore. It is thus unlikely that this bug will
ever be fixed. Add "@babel/plugin-proposal-private-property-in-object" to
your devDependencies to work around this error. This will make this message
go away.
  
ERROR in [eslint] 
src/stories/Button.jsx
  Line 1:8:  'React' is defined but never used  no-unused-vars

src/stories/Header.jsx
  Line 1:8:  'React' is defined but never used  no-unused-vars

src/stories/Page.jsx
  Line 35:13:  `"` can be escaped with `"`, `“`, `"`, `”`  react/no-unescaped-entities
  Line 35:18:  `"` can be escaped with `"`, `“`, `"`, `”`  react/no-unescaped-entities

Search for the keywords to learn more about each error.

preview compiled with 1 error
=> Failed to build the preview
WARN Force closed preview build
SB_BUILDER-WEBPACK5_0003 (WebpackCompilationError): There were problems when compiling your code with Webpack.

大したエラーではなかった。修正したらビルドできた。

kzk4043kzk4043

storiesファイルはstorybook/main.jsで指定すればどこに置いてても拾ってくれそう。
storyフォルダに入れ込むのと、各コンポーネントに隣接して置くのどっちがいいだろな…なんかCSS modulesとかにして、テストとかも全部隣接させるのがわかりやすそうな気もする。

src
├── hoge
   ├── HogeComponent.ts
   ├── HogeComponent.module.css
   ├── hoge.test.ts
   └── hoge.stories.ts

こんな感じ。
一つのコンポーネントとかに関連するファイルが増えるからなあ…

kzk4043kzk4043

1コンポーネントに対して1ファイル作らないといけない?
複数コンポーネントを同時に書く方法あるんだろうか。

kzk4043kzk4043

書き方

The stories are written in Component Story Format (CSF)--an ES6 modules-based standard--for writing component examples.

import type { Meta, StoryObj } from '@storybook/react';

import { Button, ButtonProps } from './Button';

const meta: Meta<typeof Button> = {
  component: Button,
};

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

export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

default exportでコンポーネント等のメタ情報を定義して、あとはテストオブジェクト?的なものを作ればOK.

https://qiita.com/suzukalight/items/a1cb23ee9b754add94ca

CSFは新しい書き方なのね。

以前の Storybook は、storiesOf でコンポーネントパターンを定義していましたが、storiesOf 形式では当然 Storybook にロックインするため、せっかく定義した「コンポーネントの状態」を、他ツールへ応用しづらいのが現状です。
CSF は、Storybook v5.2 で新しく導入されたフォーマットです。そのコアコンセプトは、この storiesOf にポータビリティを持たせることです。Jest や Cypress など、他ツールとも連携できるように設計されています。

kzk4043kzk4043

コンポーネントの引数はargsで定義。

https://storybook.js.org/docs/writing-stories/args

story毎

export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

export const PrimaryLongName: Story = {
  args: {
    ...Primary.args,  // こんな感じで使い回せる
    label: 'Primary with a really long name',
  },
};

ファイル = component毎

const meta: Meta<typeof Button> = {
  component: Button,
  //👇 Creates specific argTypes
  argTypes: {
    backgroundColor: { control: 'color' },
  },
  args: {
    //👇 Now all Button stories will be primary.
    primary: true, // default export内で定義すれば、全部に適応できる
  },
};

export default meta;

全部の場合はpreview.tsに定義

preview.ts
// Replace your-renderer with the renderer you are using (e.g., react, vue3, angular, etc.)
import { Preview } from '@storybook/your-renderer';

const preview: Preview = {
  // The default value of the theme arg for all stories
  args: { theme: 'light' },
};

export default preview;

Args can be used to dynamically change props, slots, styles, inputs, etc.

Argsは引数以外にもいろいろ使えるぽい

kzk4043kzk4043

decorators

storyの周り?にスタイルを追加するためのもの。
例えばボタンコンポーネントの周りにpaddingを設定したりとか。

あとは、

For example, if you're working with React's Styled Components and your components use themes, add a single global decorator to .storybook/preview.js to enable them. With Vue, extend Storybook's application and register your library. Or with Angular, add the package into your polyfills.ts and import it:

使用技術によってはdecoratorsを使って環境設定する感じ?

kzk4043kzk4043

Play function

Play functions are small snippets of code executed after the story renders. Enabling you to interact with your components and test scenarios that otherwise required user intervention.

storyレンダリング後になにかしたい時。なにかってなに。

We recommend installing Storybook's addon-interactions before you start writing stories with the play function.

addon-interactionsを入れたほうがいいらしい

あーこれ、ユーザの行動の自動化(フォームへの入力とかクリックとか)とかか。つまりテスト用って感じ?

なんかいろいろ書いてあるが使う時に読む

たぶんtesting-libraryの書き方勉強する必要あり?

export const FilledForm: Story = {
  play: async (引数) => {

// play functionの引数はこんな感じ
abortSignal
allArgs
applyLoaders
argTypes
args
argsByTarget
canvasElement
component
componentId
globals
hooks
id
initialArgs
kind
loaded
moduleExport
name
originalStoryFn
parameters
playFunction
step
story
subcomponents
tags
title
unboundStoryFn
undecoratedStoryFn
unmappedArgs
viewMode

https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas

By default, each interaction you write inside your play function will be executed starting from the top-level element of the Canvas. This is acceptable for smaller components (e.g., buttons, checkboxes, text inputs), but can be inefficient for complex components (e.g., forms, pages), or for multiple stories. To accommodate this, you can adjust your interactions to start execution from the component's root.
Applying these changes to your stories can provide a performance boost and improved error handling with addon-interactions.

export const ExampleStory: Story = {
  play: async ({ canvasElement }) => {
    // Assigns canvas to the component root element
    const canvas = within(canvasElement);

    // Starts querying from the component's root element
    await userEvent.type(canvas.getByTestId('example-element'), 'something');
    await userEvent.click(canvas.getByRole('another-element'));
  },
};

よくわからんけどconst canvas = within(canvasElement);って書き方が効率がいいってこと?

kzk4043kzk4043

Loaders

Loaders are asynchronous functions that load data for a story and its decorators. A story's loaders run before the story renders, and the loaded data injected into the story via its render context.

非同期データロード?

Loaders can be used to load any asset, lazy load components, or fetch data from a remote API. This feature was designed as a performance optimization to handle large story imports.
However, args is the recommended way to manage story data. We're building up an ecosystem of tools and techniques around Args that might not be compatible with loaded data.

大規模データとか特殊用途用で、基本はarguments使ってね、って感じか

kzk4043kzk4043

Naming components and hierarchy

const meta: Meta<typeof Button> = {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/configure/#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: 'Design System/Atoms/Button',
  component: Button,
};

タイトルを/で区切ればstorybook上でフォルダ分け的な感じになる

フォルダ内でのソートもできるらしい

kzk4043kzk4043

addon

A major strength of Storybook are addons that extend Storybook’s UI and behavior. Storybook ships by default with a set of “essential” addons that add to the initial user experience.

kzk4043kzk4043

Actions

The actions addon is used to display data received by event handler (callback) arguments in your stories.

const meta: Meta<typeof Button> = {
  component: Button,
  argTypes: { onClick: { action: 'clicked' } },
};

preview.jsにonXxx全部予めつけとくこともできる。

preview.js
// Replace your-framework with the framework you are using (e.g., react, vue3)
import { Preview } from '@storybook/your-framework';

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on.*' },
  },
};

export default preview;

え、動かないんだけど…?
Exampleの方は動いている…全く同じコードにしても動かないのはなぜ

むむむ

// hoge.jsx
- export function Square({ value, onSquareClick }) {
+ export function Square({ value, onSquareClick, ...props }) {
  return (
-    <button className="square" onClick={onSquareClick} >
+    <button className="square" onClick={onSquareClick} {...props}>
      {value}
    </button>
  );
}

// hoge.stories.js
export default {
  ...
  argTypes: {
    onClick: { action: "clicked" },
  },
};

propsの展開+argTypesの定義でいけた。結構めんどいぞ。結局SquareにonClickを渡してるだけってことか。大事なことだと思うのだがなぜActionsのページに書かない…

kzk4043kzk4043

あとは背景設定とかviewport設定とかいろいろあるっぽい

kzk4043kzk4043

テスト

基本play function?

  • Test runner to automatically test your entire Storybook and catch broken stories.
  • Visual tests capture a screenshot of every story then compare it against baselines to detect appearance and integration issues
  • Accessibility tests catch usability issues related to visual, hearing, mobility, cognitive, speech, or neurological disabilities
  • Interaction tests verify component functionality by simulating user behaviour, firing events, and ensuring that state is updated as expected
  • Coverage tests to measure how much of your code is covered by your tests
  • Snapshot tests detect changes in the rendered markup to surface rendering errors or warnings
  • End-to-end tests for simulating real user scenarios
  • Unit tests for functionality
kzk4043kzk4043

Test runner

Storybook test runner turns all of your stories into executable tests. It is powered by Jest and Playwright.

とりあえずレンダリングエラーだけ確認できるってことか。まあ確かに全部チェックとかしとれんもんな…warnとかも出たり、消したりできるんかな。

  • play functionなし:Storyがエラーなくレンダリングされるか
  • あり:play functionがエラー吐かないか、全アサーション(play functionってassertionあるの?)がパスしたか

やってみるか…

npm install @storybook/test-runner --save-dev

お、パスした。けど、レポートとか無いんかしら

Test Suites: 6 passed, 6 total
Tests:       22 passed, 22 total
Snapshots:   0 total
Time:        3.919 s
Ran all test suites.

一応やっといてもいいがって感じな気がしてきた(レンダリングエラーは気づきそう)…いや、まあでもいろんなパターンでレンダリングできるかどうかわかるのか。

kzk4043kzk4043

Visual tests

chromaticがおすすめらしい。案件ではお金かかるからきついかもだが…個人レベルなら無料っぽいのでやってみる。

npm install --save-dev chromatic
npx chromatic --project-token=[トークン]

いろいろ自動ですごい!!って思ったけどスタイル変更検知してくれない…笑
チュートリアル的なものがおわらない

終わった。お、いい感じ。

chromaticをscriptsに登録

  "scripts": {
    "chromatic": "npx chromatic --project-token=[トークン]"
  },

んん、言うだけあってめちゃいい感じすな…でもお高いんでしょう…?
githubとも連携してReviewとかマージとかいい感じにできるっぽい。よさそう。

でもこれなんかビルドごとに承認する感じなのか。1回変更してそれを戻したけどそれは検知してない。最新ビルドを承認しても過去のやつは別途やらなきゃ行けないのね。まあでもそりゃそうか。単純にスナップショット比較してるだけやもんね。いや、でも全Snapshot取ってたら最新OKならOKちゃうん。いや、ちゃうか。うーん…?

For a self-managed alternative to Chromatic, we offer test runner. It allows you to run visual tests on stories by integrating with Jest and Playwright. Here's an example recipe for visual testing stories.

test runnerでもできるらしいが、結構設定がいるのかな?案件では予算的にこっちになるのかなあ…?

https://github.com/storybookjs/test-runner?tab=readme-ov-file#image-snapshot

kzk4043kzk4043

Interaction tests

You start by writing a story to set up the component's initial state. Then simulate user behavior using the play function. Finally, use the test-runner to confirm that the component renders correctly and that your interaction tests with the play function pass.

play functionでユーザアクションを定義して、test-runnerでチェックって感じか。
お、ダブルクリックとかもあるのね。二重クリック防止のチェックとかにも使える?

export const Initial = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const squares = canvas.getAllByRole("button");
    await userEvent.click(squares[0]);
    await userEvent.click(squares[1]);
    await userEvent.click(squares[2]);
    await userEvent.click(squares[3]);
    await userEvent.click(squares[4]);
    await userEvent.click(squares[5]);
    await userEvent.click(squares[6]);

    // 👇 Assert DOM structure
    await expect(canvas.getByText("Winner: X")).toBeInTheDocument();
  },
};

書き方あってるかわからないが、↑でマルバツゲームを自動化できた↓。storybookをみれば結果が確認できる。

npm run test-storybook

で手動実行も可。これをhuskyに組み込んどけばいいのかな。CIが理想ではあるがGA結構お金かかるらしいしな…

kzk4043kzk4043
    "@storybook/jest": "^0.2.3",
    "@storybook/testing-library": "^0.2.2",

storybookのjestとtesting-libraryまだ1系に乗ってないやん…大丈夫なんか笑
まあでもStorybookでdevDependenciesやしな…

kzk4043kzk4043

https://storybook.js.org/docs/sharing/publish-storybook

チームでの共有前回も困ったな…結局デザイナさんにローカルでビルドしてもらうという力技にした…。この解説読んだ感じだとアクセスコントロールは特にできなさそう?GithubPagesってOrganizationにアクセス限るみたいな機能なかったっけ…

kzk4043kzk4043

まとめ

  • すごいいい感じ
    • 案件で使った時結構環境設定で詰まった印象があったが、進化している?(シンプルなリポジトリだからってのもあるだろうが)
    • フロントエンドのテストはStorybookで十分そうな印象
    • chromaticよいかんじ
  • すごいけど本気で使うってなるとお金が気になる
    • chromaticとの連携は結構ありっぽいなあ…でも費用と相談って感じか(高いのかどうかもよくわかってない)
    • 基本はstory書いて、重要な動作はplay function作って、test-runnerとアクセシビリティテスト回しつつ…これCIで回したら結構お金かかるのかなあ…
kzk4043kzk4043

storyshots

https://storybook.js.org/docs/writing-tests/snapshot-testing

テストの範疇だが大きくなりそうなので分離。

Storyhots is now officially deprecated, is no longer being maintained, and will be removed in the next major release of Storybook.

deprecatedやんw
専用のaddon作ったけど複雑やしデファクトスタンダードのjestに寄せればよくね、みたいな感じなんかな?

kzk4043kzk4043

https://storybook.js.org/docs/writing-tests/storyshots-migration-guide

migration guide。test-runnerかportable storiesに移行せよとのこと。portable storiesってなんや。でもtest-runnerってレンダリングエラーやろ?前後の差分とかは比べられんやん。

including interaction testing with the play function, DOM snapshot, and accessibility testing.

俺がtest-runnerさんの可能性を引き出せてなかっただけか…

kzk4043kzk4043

スナップショットテスト有効化

.storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    // the #storybook-root element wraps the story. In Storybook 6.x, the selector is #root
    const elementHandler = await page.$('#storybook-root');
    const innerHTML = await elementHandler.innerHTML();
    expect(innerHTML).toMatchSnapshot();
  },
};

export default config;

こんだけでいけるん…?


いけてる。すご。
ただちょっと魔法感あるな…トラブったら困りそうな予感。
storyと同じ階層に生成されてるのか。まとめられんのかな?

kzk4043kzk4043

Portable Stories

Storybook provides a composeStories utility that helps convert stories from a story file into renderable elements that can be reused in your Node tests with JSDOM

??
解説記事もあんまないな。よくわからんから広まってない or 新しい機能?

Import all story files based on a glob pattern
Iterate over these files and use composeStories on each of their modules, resulting in a list of renderable components from each story
Cycle through the stories, render them, and snapshot them

結局やってることはtest-runnerと一緒?の割に設定多いから広まらんのか?何のためにある…?
名前的にテスト用ってよりは、他のテスティングフレームワークとかで再利用するための仕組み(can be reused in your Node tests with JSDOMってあるし)?なんもわからん。

設定コードを見るとゴリゴリにテストフローを記述してる感じ。ゴリゴリカスタマイズできる上級者向け機能って感じ?
そのままやるとスナップショットは1ファイルにまとまるけど、分けることもできるよとのこと。

ちょっと一旦スキップで

kzk4043kzk4043

test-runnerに戻る

スナップショットファイルの内容

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Tic-Tac-Toe/Square CheckActions smoke-test 1`] = `
<button class="square">
</button>
`;

exports[`Tic-Tac-Toe/Square X smoke-test 1`] = `
<button class="square">
  X
</button>
`;
...
  • 書式は謎だが、js?exportsというオブジェクトにTic-Tac-Toe/Square X smoke-test 1をキーにしてHTMLを突っ込んでるのかな?
  • Tic-Tac-Toe/Squareはtitle
  • Xはstory名(export const X = {のX)
  • smoke-testはなんだ?play function 使ってるやつはplay-testになっていた。play function使ってないやつはsmoke-test?→スモークテスト
  • 1はなんだろ…全部1になってる。何で変わるのか不明
  • あとはレンダリングされたHTML

文言変えてみる

検出された。最後ranって綴りあってる…?→過去形か!笑そんなことも忘れているとはやばいな…

kzk4043kzk4043

変更を承認するのどうやるの。
chromaticだと自動で承認するかどうかのボタンまで出たもんなーやっぱ便利やなあれ。一覧化してこれはOK、これはだめみたいな感じでやりたいよね。
いや、てかMigration guideに記載がないやん…しといて…笑まああくまでMigration用やしな…じゃあjestを見ればよい…?

 › 2 snapshots failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.

ってエラー書いてあった。全部承認するならnpm run test-storybook -uってこと?
これ押した後ミスってたらキャンセルする方法とかあるんかいな。承認されたかどうかはどこで管理されている?というかそうか、スナップショットファイルが更新されるかどうかか。キャンセルはスナップショットファイルの差分を捨てればいい。
じゃあ1回やってみる。
え、ならないんですけど笑
さっきと同じ結果。

"test-storybook-u": "test-storybook -u",っていうscriptをpackage.jsonに追加したらいけた。オプションって使えなかったっけか…
やはり差分を戻せば戻る。

https://jestjs.io/ja/docs/snapshot-testing

これを解決するには、生成したスナップショットを更新する必要があります。 単純にスナップショットを再生成するように指示するフラグを付けてJestを実行するだけでできます。
jest --updateSnapshot
上記のコマンドを実行することで変更を受け入れることができます。 お好みで一文字の -uフラグでもスナップショットの再生成を行うことができます。
再生成されるスナップショットを限定したい場合は、 --testNamePatternフラグを追加して指定することでパターンにマッチするテストのみスナップショットを再生成することができます。

これか。

インタラクティブスナップショットモード

いや、どうやってそのモードに入るのか書いてや…笑
これや--watch

i押しても

こんな感じになってEnter以外効かん。なんやねん。なんかやり方おかしい?バグ…?

うーん、なんかめんどい気がするけど、どうせ全部OKになってからしかやらんでしょ?みたいなこと?

kzk4043kzk4043

ローカルでビジュアルリグレッションテスト

https://storybook.js.org/docs/writing-tests/storyshots-migration-guide#run-image-snapshot-tests-with-the-test-runner

import { TestRunnerConfig, waitForPageReady } from '@storybook/test-runner';

import { toMatchImageSnapshot } from 'jest-image-snapshot';

const customSnapshotsDir = `${process.cwd()}/__snapshots__`;

const config: TestRunnerConfig = {
  setup() {
    expect.extend({ toMatchImageSnapshot });
  },
  async postVisit(page, context) {
    // Waits for the page to be ready before taking a screenshot to ensure consistent results
    await waitForPageReady(page);

    // To capture a screenshot for for different browsers, add page.context().browser().browserType().name() to get the browser name to prefix the file name
    const image = await page.screenshot();
    expect(image).toMatchImageSnapshot({
      customSnapshotsDir,
      customSnapshotIdentifier: context.id,
    });
  },
};
export default config;

jest-image-snapshotがないとのこと。アメックス…?

[test-storybook] Cannot find module 'jest-image-snapshot'

-> npm i --save-dev jest-image-snapshot

…?全部タイムアウトした。
時間を増やせばいいってより根本的になにかおかしそう。。

kzk4043kzk4043

ぬーん…やっぱ魔法やからデバッグがよくわからん。。
chromaticに誘導したいんだろうが公式が不親切過ぎんか…?

    console.log("postVisit");
    // Waits for the page to be ready before taking a screenshot to ensure consistent results
    await waitForPageReady(page);
    console.log("page ready");

page readyに到達してない。うーん…storybook実行してればいいんちゃうの…?

await waitForPageReady(page);を外したらうまくいった… postVisit自体がレンダリング後のはずやからこれでも良い気がするが、いいのか…?画像自体はplay functionありのものも含めちゃんと取れてた。うーん…すっきりしない。。

kzk4043kzk4043

文字を赤に変えたら検出された。差分画像が生成されてる(スナップショットフォルダ内にdiffフォルダが生成されてそのなに入っている)からそれをみて判断する感じか。でもChromaticのを見てるからそれと比べるとやっぱ見ずらい。

-uオプションで実行したら更新され、diff画像は削除された。