概要
monorepo 環境で create-react-app --typescript したパッケージに対して、Storybookを導入した上で、「Story上にコンポーネントの型情報を表示する」「Storyshotsでレグレッションテストを行う」の2つを行えるようにする例の紹介です。
- monorepo+TypeScript環境の構築は、こちらの記事で行いました
- Storybook を導入して、コンポーネントカタログを表示
- react-docgen-typescript-loader でコンポーネントの型情報を表示
- Jest と Storyshots によって、コンポーネントのレグレッションテストを実行
動作環境
- 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 のテーマを設定
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 のアドオンを追加します;
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 から抜き出したものです。便利ですね!
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 を利用してマークアップしていきます;
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;
.content {
margin: 32px 0;
white-space: pre-wrap;
}
import { create } from '@storybook/theming';
import 'semantic-ui-css/semantic.min.css';
addParameters({ ... });
import logo from './logo.svg';
import 'semantic-ui-css/semantic.min.css';
import './App.css';
Storybook
ダミーデータを用意して、コンポーネントを表示させています;
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 を実行可能になります;
{
"scripts": {
"storybook": "yarn workspace client start-storybook"
}
}
$ yarn storybook
Storybook が起動し、以下のようなコンポーネントのカタログが表示されれば、セットアップは完了です;
Viewport アドオンのチェック
Viewport アドオンを有効化しましたので、下記のように iPhone 表示なども試すことができます;
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 を作成します;
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 で指定した型情報が表示されるようになります;
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 で記述した型情報が表示されていれば、今回の目標は達成です!
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 構成であることを伝えます
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 コマンドによって並行してテストされるようになります;
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 を、それぞれダミーデータに置き換えさせています
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 に対する変換オプションをここで指定できます;
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 の例に載っているモックファイル**をそのまま利用します。
module.exports = 'test-file-stub';
module.exports = {};
テスト実行
以上で Jest 実行環境が整いましたので、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 の初期化関数を追加します
module.exports = {
...
setupFiles: ['<rootDir>/.jest/setup.js'],
};
/src/client/.jest/setup.js
Storybook の初期化時に使用している require.context を Jest でも使用できるようにします(※参照文献)。babel-plugin-require-context-hook を使います;
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 つずつスナップショットファイルを生成するようにしています
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.
デグレ検知の実験
スナップショットテストが正しく動作しているかをチェックするために、簡単にデグレを起こしてみましょう;
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 のテストを行うファイルのみをマッチさせるように上書きします;
const baseConfig = require('./jest.config');
module.exports = {
...baseConfig,
testMatch: ['<rootDir>/**/test.storyshots.(js|jsx|ts|tsx)'],
};
/src/client/package.json
テストを実行するスクリプトを、ロジック・Storyshots に分割します。Storyshots のテストについては、さきほと作成した config ファイルを使用するように変更しました;
"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 経由で直接呼び出すように変更しました;
"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.
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 上に公開していますので、併せてご参照ください。
- https://github.com/suzukalight/monorepo-react-prisma2/pull/1
- https://github.com/suzukalight/monorepo-react-prisma2/pull/2
- https://github.com/suzukalight/monorepo-react-prisma2/pull/3