Wed, Nov 7, 2018

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その10: Code Splitting、Flow、Jest、Enzyme

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その10: Code Splitting、Flow、Jest、Enzyme

ReactReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)

前回React Routerをセットアップした。

今回は残りの要素をまとめてかたづける。

Code Splitting

webpackでリソースをバンドルすると、一回の通信でアプリの要素全てをロードできるので効率いいような気がするけど、アプリの規模が大きくなってくるとバンドルサイズが大きくなって、初期ロード時間が長くなり、つまり初期画面の表示に時間がかかるようになってしまう。 そもそも、いつもアプリの全画面をみるとは限らないので、いつもアプリの全要素をロードするのは無駄。

そんな問題に対応する技術がCode Splitting。 バンドルを分割し、(理想的には)必要な時に必要な分だけロードする技術。

Code Splittingのやりかたはいくつかあるが、webpackのディレクティブを使ったプリフェッチを、フォントモジュールに適用してみる。

src/index.jsx:

 import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
 import { ConnectedRouter } from 'connected-react-router';
 import App from './components/App';
 import configureStore from './configureStore';
 import configureStore, { history } from './configureStore';
-import './fonts.css';
+import(/* webpackPrefetch: true */ './fonts');

 const store = configureStore();
 const root = document.getElementById('root');

 if (root) {
   ReactDOM.render(
     <Provider store={store}>
       <ConnectedRouter history={history}>
         <App />
       </ConnectedRouter>
     </Provider>,
     root,
   );
 }

コード変更はこれだけ。

import()ダイナミックインポートという、ECMAScriptで現在策定中の機能。 これを使えるようにするためには、Babelのプラグインを追加する必要がある。

yarn add -D babel-plugin-syntax-dynamic-import

.babelrc

 {
   "presets": ["env", "react"],
-  "plugins": ["styled-components"]
+  "plugins": ["styled-components", "syntax-dynamic-import"]
 }

ダイナミックインポートの設定も完了。 これでフォントモジュールはメインのバンドルとは別ファイルになり、初期画面の表示時にはロードされず、ブラウザの空き時間に非同期にロードされるようになる。


Code SplittingはReactのドキュメントでも紹介されていて、そこにはReact特有のやり方も載っている。 React.lazySuspenseを使うものがかなりナウい。

Flow

Reactに限らない話だけど、JavaScriptは動的型付け言語なので、特に規模が大き目なアプリを開発するとなると保守性が悪くなりがちで辛い。 ので、できれば静的型付けでやりたい。

JavaScriptを静的型付けにするには、TypeScriptFlowという二つの選択肢がある。 今回、FlowがReactと同じくFacebook製なので、Reactと相性がいいかと思ってFlowを選択したけど、人気やエコシステムの充実度から見るとTypeScriptのほうがよかった気がする。 ので、Flowについてはさらっと書く。

Flow導入

Flowは、ソースに型情報を付けて静的型チェック可能にしつつ、実行時には型情報を取り去って普通のJavaScriptとして実行できるようにする仕組み。

型チェックするツールはflow-binパッケージで配布されていて、型情報の除去はbabel-preset-flowを使ってBabelでできる。 babel-preset-flowは、すでにインストールしたbabel-preset-reactに含まれてるので、敢えて入れる必要はない。

yarn add -D flow-bin

これで、yarn flowでFlowを実行できるようになった。

$ yarn flow version
yarn run v1.7.0
$ C:\Users\kaitoy\Desktop\bin\pleiades\workspace\react-redux-scaffold\node_modules\.bin\flow version
Flow, a static type checker for JavaScript, version 0.77.0
Done in 0.38s.


で、yarn flow initでFlowの設定ファイル.flowconfigを生成して、型チェックしたいファイルの頭に// @flowと書けばとりあえず機能する。

Flowの型アノテーション

それだけでもだいぶ型推論してくれてチェックが利くけど、型アノテーションを書いていくとよりいい。 ただ、アノテートするとESLintとけんかするので、それ対策としてeslint-plugin-flowtypeを入れる必要がある。

yarn add -D babel-eslint eslint-plugin-flowtype

.eslintrc.js:

 module.exports = {
   env: {
     browser: true,
   },
+  parser: 'babel-eslint',
-  extends: ['airbnb', 'prettier'],
+  extends: ['airbnb', 'plugin:flowtype/recommended', 'prettier'],
+  plugins: ['flowtype'],
 };


例として、Reactコンポーネントのpropsに型を付けてみる。

// @flow

import React from 'react';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import PropTypes from 'prop-types';

