15
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

monorepo+TypeScript環境でStorybook+Storyshotsを動かす

Posted at

storybook-typescript-docgen.png

run-storyshots.png

概要

monorepo 環境で create-react-app --typescript したパッケージに対して、Storybookを導入した上で、「Story上にコンポーネントの型情報を表示する」「Storyshotsでレグレッションテストを行う」の2つを行えるようにする例の紹介です。

動作環境

  • Mac
  • Node.js v10.16.0 / npm v6.9.0
  • create-react-app (react-script v3.1.1)
  • TypeScript v3.5.3
  • Storybook v5.1
  • @storybook/addon-viewport
  • react-docgen-typescript-loader
  • Jest v24.9
  • Babel v7

Storybook 本体のセットアップ

yarn install

  • Storybook w/React, Addons(viewport) をインストール
  • 型情報もインストール
$ yarn workspace client add -D @storybook/react @storybook/theming @storybook/addons @storybook/addon-viewport @types/storybook__react @types/storybook__addon-actions

config

./.storybook/config.js

子パッケージの ./.storybook/config.js に設定を記述します。

  • /.stories.tsx?$/ にマッチするファイルすべてを対象にするように設定
  • Storybook のテーマを設定
/src/client/.storybook/config.js
import { configure, addParameters } from '@storybook/react';
import { create } from '@storybook/theming';

addParameters({
  options: {
    theme: create({
      base: 'light',
      brandTitle: 'Blog',
    }),
  },
});

// automatically import all files ending in *.stories.js
const req = require.context('../src/components', true, /.stories.tsx?$/);
function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);

./.storybook/addons.js

viewport のアドオンを追加します;

/src/client/.storybook/addons.js
import '@storybook/addon-viewport/register';

React Component を記述

コンポーネント記述に使用する、各種のライブラリをインストールします;

  • node-sass: CSS Modules に SASS を使用
  • semantic-ui-react, semantic-ui-css: CSS フレームワーク
  • date-fns: 日付管理ライブラリ
$ yarn workspace client add node-sass semantic-ui-react semantic-ui-css date-fns

型情報

Prisma2 が自動生成した src/server/generated/nexus-typegen.ts から抜き出したものです。便利ですね!

/src/client/src/types/data.d.ts
export interface User {
  email: string; // String!
  id: string; // ID!
  name: string | null; // String
}

export interface Post {
  author: User | null; // User
  content: string | null; // String
  createdAt: any; // DateTime!
  id: string; // ID!
  published: boolean; // Boolean!
  title: string; // String!
  updatedAt: any; // DateTime!
}

コンポーネント

semantic-ui-react と date-fns を利用してマークアップしていきます;

/src/client/src/components/organisms/BlogPost/index.tsx
import React from 'react';
import format from 'date-fns/format';
import { Container, Header, Message, Icon } from 'semantic-ui-react';

import { User, Post } from '../../../types/data';

import styles from './index.module.scss';

interface AuthorPresenterProps {
  author: User | null;
}

export const Author = ({ author }: AuthorPresenterProps) =>
  author && (
    <Message icon>
      <Icon name="user" size="large" />
      <Message.Content>
        <Message.Header>{author.name}</Message.Header>
        {author.email}
      </Message.Content>
    </Message>
  );

export interface BlogPostPresenterProps {
  post: Post;
}

export const BlogPost = ({ post }: BlogPostPresenterProps) => (
  <Container>
    <Header as="h1">{post.title}</Header>
    <small>{`created at: ${format(post.createdAt, 'yyyy/MM/dd')}`}</small>
    <article className={styles.content}>{post.content}</article>
    <Author author={post.author} />
  </Container>
);

export default BlogPost;
/src/client/src/components/organisms/BlogPost/index.module.scss
.content {
  margin: 32px 0;
  white-space: pre-wrap;
}
/src/client/.storybook/config.js
import { create } from '@storybook/theming';

