monorepo 環境で、create-react-app w/ TypeScript したパッケージに対して、Jest のテストと、Storyshots による Storybook ベースのスナップショットテスティングを導入する例です。
まえがき
完成品
実装内容をプルリクエストにしたものを、GitHub 上に公開していますので、併せてご参照ください。
- https://github.com/suzukalight/monorepo-react-prisma2/pull/2
- https://github.com/suzukalight/monorepo-react-prisma2/pull/3
動作環境
- Mac
- Node.js v10.16.0 / npm v6.9.0 / yarn v1.16.0
- create-react-app (react-script v3.1.1)
- TypeScript v3.5.3
- Storybook v5.1
- Jest v24.9
- Babel v7
Jest のセットアップ
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/*'],
};
client プロジェクトのセットアップ
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
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 上に公開していますので、併せてご参照ください。