Storybookを触ってみた
Storybookを使用した理由
今まで、ReactやNext.jsで開発するときはStorybookを使わずに実装していたのですが、コンポーネントの管理やコンポーネント単位でテストもできるということで、個人開発をするタイミングで導入してみました。
対象読者
- Storybookを触ったことない方
 - Storybookを初めて知った方
 - Storybookのドキュメントを読もうとしたが、よく分からなかった方
 
Storybookとは
- コンポーネントをカタログ化して、管理できるツールです。カタログ化することで、双方の行き違いを限りなく少なくできる
 - コンポーネント単位でテストができる。
 
導入
プロジェクトは作成している状態。
今回はReactのプロジェクトに導入することを想定しております。
実行
npx -p @storybook/cli sb init --type react_scripts
今回の場合は、バージョン6.5が入りました。
"devDependencies": {
    "@storybook/react": "^6.5.16",
  }
フォルダ構成を確認
下記の二つのフォルダが作成されます。
- 
/.storybook
Storybookの構成ファイル - 
/src/stories
ストーリーを配置する場所。
ストーリーとはStorybookに表示するコンポーネントとその状態を定義するJavaScriptやTypeScriptファイルです。 
早速立ち上げてみます。ターミナルにて下記のコマンドを実行します。
 yarn storybook
localhost:6006/で立ち上げる。

Storybookの設定
流れは下記のようになります。
- ディレクトリ・ファイルを作成
 - コンポーネントを作成
 - ストーリーを作成
 
ディレクトリ・ファイルを作成
デフォルトでは、storiesディレクトリにButtonコンポーネントとButtonストーリーが作成されております。
src/stories/Button.jsx
src/stories/Button.stories.jsx
こちらは好みでディレクトリを分けてストーリーを管理する方法もできます。
もし自作でディレクトリを分けた際は、main.jsにて対象するディレクトリの設定を変える必要があります。
下記だと../src/stories/**以下のファイルが読み込まれます
module.exports = {
  stories: [
    "../src/**/*.stories.mdx",
    "../src/stories/**/*.stories.@(js|jsx|ts|tsx)",
  ],
 //略
};
コンポーネントを作成
function Button({ children }) {
  return <button>{children}</button>;
}
export default Button;
ストーリーを作成
ストーリーとは、コンポーネントをどのように描写するのかを決めるJavaScriptの関数。
ストーリーの書き方はComponent Story Format (CSF)で書きます。
CSFはESmoduleをベースとして記述している書き方で、Story fileはdefault exportかnamed exportから構成されています。
※Esmoduleについて
CSFは、下記の3つでコンポーネントに関するメタデータを定義します。
- title
ストーリーのディレクトリ名 - component
ストーリーを設定するコンポーネントを指定。 - PropTypes or argTypes
propsで渡される型の設定を行うことができる。 
export default {
  title: 'Example/Button',
  component: Button,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as ComponentMeta<typeof Button>;
argTypesを設定することでButtonのbackgroundColorを変更することが変更することができる。

argTypes
- StorybookのAPIの一部
 - 特定のコンポーネントのプロパティを調整するために使用される
 - Storybookによって管理されているプロパティのデフォルト値や型を定義し、StorybookのUIで編集できるようにするもの。
 - TypeScriptを使用している場合はargTypesに対して型定義を追加ことができる。
 - プロパティの方に関するエラーを防ぐためcomponentMetaをtypeof Buttonにしている。
ComponentMeta<typeof Button> 
↓argTypesの種類
Storybookを導入した際に、Buttonコンポーネントがbindされているので.argspropsで表示を編集することができます。
export default {
  title: "Example/Button",
  component: Button,
  argTypes: {
    backgroundColor: { control: "color" },
  },
} as ComponentMeta<typeof Button>;
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: "This is Primary",
};
export const Secondary = Template.bind({});
Secondary.args = {
  label: "This is Secondary",
};
export const Large = Template.bind({});
Large.args = {
  size: "large",
  label: "Button",
};
export const Small = Template.bind({});
Small.args = {
  size: "small",
  label: "Button",
};
また、ブラウザ上でも変更して表示の確認もできます。