// Propsという型の定義
// text(string型)とopen(boolean型)というプロパティを持つオブジェクト
type Props = {
  text: string,
  open: boolean,
};

// Props型を受け取る関数
const MyDialog = ({ text, open }: Props) => (
  <Dialog open={open}>
    <DialogTitle>{text}</DialogTitle>
  </Dialog>
);

MyDialog.propTypes = {
  text: PropTypes.string.isRequired,
  open: PropTypes.bool.isRequired,
};

export default MyDialog;

こんな感じで型を付けておくと、MyDialogに渡すpropsを間違った場合にFlowがエラーにしてくれる。 prop-typesによる型定義と冗長な感じに見えるけど、Flowは静的に型チェックするのに対し、prop-typesはアプリの動作中に型チェックしてくれるので、両方書いておくのがよさそう。 (Flowの型定義からprop-typesの定義を生成してくれるbabel-plugin-react-flow-props-to-prop-typesというのがあるけど、サポートされていない型があるし、メンテされていないし、微妙。)


上のコードで、typeというキーワードで型を定義しているんだけど、Reactとかの3rdパーティライブラリの型情報(libdefと呼ばれるもの)は、ライブラリ開発者などが作ったものが公開されていて、インストールして利用できる。

libdefはそれようのリポジトリで管理されていて、flow-typedで引っ張れる。

yarn add -D flow-typed
yarn flow-typed --ignoreDeps dev install

これで、package.jsonに書かれている依存(devDependenciesを除く)を見て、必要なlibdefをダウンロードしてきて、プロジェクトルートのflow-typedというディレクトリにインストールしてくれる。

例えばさっきのReactコンポーネントのコードに、ReactのAPIの型の一つであるNodeを書くと以下のようになる。

 // @flow

 import React from 'react';
+import type { Node } from 'react';
 import Dialog from '@material-ui/core/Dialog';
 import DialogTitle from '@material-ui/core/DialogTitle';
 import PropTypes from 'prop-types';

 // Propsという型の定義
 // text(string型)とopen(boolean型)というプロパティを持つオブジェクト
 type Props = {
   text: string,
   open: boolean,
 };

 // Props型を受け取る関数
