Storybook自動生成によるDX改善

目次

こんにちは。株式会社ハイヤールーの@Himenonです。HireRoo アドベントカレンダー 2022 2 日目の記事です!

現在、Web フロントエンドのリアーキテクチャを行っており、Presentation 層と Container 層の分離を進めています。この詳細についてはフロントエンドのコンポーネントの責務分離のための方針とその効果についてをご覧ください。ここでは Presentation 層にスポットを当て、その開発を支える Storybook を弊社でどのように効果的に利用しているのか紹介します。ただし、Storybook はなにか、という話はここでは触れません。

なぜ Storybook を使うか

前述したとおり、我々の現在の目的は Presentation 層と Conatiner 層を行っています。ただそれだけを行っているわけではなく、数年先まで見据えてどのように利用するべきかまで考えた上で Storybook を利用しています。

将来的な使い方として以下の目的があります。

  1. Presentation 層と Container 層を分離するために作業領域を制限する
  2. DOM の Snapshot テストを実施しライブラリなどによる不本意な変更を検知する
  3. Visual Regression Test のために利用する

1 に関しては現在の作業によって達成されつつあり、自然と作業領域の分断が確立されています。2 についても徐々に用意はできており、導入予定です。逆に言えば、Storybook に対して高望みしていないことは、

  • e2e テスト
  • Container 層を含んだような機能として独立した挙動確認のための使い方

です。これらの責務は Storybook で確認できることを期待していません。それはなぜか、次節で説明します。

Storybook のほとんどのパラメーターは自動生成する

Storybook のために開発者が手を動かす範囲はほとんどの場合「Component の proops をどのように作るか」です。props が定義されればその写像として UI が表示される、というのが Presentation 層の役割です。Storybook はそれを実現できれば我々にとって十分な機能を果たします。逆に言えば、それ以外の作業で Storybook に関する調整を入れるのはサービス開発における本質的な部分ではありません。したがって、ここが Storybook の責務境界であり、前節の解になります。

Storybook の責務範囲が決まれば、自動生成する部分が見えてきます。すなわち、我々の利用範囲においてprops 以外は基本的に自動生成することが可能になります。

これを実装として表現すると、Storybook の基本骨格は次のようになります。

import { action } from "@storybook/addon-actions";
import { type ComponentMeta, type ComponentStoryObj } from "@storybook/react";
import { ComponentPropsWithoutRef } from "react";
// default importすることにより、それComponent名を意識しなくて良い
// ただし、Componentはdefault exportが求められる
import Module from "./Sample";

type T = typeof Module;
type Meta = ComponentMeta<T> & { args: ComponentPropsWithoutRef<T> };
type Story = ComponentStoryObj<T>;

// この部分を自動生成する
const args: ComponentPropsWithoutRef<T> = {};

export const Default: Story = {};

export default {
  component: Module,
  args,
} as Meta;

また、同時に jest 用の Snapshot テストも生成しており、Storybook を利用したテストとして次のようなテストコードも生成しています。

/**
 * @jest-environment jsdom
 */
import { render } from "@hireroo/ui-theme/test";
import { composeStories } from "@storybook/testing-react";

import * as Stories from "./Sample.stories";

const { Default } = composeStories(Stories);

it("renders correctly", () => {
  const { asFragment } = render(<Default />);
  expect(asFragment()).toMatchSnapshot();
});

@hireroo/ui-theme/test@testling-library/react + Material UI の Theme Provider を Component 化したような状態で提供しています。

import * as TestingLibrary from "@testing-library/react";
import * as React from "react";

import { ThemeProvider } from "../theme";

export const render = (ui: React.ReactElement) => {
  return TestingLibrary.render(<ThemeProvider children={ui} />);
};

この実装の多くの部分は@masashi 氏の記事、Storybook を導入する際にやるべきこと 3 選をベースとしています。この場を借りて感謝します。ただし、いくつかカスタマイズした点があるのでそれについて触れます。

他の Storybook で定義した props を別の Storybook で読み取る

Component は少なからず階層構造を形成します。その際、Storybook で利用する props も同様に階層構造を持つことになり、Storybook 上での props の再利用をすることで DRY の原則に則ることができます。Storybook 用のファイルは default export 以外の export は Component として認識されるため、default export を必然的に拡張することになります。

このとき、default export を次のように型定義をキャストすることで、親コンポーネントでargsを props として参照できるように調整しています。

type Meta = ComponentMeta<T> & { args: ComponentPropsWithoutRef<T> };

export default {
  component: Module,
  args,
} as Meta;

自動生成のコードは Template literals で記述する

参考にし記事(Storybook を導入する際にやるべきこと 3 選)では Hygen を利用してコードの自動生成をしていますが、弊社ではTemplate literalを用いて自動生成用の実装を記述しています。理由としては

  • ライブラリを導入したときの独自テンプレート構文を覚えたくない
  • Template literal は普段から利用しているため誰でもメンテナンスできる

の 2 点です。例えば Storybook 用の自動生成コードは次のように書けます。引数は componentName(コンポーネント名)のみで、それ以外の部分はすべて自動生成の対象になります。

export type GenerateStoryArgs = {
  componentName: string;
};