argsTypeによる設定
ストーリーファイルでargTypesを利用することでpropsで渡される型の設定を行うことができます。
今回は、ボタンのカラーをラジオボタンに設定。サイズをセレクトタイプで設定します。
import React from "react";
import { Button } from "./Button";
export default {
  title: "Example/Button",
  component: Button,
  argTypes: {
    color: {
      options: ["primary", "default", "danger"],
      control: { type: "radio" },
    },
    size: {
      options: ["sm", "base", "lg"],
      control: { type: "select" },
    },
  },
};
//略

typeにはこの他にはtext, date, boolean, numberなどを設定することができる。
Actionsの確認
onClickイベントが正常に動作するのか確認する。
- コンポーネントファイルにhandleClick引数とhandleClickイベントを設定
 
export const Button = ({
  primary,
  backgroundColor,
  size,
  label,
  handleClick,
  ...props
}) => {
  const mode = primary
    ? "storybook-button--primary"
    : "storybook-button--secondary";
  return (
    <button
      type="button"
      className={["storybook-button", `storybook-button--${size}`, mode].join(
        " "
      )}
      style={backgroundColor && { backgroundColor }}
      onClick={handleClick}
      {...props}
    >
      {label}
    </button>
  );
};
- argTypesにhandleClickを設定。actionをtrueにする
 
export default {
  title: "Common/Button",
  component: Button,
  argTypes: {
    color: {
      options: ["primary", "default", "danger"],
      control: { type: "radio" },
    },
    size: {
      options: ["sm", "base", "lg"],
      control: { type: "select" },
    },
    backgroundColor: {
      control: { type: "color" },
    },
    handleClick: { action: true },
  },
};

アドオン
アドオンとはStorybookの拡張機能。
種類
- LinkTo
 - viewport
 - interaction test
 
LinkTo
ストーリーから別のストーリーに移動する際にはLinkToを利用する。
- 下記のコンポーネントを利用
 
export const Button = ({
  primary,
  backgroundColor,
  size,
  label,
  handleClick,
  ...props
}) => {
  const mode = primary
    ? "storybook-button--primary"
    : "storybook-button--secondary";
  return (
    <button
      type="button"
      className={["storybook-button", `storybook-button--${size}`, mode].join(
        " "
      )}
      style={backgroundColor && { backgroundColor }}
      onClick={handleClick}
      {...props}
    >
      {label}
    </button>
  );
};
- ストーリーファイルにLinkToをインポート
 
import { linkTo } from "@storybook/addon-links";
- ストーリーファイルのhandleClickを利用。linkToの設定
 
- 第一引数 移動先のコンポーネント
 - 第二引数 移動先のストーリーの名前
 
const Template = (args) => (
  <Button {...args} handleClick={linkTo("Example/Button", "Secondary")} />
);
viewport
特徴
- レイアウトサイズを変更できる。
 - レスポンシブにしたい時に便利
https://github.com/storybookjs/storybook/tree/master/addons/viewport 
Version6から?
アドオンをインストールしなくても良いらしい。
設定
.storybook/preview.js内に設定していく。
1. INITIAL_VIEWPORTSをimport
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
2. parametersの設定
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
addParameters({
 viewport: {
  viewports: INITIAL_VIEWPORTS,
 },
});


interaction test
interaction testとは
- interaction testとはStorybook上でボタンクリックやフォーム入力などのユーザーのインタラクションを再現できる機能。
 - StorybookのAddonとして行うことができる。
 - ボタンコンポーネントで利用した場合はストーリーの中でボタンをクリックすることができる
 - Interactionsではtesting-library、Jestの機能を利用する。
- 既に
package.jsonにインストールされている。- @storybook/addon-interactions
 - @storybook/testing-library
 
 
 - 既に
 - Jestの機能を利用する場合はstorybook/jestのインストールが必要
 
流れ
下記の1,2でinteractionを使用できるように設定。
3~6で実際にinteractionを設定していきます。
- Jestをinstall
 - main.jsにデバック用の再生コントロールを有効にする
 - play関数の設定
 - 取得したいDOM要素をクエリする関数を定義
 - 取得した要素とその要素の中にあるイベントを実行
 - 実行されたか確認
 