-const MyDialog = ({ text, open }: Props) => (
+const MyDialog = ({ text, open }: Props): Node => (
   <Dialog open={open}>
     <DialogTitle>{text}</DialogTitle>
   </Dialog>
 );

 MyDialog.propTypes = {
   text: PropTypes.string.isRequired,
   open: PropTypes.bool.isRequired,
 };

 export default MyDialog;

因みにflow-typedディレクトリの中身はコミットすることが推奨されている。 なんか違和感あるんだけど…

Jest

Reactプロジェクトでユニットテストを書くなら、Jest一択でいいっぽい。 JestもReactと開発元が同じFacebookで、Reactと相性がいいはずだし、Reactプロジェクト以外でもJestは人気。

ゼロ設定で使えるように作られていて、導入の敷居が低いのが特徴。 また多機能で、アサーション、モック、カバレージ測定辺りは組み込まれていてすぐ使える。

もともとは(今も?)Jasmineベースで、APIが似た感じなので、JasmineとかMochaに慣れた人には特に使いやすい。

Jestインストール

ReactプロジェクトでJestを使うには以下のパッケージを入れる。

yarn add -D jest babel-jest react-test-renderer

Jestはv23.4.2が入った。


npm scriptにjestを追加しておくといい。

package.json:

 (前略)
   "scripts": {
     "format": "prettier --write **/*.jsx **/*.js **/*.css",
     "build": "webpack --config webpack.prod.js",
+    "test": "jest",
     "start": "webpack-dev-server --hot --config webpack.dev.js"
   },
 (後略)

Jestセットアップ

Jestの設定ファイルであるjest.config.jsをプロジェクトルートに生成する。

yarn test --init

プロンプトでいくつかのことを聞かれるが、「Choose the test environment that will be used for testing」にjsdomで答えるのがポイント。ブラウザで動かすアプリなので。

設定ファイルはとりあえずおおむね生成されたままでいいけど、一点、v23.4.2時点では、テスト実行時に「SecurityError: localStorage is not available for opaque origins」というエラーが出る問題があるので、testURLを「http://localhost/」に設定しておく必要がある。

jest.config.js:

// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html

module.exports = {
  // All imported modules in your tests should be mocked automatically
  // automock: false,

  // Stop running tests after the first failure
  // bail: false,

  // Respect "browser" field in package.json when resolving modules
  // browser: false,

  // The directory where Jest should store its cached dependency information
  // cacheDirectory: "C:\\Users\\kaitoy\\AppData\\Local\\Temp\\jest",

  // Automatically clear mock calls and instances between every test
  // clearMocks: false,

  // Indicates whether the coverage information should be collected while executing the test
  // collectCoverage: false,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  // collectCoverageFrom: null,

  // The directory where Jest should output its coverage files
  coverageDirectory: 'coverage',

  // An array of regexp pattern strings used to skip coverage collection
  // coveragePathIgnorePatterns: [
  //   "\\\\node_modules\\\\"
  // ],

  // A list of reporter names that Jest uses when writing coverage reports
  // coverageReporters: [
  //   "json",
  //   "text",
  //   "lcov",
  //   "clover"
  // ],

  // An object that configures minimum threshold enforcement for coverage results
  // coverageThreshold: null,

  // Make calling deprecated APIs throw helpful error messages
  // errorOnDeprecated: false,

  // Force coverage collection from ignored files usin a array of glob patterns
  // forceCoverageMatch: [],

  // A path to a module which exports an async function that is triggered once before all test suites
  // globalSetup: null,

  // A path to a module which exports an async function that is triggered once after all test suites
  // globalTeardown: null,

  // A set of global variables that need to be available in all test environments
  // globals: {},

  // An array of directory names to be searched recursively up from the requiring module's location
  // moduleDirectories: [
  //   "node_modules"
  // ],

  // An array of file extensions your modules use
  // moduleFileExtensions: [
  //   "js",
  //   "json",
  //   "jsx",
  //   "node"
  // ],

  // A map from regular expressions to module names that allow to stub out resources with a single module
  // moduleNameMapper: {},

  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
  // modulePathIgnorePatterns: [],

  // Activates notifications for test results
  // notify: false,

  // An enum that specifies notification mode. Requires { notify: true }
  // notifyMode: "always",

  // A preset that is used as a base for Jest's configuration
  // preset: null,

  // Run tests from one or more projects
  // projects: null,

  // Use this configuration option to add custom reporters to Jest
  // reporters: undefined,

  // Automatically reset mock state between every test
  // resetMocks: false,

  // Reset the module registry before running each individual test
  // resetModules: false,

  // A path to a custom resolver
  // resolver: null,

  // Automatically restore mock state between every test
  // restoreMocks: false,

  // The root directory that Jest should scan for tests and modules within
  // rootDir: null,

  // A list of paths to directories that Jest should use to search for files in
  // roots: [
  //   "<rootDir>"
  // ],

  // Allows you to use a custom runner instead of Jest's default test runner
  // runner: "jest-runner",

  // The paths to modules that run some code to configure or set up the testing environment before each test
  // setupFiles: [],

  // The path to a module that runs some code to configure or set up the testing framework before each test
  // setupTestFrameworkScriptFile: null,

  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
  // snapshotSerializers: [],

  // The test environment that will be used for testing
  // testEnvironment: "jest-environment-jsdom",

  // Options that will be passed to the testEnvironment
  // testEnvironmentOptions: {},

  // Adds a location field to test results
  // testLocationInResults: false,

  // The glob patterns Jest uses to detect test files
  // testMatch: [
  //   "**/__tests__/**/*.js?(x)",
  //   "**/?(*.)+(spec|test).js?(x)"
  // ],

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  // testPathIgnorePatterns: [
  //   "\\\\node_modules\\\\"
  // ],

  // The regexp pattern Jest uses to detect test files
  // testRegex: "",

  // This option allows the use of a custom results processor
  // testResultsProcessor: null,

  // This option allows use of a custom test runner
  // testRunner: "jasmine2",

  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
  testURL: 'http://localhost/',

  // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
  // timers: "real",

  // A map from regular expressions to paths to transformers
  // transform: null,

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  // transformIgnorePatterns: [
  //   "\\\\node_modules\\\\"
  // ],

  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
  // unmockedModulePathPatterns: undefined,

  // Indicates whether each individual test should be reported during the run
  // verbose: null,

  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
  // watchPathIgnorePatterns: [],

  // Whether to use watchman for file crawling
  // watchman: true,
};


また、例によって、(主にJestのグローバル変数のために、)JestのテストコードとESLintがけんかするので、ESLintをなだめるためにeslint-plugin-jestを入れる。

yarn add -D eslint-plugin-jest

.eslintrc.js:

 module.exports = {
   env: {
     browser: true,
+    'jest/globals': true,
   },
   parser: 'babel-eslint',
   extends: ['airbnb', 'plugin:flowtype/recommended', 'prettier'],
-  plugins: ['flowtype'],
+  plugins: ['flowtype', 'jest'],
 };

Jestのテスト作成

Jestのテストは、jest.config.jstestMatchにマッチするJavaScriptファイルに書く。 デフォルトでは__test__ディレクトリ以下に置けばいい。

テストコードはよくある感じのBDD風に書けばいいと思う。

src/__tests__/reducers/reducers.test.js:

import { hoge } from '../../reducers/reducers';
import { hogeButtonClicked } from '../../actions/actions';

const initialState = {
  clicked: false,
};

describe('reducers', () => {
  describe('hoge()', () => {
    test('returns a state with clicked:true when the action is HOGE_BUTTON_CLICKED', () => {
      const state = hogeButtonClicked(initialState, hogeButtonClicked({}));
      expect(state.clicked).toBe(true);
    });
  });
});

スナップショットテスト

Jestの目玉のひとつはスナップショットテスト。 Reactコンポーネントのレンダリング結果が以前と変わってないかをテストできる。

src/__tests__/components/HogeButton.test.jsx:

import React from 'react';
import renderer from 'react-test-renderer';
import HogeButton from '../../components/HogeButton';

describe('components', () => {
  describe('HogeButton', () => {
    test('renders correctly', () => {
      const tree = renderer.create(
        <HogeButton variant="contained" onClick={() => {}}>
          HOGE
        </HogeButton>
      ).toJSON();
      expect(tree).toMatchSnapshot();
    });
  });
});

このテストの初回実行時には、src/__tests__/components/__snapshots__/HogeButton.test.jsx.snapというスナップショットファイルが生成される。 これはテキスト形式で、以下のような人が読み解ける内容。

src/__tests__/components/__snapshots__/HogeButton.test.jsx.snap:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`components HogeButton renders correctly 1`] = `
<button
  className="MuiButtonBase-root-25 MuiButton-root-1 MuiButton-contained-10 MuiButton-raised-13"
  disabled={false}
  onBlur={[Function]}
  onClick={[Function]}
  onFocus={[Function]}
  onKeyDown={[Function]}
  onKeyUp={[Function]}
  onMouseDown={[Function]}
  onMouseLeave={[Function]}
  onMouseUp={[Function]}
  onTouchEnd={[Function]}
  onTouchMove={[Function]}
  onTouchStart={[Function]}
  tabIndex="0"
  type="button"
>
  <span
    className="MuiButton-label-2"
  >
    HOGE
  </span>
  <span
    className="MuiTouchRipple-root-28"
  />
</button>
`;