export const generateStory = (args: GenerateStoryArgs): string => {
  const componentName = args.componentName;
  return [
    `import { action } from "@storybook/addon-actions";`,
    `import { type ComponentMeta, type ComponentStoryObj } from "@storybook/react";`,
    `import { ComponentPropsWithoutRef } from "react";`,
    ``,
    `import Module from "./${componentName}";`,
    ``,
    `type T = typeof Module;`,
    `type Meta = ComponentMeta<T> & { args: ComponentPropsWithoutRef<T> };`,
    `type Story = ComponentStoryObj<T>;`,
    ``,
    `const args: ComponentPropsWithoutRef<T> = {`,
    `  name: "Hello world! ${componentName}",`,
    `  onClick: action("Clicked!"),`,
    `};`,
    ``,
    `export const Default: Story = {};`,
    ``,
    `export default {`,
    `  component: Module,`,
    `  args,`,
    `} as Meta;`,
    "",
  ].join("\n");
};

上記のように、Template Literal で記述されており、JavaScript を書ける人であれば誰でもメンテナンスできます。

commander による CLI 化

実装された Generator は commander(https://github.com/tj/commander.js/ ) によって CLI 化しました。実行する際のコマンドは次のとおりです。

pnpm run ui-generator --output "./src/widget/sample" -n "Sample"

CLI の実装としては次の様になっています。

#!/usr/bin/env node --loader ts-node/esm --experimental-specifier-resolution=node

import { program } from "commander";

type CliArgs = {
  type?: "ui" | "store" | "container"; // ここでは紹介していないがUI以外も自動生成している
  name?: string;
  output?: string;
  namespace?: string;
  extra?: string;
};

program
  .name("generator-tools")
  .description("CLI to create ui component starter kit")
  .option("--type <char>", "please set generate type: ui, store, container")
  .option("-o, --output <char>", "please set output directory")
  .option("-n, --name <char>", "please set component name");

program.parse(process.argv);
// 以下、与えられた引数をgenerateする関数に放り込み、ファイルを出力する

補足情報として、上記のCliArgstypeフィールドを見てわかるように、UI 以外の部分も自動生成するようにしています。

[Tips] CLI を ts-node で実行する

tsc でコンパイルをするのが面倒なため、パッケージインストール時に指定される実行コマンドを Shebang でts-nodeが実行されるように調整しています。どういうことかというと、node_modules/.bin にインストールされたコマンド(今回は generator-tools という名前)を確認すると次のような結果を得られます。

cat cat node_modules/.bin/generator-toolsの出力結果

#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")

case `uname` in
    *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac

if [ -z "$NODE_PATH" ]; then
  export
NODE_PATH="/Users/himenon/CodeSpaces/hireroo/frontend/node_modules/.pnpm/node_modules"
else
export NODE_PATH="$NODE_PATH:/Users/himenon/CodeSpaces/hireroo/frontend/node_modules/.pnpm/node_modules"
fi
# ここから下が実際に実行されるコマンド
if [ -x "$basedir/node" ]; then
  exec "$basedir/node"  --loader ts-node/esm --experimental-specifier-resolution=node
"$basedir/../../../generator-tools/src/index.ts" "$@"
else
  exec node  --loader ts-node/esm --experimental-specifier-resolution=node "$basedir/../../../generator-tools/src/index.ts" "$@"
fi

また、tsc で js にビルドする手間が省けるだけでなく、モノレポ内なら ts-node が既に入っているため、改めて ts-node をインストールする必要がないところも利点です。

[Tips] スナップショットテストの React.lazy の対応

Tips として Snapshot テストにおける Suspense されるコンポーネントの対応について紹介します。Suspense されるコンポーネントをスナップショットテストとする場合はそのコンポーネントがマウントされるまで待機する必要があります。つまり、コンポーネントのレンダリングが 1 回で終わらず、Suspense 後の結果を Snapshot テストの対象とする必要があります。これを解決する方法は react-dom から公式に提供されています。

https://reactjs.org/docs/test-utils.html#act

例えば、弊社の事例では Markdown 用のコンポーネントを読み込む必要がありますが、これらは remakjs 系のライブラリを大量に読み込むため Suspense の対象となっています。これを snapshot テストするためには、actを利用して次のように記述します。

/**
 * @jest-environment jsdom
 */
import { render } from "@hireroo/ui-theme/test";
import { composeStories } from "@storybook/testing-react";
import { act } from "react-dom/test-utils";

import * as Stories from "./ProjectQuestion.stories";

const { Default } = composeStories(Stories);

it("renders correctly", async () => {
  const { asFragment } = render(<Default />);
  await act(async () => {
    await import("@hireroo/markdown/react");
  });
  expect(asFragment()).toMatchSnapshot();
});

これにより、Markdown のコンポーネントを Snapshot テストの対象に組み込むことができます。

まとめ

Storybok の責務範囲を

  • Presentation 層と Container 層を分離するために作業領域を制限する
  • DOM の Snapshot テストを実施しライブラリなどによる不本意な変更を検知する
  • Visual Regression Test のために利用する

と定め、props 以外の Storybook やテストコードを自動生成しました。これにより、開発者はサービスとして価値のある部分に多くの時間を割くことができる状態になりました。

明日の記事は@iwata による「Web フロントエンドの開発体験を向上させるために Firebase の preview channels を Circle CI から利用する」です!