import 'semantic-ui-css/semantic.min.css';

addParameters({ ... });
/src/client/src/App.tsx
import logo from './logo.svg';

import 'semantic-ui-css/semantic.min.css';

import './App.css';

Storybook

ダミーデータを用意して、コンポーネントを表示させています;

/src/client/src/components/organisms/RaceListSmall/storybook/index.stories.tsx
import React from 'react';
import { storiesOf } from '@storybook/react';

import { Post } from '../../../../types/data';

import BlogPost from '..';

const post: Post = {
  id: '1',
  published: true,
  title: 'テスト投稿1',
  createdAt: new Date(2019, 7, 31, 18, 0, 0),
  updatedAt: new Date(2019, 7, 31, 18, 0, 0),
  author: {
    id: '1',
    name: 'テスト太郎',
    email: 'test@example.com',
  },
  content: `
テスト1a
テスト1b

テスト2a`,
};

storiesOf('organisms/BlogPost', module).add('BlogPost', () => <BlogPost post={post} />);

yarn storybook コマンドで起動させる

package.json に Storybook の起動コマンドを書いておきます。yarn workspace [ws-name] [command] 構文が利用できます。これで yarn storybook というシンプルなコマンドで、Storybook を実行可能になります;

/package.json
{
  "scripts": {
    "storybook": "yarn workspace client start-storybook"
  }
}
$ yarn storybook

Storybook が起動し、以下のようなコンポーネントのカタログが表示されれば、セットアップは完了です;

storybook.png

Viewport アドオンのチェック

Viewport アドオンを有効化しましたので、下記のように iPhone 表示なども試すことができます;

storybook-viewport.png

react-docgen-typescript-loader アドオン

@storybook/addon-info アドオンは、Story 表示に「タイトル」「ソース」「PropTypes」を追加表示してくれる、便利な情報表示アドオンです。

react-docgen-typescript-loader を使うと、この Prop Types 部分に TypeScript の型情報を表示させることができるようになります。

yarn install

上記 2 点と、addon-info の types をインストールします;

$ yarn workspace client add -D @storybook/addon-info react-docgen-typescript-loader @types/storybook__addon-info

webpack.config.js

docgen を有効化するために、Storybook 用のカスタム Config を作成します;

/src/client/.storybook/webpack.config.js
module.exports = ({ config }) => {
  config.module.rules.push({
    test: /\.(ts|tsx)$/,
    use: [
      {
        loader: require.resolve('react-docgen-typescript-loader'),
      },
    ],
  });
  config.resolve.extensions.push('.ts', '.tsx');
  return config;
};

index.stories.jsx

withInfo をすることで、Story にコンポーネントに関する情報を表示させることができるようになります。このなかに「Prop Types」ブロックがあるのですが、ここが react-docgen-typescript-loader によって拡張され、TypeScript で指定した型情報が表示されるようになります;

/src/client/src/components/organisms/BlogPost/__stories__/index.stories.tsx
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withInfo } from '@storybook/addon-info';

...

storiesOf('organisms/BlogPost', module)
  .addDecorator(withInfo({ inline: true }))
  .add('BlogPost', () => <BlogPost post={post} />);

型情報の表示

上記のように Storybook を更新すると、以下のようなコンポーネント情報が表示され、PropTypes 部分に TypeScript で記述した型情報が表示されていれば、今回の目標は達成です!

storybook-typescript-docgen.png

Jest のセットアップ

続いて、Jest のテストと、Storyshots による Storybook ベースのスナップショットテスティングを導入する例です。

yarn install

  • Jest のインストール
  • Babel および presets のインストール(react, typescript)
  • 型情報もインストール
$ yarn add -DW @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript babel-jest babel-plugin-require-context-hook jest react-test-renderer @types/jest

global config

/babel.config.js

  • presets: React(JSX)と TypeScript を変換するように指定します
  • babelrcRoots: Jest で実行する Babel に対して、monorepo 構成であることを伝えます
/babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
    '@babel/preset-react',
    '@babel/preset-typescript',
  ],
  babelrcRoots: ['src/*'],
};

/jest.config.js

**projects オプション**を指定して、Jest が複数のプロジェクトを独立して扱えるようにします。<rootDir>/[packages-dir]/* を指定すると、packages-dir 配下のすべてのプロジェクトがテスト対象となり、Jest コマンドによって並行してテストされるようになります;

/jest.config.js
module.exports = {
  projects: ['<rootDir>/src/*'],
};

テスト対象プロジェクトへの設定

create-react-app 環境には、デフォルトで src/App.test.tsx がありますので、まずはこのテストが通るように環境を整えます。

/src/client/jest.config.js

  • displayName: テスト実行中に、ラベルとして console に表示してくれます
  • moduleFileExtensions: テスト対象の拡張子を指定します
  • transform: Babel などの変換プロセスを指定します。JS ファイルを指定して、より細かい変換動作を指定できます
  • testMatch: テスト対象のファイル名を正規表現で指定します
  • moduleNameMapper: import などで指定したファイルが、テストにおいて邪魔になる場合、それを別のモジュールに置き換えることができる設定です。assets と styles を、それぞれダミーデータに置き換えさせています
/src/client/jest.config.js
module.exports = {
  name: 'client',
  displayName: 'client',
  verbose: true,
  moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'],
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/.jest/transform.js',
  },
  testMatch: ['<rootDir>/**/?(*.)(spec|test).(ts|js)?(x)'],
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
      '<rootDir>/.jest/__mocks__/file.js',
    '\\.(styl|css|less|scss)$': '<rootDir>/.jest/__mocks__/style.js',
  },
};

/src/client/.jest/transform.js

Jest の設定ファイルで指定した、変換動作を行う関数です。ここでは babel-jest で JS ファイルを変換します。Babel に対する変換オプションをここで指定できます;

/src/client/.jest/transform.js
module.exports = require('babel-jest').createTransformer({
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
    '@babel/preset-react',
    '@babel/preset-typescript',
  ],
  plugins: ['require-context-hook', '@babel/plugin-transform-modules-commonjs'],
});

Mocks

**Jest の例に載っているモックファイル**をそのまま利用します。

/src/client/.jest/__mocks__/file.js
module.exports = 'test-file-stub';
/src/client/.jest/__mocks__/style.js
module.exports = {};

テスト実行

以上で Jest 実行環境が整いましたので、package.json にテストコマンドを追加して、実行してみます;

/package.json
  "scripts": {
    "test": "NODE_ENV=test jest"
  }
$ yarn test