スナップショットファイルはコミットしてバージョン管理して、変更があったときには差分を確認する。

Enzyme

Reactのユニットテストをよりいい感じに書けるようにしてくれるユーティリティライブラリがEnzyme。 Airbnb製。 Reactコンポーネントをレンダリングして、jQueryみたいなAPIでセレクタを指定したりしてエレメントを取得し、アサートするようなテストが書ける。

Enzymeによるレンダリングには以下の3種類があり、テスト内容によって使い分ける。


Enzymeはv3から本体とアダプタという構成になっていて、Reactのバージョンによってアダプタを使い分ける。 (preactとかInfernoのアダプタもある。)

yarn add -D enzyme enzyme-adapter-react-16

Enzymeはv3.3.0が入った。

jest-enzymeも入れるとアサーションがいい感じに書けてよりいいかもしれない。


Full Renderingをやってみる。

ContainedButtonがクリックされたとき、onClickに指定した関数が呼ばれることを確認するテストは以下のように書ける。

src/__tests__/components/HogeButton.test.jsx:

 import React from 'react';
 import renderer from 'react-test-renderer';
+import Enzyme, { mount } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
 import HogeButton from '../../components/HogeButton';

+beforeAll(() => {
+  Enzyme.configure({ adapter: new Adapter() });
+});

 describe('components', () => {
   describe('HogeButton', () => {
     test('renders correctly', () => {
       const tree = renderer.create(
         <HogeButton variant="contained" onClick={() => {}}>
           HOGE
         </HogeButton>
       ).toJSON();
       expect(tree).toMatchSnapshot();
     });
+
+    test("calls the passed handler when it's clicked", () => {
+      const handler = jest.fn();
+      const wrapper = mount(<HogeButton onClick={handler} />);
+      wrapper.find('button').simulate('click');
+      expect(handler).toHaveBeenCalledTimes(1);
+    });
   });
 });

mountがFull RenderingのAPIで、内部でreact-test-rendererを使っているみたいなんだけど、mountのためにreact-test-rendererをimportする必要はない。


以上で全10回に渡るReact―Redux環境のセットアップ体験記が完結。

だらだら書いてるうちに、Babelの7が出たりReact HooksとかReact Suspenseとかが出てきてて、また大きく変わってきそう…