📖

あまりイケてないプロジェクトにstorybookを導入する

2022/12/14に公開

あまりイケてないプロジェクトにstorybookを導入する

こんにちは。皆さんはstorybook、導入していますか?
私が今携わっているプロジェクトでは、一次請けから引き継いだコードを触って責任を負いたくない!ということで導入するところまで説得できませんでした。
ただメインで実装している私がそろそろ案件から手離れすることもあり、一念発起し半ば強引に導入することにしました。

なので今回は既存の記事を参考にしつつ、

  1. storybookの導入の選定理由
  2. storybookの導入手順

を記録しておこうと思います。

前提

携わっているのは資産を読み取って表示するwebアプリケーションです。
フロントエンド側の主な役割は、ごく一般的なstate管理(チェックボックスやモーダルなど)・UIになります。
なので、今回storybookによるstatelessなUIテストを導入できれば、担保できる品質が大幅に高くなるのでは?と思い導入を検討しました。


主なパッケージ
react: ^16.13.0
react-scripts: ^5.0.1
tailwindcss: ^1.2.0
node-sass: ^8.0.0
webpack: 5.75.0


イケてない点

このプロジェクトはちょっちイケてないところが複数ありました。

例えば、

  • prettierやeslintが導入されているのに、前任者はほぼ無視していた
  • テストが存在せず、確認が全て手動
  • nodeバージョンが13.10.1でLTSでなく、依存関係解決に苦労する(一回私は断念した)

その他にも

  • 密結合低凝集
  • 副作用がたくさんある

等様々問題はありますが、全てを一気に変えることは難しいので、要件的に一番効果が大きいと思われるUI部分のテストを強化していきます!

施策

最終目標は、storybookを導入してstatelessに手動でもUIテストをできるようにすることです。

  • nodeのバージョンを最新にあげる
    • PL経由で1次請けに依頼
  • eslintやprettierなどのリファクタリングを終わらせる
  • storybookを導入する
    • プロジェクトの一番大きな関心ごとはデータをViewに変換することなので、UI部分の確認をより手軽に行いたい。

今回やらないこと

今回コストの観点から、

  • Visual Regression Testing
  • スナップショットテスト

を見送ろうと思います。
特にスナップショットテストにおいてはUIを一から実装し直すことが多いので、テストの寿命が短くその割にコストがかかるなあと判断しました。

具体的な手順

  1. nodeを18.12.1へアップグレード(2022-12時点で18.12.1)

  2. eslintとprettierを適用

nodeをアップグレードするまではなぜかeslintやprettierを無視できていたので、アップグレードと同時に全てのファイルにprettier/eslintを適用しました。

  1. プロジェクトにstorybookを追加

npx storybook init

Create React Appを利用しているので、それに適した設定がインストールされます。

  1. @storybook/preset-create-react-appのバージョンを更新

既存のwebpack configurationのままだとschemaが違う!と怒られたので、最新のバージョンへアップグレードしました。[1]

  1. Create React App + tailwind と、storybookのcssのビルドのタイミングを調整する

本プロジェクトではtailwindを使っており、storybookにスタイルを適用する必要があります。
なので、tailwind-cssをstorybookで読み込むを参考に

  1. @storybook/addon-postcssの
  2. preview.jsにてビルドされたcssの読み込み

を実行したところ、

ModuleBuildError: Module build failed (from node_modules/postcss-loader/dist/cjs.js):
Syntax Error

(1:1) /App.css Unknown word

> 1 | var api = require("!../node_modules/...
    | ^

このようなエラーが出ました。

storybookでApp.cssをimportした時に、すでにcssからjavascriptへと処理されている文字列を再度cssとして読み込もうとした事によるエラーのようです。

紆余曲折あり、@storybook/addon-postcssにまさになissueを発見しました。

要約すると、CRA + tailwindcssにおけるcssビルドのタイミングと、storybook(main.jsやpreview.js)がcssを読み込むタイミングがずれていることが原因のようです。
今回は案件に合わせて、以下のように修正しました。
私の案件ではpostcssやsassはyarn scriptのタイミングですでにcssにコンパイル済みなので、それによってissueと異なる対応をしていると思います。

// package.json
{
	"devDependencies: {
		...
		"postcss": "^8.4.20",
		"postcss-loader": "^7.0.2", // 最新のpostcss-loaderを明示的に利用する
	}
}
// .storybook/main.js
const path = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  stories: [
    path.resolve(__dirname, '../src/**/*.stories.mdx'),
    path.resolve(__dirname, '../src/**/*.stories.@(js|jsx|ts|tsx)'),
  ],

  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/preset-create-react-app',

    // @storybook/addon-postcssを利用すると、storybookですでにCRA側でJSへビルド済みのcssを再度style-loaderで埋め込もうとしてしまう
    // ちなみに、style-loaderというのはcssをHTML **DOM**に埋め込むためのwebpack loaderのこと。
    //
    //{
    //  name: '@storybook/addon-postcss',
    //  options: {
    //    postcssLoaderOptions: {
    //      implementation: require('postcss'),
    //    },
    //  },
    //},
  ],
  framework: '@storybook/react',
  core: {
    builder: 'webpack5',
  },

  // webpackのresolveを修正して、タイミングを調整する。
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.css$/,
      use: [
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: [require('tailwindcss'), require('autoprefixer')],
            },
          },
        },
      ],
      include: path.resolve(__dirname, '../'),
    });
    config.resolve.plugins = [
      ...(config.resolve.plugins || []),
      new TsconfigPathsPlugin({
        extensions: config.resolve.extensions,
      }),
    ];
    return config;
  },
};
// .storybook/preview.js
import '../src/App.css'; // この案件では、yarn scriptでsass/postcss等を実行しており、生成済みのcssが存在します。
import '../public/fonts/style.css'; // アイコン用css
...

  1. hygenでコンポーネントを自動生成する