yarn run v1.16.0
$ NODE_ENV=test jest
 PASS   client  src/client/src/App.test.tsx
  ✓ renders without crashing (20ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.645s, estimated 2s
Ran all test suites.
✨  Done in 2.76s.

Storyshots のセットアップ

Storyshots は、Storybook で記述した Story をテストファイルと見立てて、そのレンダリング結果をスナップショットとして保存してくれるテスティングツールです。保存済みのスナップショットと、実行時のスナップショットが異なっている場合に、テストを Fail にしてくれます。コンポーネントの意図しないデグレを検知することができるようになります。

yarn install

  • @storybook/addon-storyshots
  • require.context を解決できる Babel plugin
  • 型情報
$ yarn add -DW @storybook/addon-storyshots react-test-renderer babel-plugin-require-context-hook @types/storybook__addon-storyshots

/src/client/jest.config.js

  • setupFiles: Jest の初期化関数を追加します
/src/client/jest.config.js
module.exports = {
  ...
  setupFiles: ['<rootDir>/.jest/setup.js'],
};

/src/client/.jest/setup.js

Storybook の初期化時に使用している require.context を Jest でも使用できるようにします(※参照文献)。babel-plugin-require-context-hook を使います;

/src/client/.jest/setup.js
const registerRequireContextHook = require('babel-plugin-require-context-hook/register');
registerRequireContextHook();

/src/client/src/components/storyshots.test.js

Jest が Storyshots の起点とするテストファイルで、ここで Storyshots の初期化を行います。

  • configPath: Storybook の config を指定
  • test: どのようなスナップショットを出力するかを指定します。ここでは multiSnapshotWithOptions を指定することで、コンポーネントごとに 1 つずつスナップショットファイルを生成するようにしています
/src/client/src/components/storyshots.test.js
import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots'; // eslint-disable-line import/no-extraneous-dependencies
import path from 'path';

initStoryshots({
  configPath: path.resolve(__dirname, '../../.storybook/config.js'),
  test: multiSnapshotWithOptions({}),
});

yarn test

では実際に Storyshots を実行してみます。成功すると、スナップショットファイルが 1 つ生成されます;

$ yarn test

 PASS   client  src/client/src/components/storyshots.test.ts
 › 1 snapshot written.
 PASS   client  src/client/src/App.test.tsx

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 written, 1 total
Time:        4.32s
Ran all test suites.
✨  Done in 5.44s.

デグレ検知の実験

スナップショットテストが正しく動作しているかをチェックするために、簡単にデグレを起こしてみましょう;

/src/client/src/components/organisms/BlogPost/index.tsx
export const BlogPost = ({ post }: BlogPostPresenterProps) => (
  <Container>
    <Header as="h1">{post.title}</Header>
    <small>{`created at: ${format(post.createdAt, 'yyyy/MM/dd')}`}</small>
    <article className={styles.content}>{post.content}</article>
    <Author author={post.author} />
    <p>デグレ検知</p>
  </Container>
)

Storyshots を実行してみます。デグレを起こした部分が表示され、テストが正しく Fail します;

$ yarn test

 PASS   client  src/client/src/App.test.tsx
 FAIL   client  src/client/src/components/storyshots.test.ts
  ● Storyshots › organisms/BlogPost › BlogPost

    expect(received).toMatchSnapshot()

    Snapshot name: `Storyshots organisms/BlogPost BlogPost 1`

    - Snapshot
    + Received

    @@ -91,10 +91,13 @@
                  テスト太郎
                </div>
                test@example.com
              </div>
            </div>
    +       <p>
    +         デグレ検知
    +       </p>
          </div>
        </div>
        <div>
          <div
            style={

      at match (../../node_modules/@storybook/addon-storyshots/dist/test-bodies.js:27:20)
      at ../../node_modules/@storybook/addon-storyshots/dist/test-bodies.js:39:10
      at Object.<anonymous> (../../node_modules/@storybook/addon-storyshots/dist/api/snapshotsTestsTemplate.js:42:33)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or run `yarn test -u` to update them.

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   1 failed, 1 total
Time:        4.289s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

わざとデグレを起こした部分を削除して保存し、改めて Storyshots を実行してみます。そうすると今回はテストが正常に終了します。これで導入成功です!

$ yarn test

 PASS   client  src/client/src/App.test.tsx
 PASS   client  src/client/src/components/storyshots.test.ts

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 passed, 1 total
Time:        3.131s
Ran all test suites.
✨  Done in 3.94s.

ロジックと Storyshots のテストを分離

現状のままだと、ロジックのテストと、Storyshots のテストが同時に走っています。コンポーネントのみのテストを行うために、Storyshots のテストを分離してみます。

/src/client/src/components/test.storyshots.ts

Storyshots のテストを行うファイルを、通常のテストファイルのパターンから外れるようにリネームします。今回は storyshots.test.ts→test.storyshots.ts に変更しました

/src/client/jest.config.storyshots.js

通常の jest.config.js から、Storyshots のテストを行うための設定を分離します。具体的には testMatch を、Storyshots のテストを行うファイルのみをマッチさせるように上書きします;

/src/client/jest.config.storyshots.js
const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,
  testMatch: ['<rootDir>/**/test.storyshots.(js|jsx|ts|tsx)'],
};

/src/client/package.json

テストを実行するスクリプトを、ロジック・Storyshots に分割します。Storyshots のテストについては、さきほと作成した config ファイルを使用するように変更しました;

/src/client/package.json
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "eject": "react-scripts eject",
    "storybook": "start-storybook",
    "test": "NODE_ENV=test jest",
    "storyshots": "NODE_ENV=test jest --config ./jest.config.storyshots.js"
  },