interactionを使用できるように設定
- Jestをinstall
npm install @storybook/jest - 
main.jsにデバック用の再生コントロールを有効にする。 
module.exports = {
  stories: [
    "../src/**/*.stories.mdx",
    "../src/stories/**/*.stories.@(js|jsx|ts|tsx)",
  ],
  addons: [
   // 他のaddonsは省略しております。
    "@storybook/addon-interactions",
  ],
  framework: "@storybook/react",
  core: {
    builder: "@storybook/builder-webpack5",
  },
  //下記を追加
  features: {
    interactionsDebugger: true,
  },
};
interactionsを設定
- play関数の設定
ストーリーファイルにplay関数関数の中に記述していく。
引数でargsとcanbasElementを受け取ることができる。 
PrimaryLarge.play = async ({ args, canvasElement }) => {
  //処理
};
※canvasElement
JavaScriptやCSSを使用してWebページ上でグラヒックやアニメーションを動的に生成する、HTML要素の一種。
- 取得したいDOM要素をクエリする関数を定義
上記のplay関数の処理内に記載していきます。
button要素取得の際に必要なgetByRoleメソッドを利用するために、within関数の引数にcanvasElementを指定する。
import { within } from "@testing-library/dom";
const canvas = within(canvasElement); 
※getByRoleメソッド
Testing Libraryの一部。特定のARIA役割を持つDOM要素を取得するために使用する。
※within関数
testing-libraryライブラリの特定のコンテナーエレメント内の DOM 要素をクエリするためのユーティリティ関数。
- 
取得した要素とその要素の中にあるイベントを実行
import userEvent from "@testing-library/user-event";
await userEvent.click(canvas.getByRole("button")); - 
実行されたか確認
handleClick関数が実行されたかMatcher関数のtoHaveBeenCalled関数でチェックを行なう。
import { expect } from "@storybook/jest";
await expect(args.handleClick).toHaveBeenCalled(); 
※Matcher関数
JavaScriptのユニットテストフレームワーク、Jestの一部。
特定の条件を満たすかどうかをテストをし、テスト内で期待する結果と実装の結果を比較するために使用される。
interaction設定で追加したコード
import { within } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { expect } from "@storybook/jest";
Primary.play = async ({ args, canvasElement }) => {
  const canvas = within(canvasElement);
  await userEvent.click(canvas.getByRole("button"));
  await expect(args.handleClick).toHaveBeenCalled();
};
import React from "react";
import { Button } from "./Button";
import { within, userEvent } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
export default {
  title: "Example/Button",
  component: Button,
  argTypes: {
    handleClick: {
      action: true,
    },
    color: {
      options: ["primary", "default", "danger"],
      control: { type: "radio" },
    },
    size: {
      options: ["sm", "base", "lg"],
      control: { type: "select" },
    },
  },
};
const Template = (args) => <Button {...args} />;
export const Default = Template.bind({});
Default.args = {
  children: "Default",
  label: "Default",
  color: "primary",
  size: "medium",
};
export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: "Button",
  color: "primary",
  size: "medium",
};
//省略
Primary.play = async ({ args, canvasElement }) => {
  const canvas = within(canvasElement);
  await userEvent.click(canvas.getByRole("button"));
  await expect(args.handleClick).toHaveBeenCalled();
};
設定後、ブラウザ上でPrimaryストーリーを選択した後に、play関数が実行され、Interactionsタブに2件のメッセージが表示される。

ActionsタブにはhandleClickのメッセージが表示される。

参考記事
最後に
Storybookは「面倒でメンテナンスコストが高い」など言われているらしいですが、
デザイナーとコミュニケーションがとりやすい、コンポーネント単位でUIのカタログが作成できる、コンポーネント単位でテストができるので保守性も高まるという点で、慣れるとメリットが大きそうに感じました。
なので、Storybookに慣れて開発してメリットを存分に発揮できるようにしていこうと思います。
下記の記事にStorybookを導入する際にやるべきことが記載されており、
抑える箇所をしっかり抑えたら、Storybookの恩恵をしっかり受けることができるということが記載されています。
Discussion