こちらの記事に従って、

  1. hygenによるコンポーネントの雛形を作成
  2. 対話形式でコンポーネントを生成するための設定ファイル(prompt.js)の追加
  3. yarn scriptの追加

をしていきます。

コンポーネントの雛形

コンポーネントの雛形として用意するものはこちらです。

  • ComponentPresenter.tsx
  • ComponentContainer.tsx
  • index.ts
  • Component.stories.tsx

複数の状態におけるstoryを簡単に作れるように、明示的にContainerとPresenterを分離する方針を採用しました。

hygenでは、コードを生成するコマンドと**_templates以下のディレクトリ構造**が一対一になります。
例えば、_templates/do/something/以下に*.tとなるテンプレートを置いた場合、hygen do somethingというコマンドでコードを生成することができます。
今回はhygen component addにてコンポーネントを追加させようと思います。

テンプレートではejs記法と冒頭のfrontmatterを利用することができます。
frontmatterとは、ファイル先頭に2つの区切り線(---)で区切った部分にyaml形式で書かれたメタ情報のことです。
こちらにfrontmatterに記述できるメタ情報の一覧があります。今回はto属性しか利用していないので割愛させていただきます。

// _templates/component/add/Presenter.tsx.t
---
to: <%= directory %>/<%= name %>/<%= name %>Presenter.tsx
---
import React from 'react';

export type <%= name %>PresenterProps = {
};

export const <%= name %>Presenter = (props: <%= name %>PresenterProps): React.ReactElement => {
  return <></>;
};
// _templates/component/add/Container.tsx.t
---
to: <%= directory %>/<%= name %>/<%= name %>Container.tsx
---
import React from 'react';

import { <%= name %>Presenter } from './<%= name %>Presenter';

export type <%= name %>ContainerProps = {
};

export const <%= name %>Container = (props: <%= name %>ContainerProps): React.ReactElement => {
  return <><<%= name %>Presenter {...{}} /></>
};
// _templates/component/add/index.ts.t
---
to: <%= directory %>/<%= name %>/index.ts
---
import { <%= name %>Container } from './<%= name %>Container';

export const <%= name %> = <%= name %>Container;
// _templates/component/add/stories.tsx.t
---
to: <%= directory %>/<%= name %>/<%= name %>.stories.tsx
---
import React from 'react';

import { ComponentMeta, ComponentStory } from '@storybook/react';

import { <%= name %>Presenter } from './<%= name %>Presenter';

export default {
  title: '<%= storyLevel %>/<%= name %>',
  component: <%= name %>Presenter,
} as ComponentMeta<typeof <%= name %>Presenter>;

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

export const <%= name %> = Template.bind({});
<%= name %>.args = {};

prompt.jsの追加

hygenでテンプレートの変数に値を代入する方法は2つ存在します。

  1. hygenのコマンドを叩く際に、--<変数名> <値>というオプションをつける
    • hygen do something --a 3 --name neco8
  2. prompt.jsで対話的に代入する

今回、同じプロジェクトに参画している人たちにコンポーネント生成の知識を要求したくないので、対話的な方法を採用します。

hygenのpromptはバックエンドとしてenquirerを利用しています。今回利用しているinput以外にも様々なform/inputが存在しますので、確認してみると大変楽しいです。

今後の課題として、directoryの入力に補完を使うことができないので、custom inputを作成したいな……。shellみたいに。

// _templates/component/add/prompt.js
module.exports = [
  {
    message: 'コンポーネントの名前は?(UpperCamelCase)',
    name: 'name',
    type: 'input',
    validate: answer => {
      if (answer === '') return false;
      return Boolean(answer.match(/[A-Z][a-zA-Z]*/));
    },
  },
  {
    message: 'コンポーネントを追加するディレクトリは?',
    type: 'input',
    name: 'directory',
  },
  {
    message: 'ストーリーの階層(≠コンポーネントのフォルダ)は?',
    name: 'storyLevel',
    type: 'input',
    validate: answer => {
      return Boolean(answer.match(/[a-zA-Z][a-zA-Z/]*[a-zA-Z]/));
    },
  },
];

yarn scriptの追加

コンポーネントを生成する際に、hygenを使っているという知識すら要求したくないので、yarn scriptを追加しておきます。

// package.json
{
  ...
  "scripts": {
    ...
    "component-add": "yarn hygen component add"
  }
...
}

終わりに

今回はだいぶ人権に近くなっている(?)storybookを導入しました。導入するしないはそれぞれの判断ですが、メンテナンスコストをけずることを意識すれば大半のプロジェクトのUIテストにおいて高い生産性をもたらしてくれますよね。
今後は隙を見てテストを生成したり、storyの追加拡充を徐々に進めていきたいと思います。

脚注
  1. Using default Webpack5 setup ERR! ValidationError: Invalid configuration object. ... ↩︎

Discussion