/package.json

ルートの package.json も変更します。storyshots を実行するコマンドを、yarn workspace 経由で直接呼び出すように変更しました;

/package.json
  "scripts": {
    "cl:start": "yarn workspace client start",
    "sr:start": "yarn workspace server start",
    "lint": "yarn cl:lint && yarn sr:lint",
    "cl:lint": "eslint --fix --ext .jsx,.js,.tsx,.ts ./src/client/src",
    "sr:lint": "eslint --fix --ext .jsx,.js,.tsx,.ts ./src/server/src",
    "storybook": "yarn workspace client storybook",
    "test": "NODE_ENV=test jest",
    "storyshots": "yarn workspace client storyshots"
  },

実行

実行してみます。無事に成功しました!

$ yarn storyshots

$ yarn workspace client storyshots
$ NODE_ENV=test jest --config ./jest.config.storyshots.js
 PASS   client  src/components/test.storyshots.ts
  Storyshots
    organisms/BlogPost
      ✓ BlogPost (37ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        3.127s
Ran all test suites.
✨  Done in 4.46s.

run-storyshots.png

NOTE

現在の Config 設定を表示する

設定がうまく反映されているか自信がない場合、Jest の Config を表示させて確認してみると良いです;

$ jest --showConfig

projects に指定した client と server の設定が configs の配列となって格納されています。それ以外にも global の config や version 情報などが表示されます;

{
  "configs": [
    {
      "cwd": "/Users/suzukalight/work/monorepo-react-prisma2",
      "displayName": "client",
      "moduleFileExtensions": ["js", "json", "jsx", "ts", "tsx", "node"],
      "moduleNameMapper": [
        [
          "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$",
          "/Users/suzukalight/work/monorepo-react-prisma2/src/client/.jest/__mocks__/file.js"
        ],
        [
          "\\.(styl|css|less|scss)$",
          "/Users/suzukalight/work/monorepo-react-prisma2/src/client/.jest/__mocks__/style.js"
        ]
      ],
      "rootDir": "/Users/suzukalight/work/monorepo-react-prisma2/src/client",
      "transform": [
        [
          "^.+\\.(js|jsx|ts|tsx)$",
          "/Users/suzukalight/work/monorepo-react-prisma2/src/client/.jest/transform.js"
        ]
      ],
    }
    {
      "rootDir": "/Users/suzukalight/work/monorepo-react-prisma2/src/server",
    }
  ],
  "globalConfig": {
    "projects": [
      "/Users/suzukalight/work/monorepo-react-prisma2/src/client",
      "/Users/suzukalight/work/monorepo-react-prisma2/src/server"
    ],
    "rootDir": "/Users/suzukalight/work/monorepo-react-prisma2",
  },
  "version": "24.9.0"
}

キャッシュをクリアする

Jest のキャッシュをクリアするには、--clearCache オプションを指定して Jest を実行します;

$ jest --clearCache

/src/client/.babelrc.js を置いてはダメなの?

/src/client/.babelrc.js を置いた場合、Storybook の Webpack も、この RC ファイルを読みに来ます。ここで Jest と Storybook の設定がバッティングしてしまい、うまく動作させられませんでした。今回は babel-jest の createTransformer で設定を分けることができたので、それで対処しています。

完成品

実装内容をプルリクエストにしたものを、GitHub 上に公開していますので、併せてご参照ください。

References

15
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?