React #1 Advent Calendar 2017 の 12/23 日分の記事になります。
「負債を残さない React プロトタイピング」というテーマで、UIプロトタイピングのやり方について書きました。
単品の技術ネタというよりは、複数のツールを組み合わせたプラクティス/開発フローの話になります。
中級者(すでに React でアプリを作れる人)向けの内容になりますので、あらかじめご了承ください。
TL;DR;
- プロトタイピングには Storybook を活用しましょう。
- TypeScript で Props の変更漏れをなくしましょう。
- Jest のスナップショットテストでコンポーネントをゆるく保護しましょう。
動機
開発初期の UI プロトタイピングを考えます。
おおまかには以下のような状況です。
- UI主導でエンジニアが画面設計とそのプロトタイプを作る。当初はサーバーサイドはほぼ書かないまま、試行錯誤する。
- のちにデザイナーによる手が入る。大幅にパーツ構成が変わるが、ロジックは変わらない。
- 本開発で、まずフロントエンド側のロジック(典型的にはルーティングとアニメーション)が加わり、サーバーサイドとの接続が入る。
そこで、次の2つの目的を達成したいとします。
- 開発初期のUIプロトタイピングの速度は下げたくない。
- でも、のちにくる修正時における技術的負債を(なるべく)残したくはない。
さて、この2つはしばしば相反します。そこで、今のところこれくらいがいいバランスだな〜と思える妥協点として、最近落ち着いているツールとそのプラクティスを紹介したいと思います。
もちろん最適なソリューションは目的やチームメンバーによって違いますので、参考にしつつアレンジしていただければと思います。
使用技術
ここでは以下の技術を利用します。
個別に解説はしませんので、あしからずお願いします。
- Storybook-React
- TypeScript
- Jest
- Create-react-app
- Webpack
- Semantic-UI-React
- Yarn
具体例
ここからは簡単なチャットアプリを例に具体的な手順を説明していきます。
ハンズオン形式になっていますので、時間のある方は実際に手を動かして体感してみてください。
エディタはとくに限定していませんが、参考までに私は Mac 上で Visual Studio Code を使っています。
開発環境準備
さきに npm 互換のパッケージマネージャーの yarn を入れます。
npm i yarn -g
まず create-react-app
で初期アプリを作ります。TypeScript を使うためのオプションを指定しています。
create-react-app react-chat --scripts-version=react-scripts-ts
これだけで、TypeScript のコード雛形と、Webpack によるビルド環境、Linter、テスト環境、および Webpack-dev-server によるホットリロードのついたフロントエンド開発環境が出来上がります1。すばらしいですね。CSS はメタ言語なしですが、Auto-prefixer がついています。
Storybook の導入
つづいてここに storybook
を追加します。
yarn global add @storybook/cli
getstorybook -f
ここで TypeScript 向けに若干修正が必要です。
まず型定義ファイルを追加します。
yarn add @storybook/react @types/storybook__react @types/storybook__addon-actions @types/storybook__addon-links -D
つぎに tsconfig.json の次の箇所を修正して storybook 用のファイルをコンパイル対象に加えます。じつはビルドではなく、エディタや IDE に認識させる目的です。
- "rootDir": "src",
+ "rootDirs": ["src", "stories"],
最後に Storybook 向けの Webpack 設定ファイルを追加します。 .storybook フォルダの下に webpack.config.js という名前で以下のファイルを保存します。ここでは Storybook のデフォルトの設定ファイルに TypeScript のコンパイルと Linter を足しています。
Storybook で用いられる Webpack は旧来の Webpack 1 なので、create-react-app でセットアップされる現行の Webpack とは記述方法が異なることに注意して下さい。
const baseConfig = require('@storybook/react/dist/server/config/defaults/webpack.config.js');
module.exports = function () {
var newConfig = baseConfig.apply(this, arguments);
newConfig.module.rules.push({
test: /\.tsx?$/,
exclude: [/node_modules/, /__tests__/, /\.(test|spec)\.tsx?$/],
include: [/stories/, /src/],
loaders: ['ts-loader', 'tslint-loader']
});
newConfig.resolve.extensions.push('.ts', '.tsx');
return newConfig;
};
以上でセットアップは終了です。
コンポーネント
いよいよコンポーネントの制作に入ります。UI プロトタイプ段階では Presentational Component のみと格闘します。言い換えると、src/components フォルダの中を埋めることになります。
仮ファイルの作成
わかりやすいように最初に形だけ整えましよう。
チャットアプリの各メッセージを現す Message
というコンポーネントと、それを集約する MesssageBox
というコンポーネントを作ることにします。本当は発言欄も作るのですが、冗長になるので割愛します。
components フォルダの下に Message.tsx と MessageBoard.tsx の2つのファイルを用意します。
import * as React from 'react';
export interface MessageProps {
}
// tslint:disable-next-line:function-name
export default function Message(props: MessageProps) {
return (
<div>Message</div>
);
}
import * as React from 'react';
export interface MessageBoardProps {
}
// tslint:disable-next-line:function-name
export default function MessageBoard(props: MessageBoardProps) {
return (
<div>MessageBoard</div>
);
}
tslint:disable-next-line:function-name
は Linter の無効化です。たいていの tslint の設定で関数名は lower-camel を強制されますが、React のコンポーネントは大文字と決まっているため必要になりますが、実際には tslint の設定で行った方が面倒がないです。
最後にこれらを Storybook に追加します。stories/index.js を stories/index.tsx にリネームし、以下のようにします。
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import Message from '../src/components/Message';
import MessageBoard from '../src/components/MessageBoard';
storiesOf('Message', module)
.add('default', () => (
<Message />
));
storiesOf('MessageBoard', module)
.add('default', () => (
<MessageBoard />
));
ここまでできたら、ターミナルに yarn run storybook
と打ち込むと Storybook が起動します。
yarn run storybook
yarn run v1.3.2
$ start-storybook -p 6006
@storybook/react v3.2.13
=> Loading custom addons config.
=> Loading custom webpack config (full-control mode).
22% building modules 104/133 modules 29 active ...ode_modules/webpack/buildin/global.jsWarning: The 'no-boolean-literal-compare' rule requires type information.
Warning: The 'strict-boolean-expressions' rule requires type information.
ts-loader: Using typescript@2.5.3 and /xxxxxxxx/react-chat/tsconfig.json
webpack built e1fe361cfc31ada80020 in 9166ms
Storybook started on => http://localhost:6006/
webpack building...
webpack built e2a9ceb4db412a53438e in 1733ms
webpack building...
webpack built fa74e6aa0d2b8c761eb5 in 909ms
ブラウザで http://localhost:6006/ を開くとリアルタイムで結果が確認できます。
あとはがりがりコンポーネントの中身を書いていきましょう。
仕上げ
最終的に以下のようなものが出来上がります。
以下に手順とコードを記します。デモ用ということで、私がよく使う Semantic-UI-React というライブラリに頼っていますが、Material-UI でもなんでも好きなものを使って下さい。自分の手でいろいろ試行錯誤したほうが Storybook のありがたみがわかるのでおすすめです。
semantic-ui-react とそのスタイルシートをインストールします。
yarn add semantic-ui-react semantic-ui-css
アバター画像を https://pixabay.com/ja/users/PanJoyCZ-719461/ からダウンロードし、"src/avatar.png" として保存します。
コンポーネントを書きつつ、Storybook 側のファイルを修正します。
import * as React from 'react';
import { Comment } from 'semantic-ui-react';
const avatarPng = require('../avatar.png');
export interface MessageProps {
speaker: string;
speech: string;
time: React.ReactNode;
}
export default class Message extends React.PureComponent<MessageProps> {
render() {
return (
<Comment>
<Comment.Avatar src={avatarPng} />
<Comment.Content>
<Comment.Author as="a">{this.props.speaker}</Comment.Author>
<Comment.Metadata>
<div>{this.props.time}</div>
</Comment.Metadata>
<Comment.Text>{this.props.speech}</Comment.Text>
</Comment.Content>
</Comment>
);
}
// tslint:disable-next-line:function-name
static Group({ children }: { children?: React.ReactNode }) {
return <Comment.Group>{children}</Comment.Group>;
}
}
import * as React from 'react';
import Message, { MessageProps } from './Message';
export interface MessageBoardProps {
messages?: MessageProps[];
}
// tslint:disable-next-line:function-name
function MessageBoard({ messages }: MessageBoardProps) {
const children = (messages || []).map((x, i) => <Message {...x} key={i} />);
return (
<Message.Group>
{children}
</Message.Group>
);
}
export default MessageBoard as React.StatelessComponent<MessageBoardProps>;
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import Message from '../src/components/Message';
import MessageBoard from '../src/components/MessageBoard';
import 'semantic-ui-css/semantic.min.css';
storiesOf('Message', module)
.add('default', () => (
<Message.Group>
<Message speaker="John" speech="Hello!" time="1/1/2017 23:00"/>
</Message.Group>
));
storiesOf('MessageBoard', module)
.add('default', () => (
<MessageBoard messages={[
{ speaker: 'John', speech: 'Hello!', time: 'yesterday 13:05' },
{ speaker: 'Mary', speech: 'Hello, John!', time: 'yesterday 21:41' },
]}/>
));
ユニットテスト
基本方針として、いわゆる単体テストでは Props に対する振る舞いをメインに書きます。DOMの詳細な構造はスナップショットテストで行います。
Props のうち、念入りにテストすべきはイベントハンドラとフラグ変数(値によって出力されるDOMが変化するもの。例えばTODOリストの完了状態など)です。
逆に、ボタンのラベルのようなDOMに転写されるだけのものはスナップショットテストに任せておけば十分でしょう。
Enzyme のセットアップ
React のテストツールとして Enzyme を導入します。
yarn add enzyme enzyme-adapter-react-16 @types/enzyme @types/enzyme-adapter-react-16 -D
テスト向けのセットアップファイルを新規作成します。
import * as Enzyme from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
import 'jest-enzyme';
Enzyme.configure({ adapter: new ReactSixteenAdapter() });
つづいて jest-enzyme
を導入します。これは Jest で Enzyme 向けの matcher が使えるようになる拡張モジュールです。
ターミナルから以下を実行。
yarn add jest-enzyme -D
setupTests.ts の冒頭につぎの import 文を追加。
+import 'jest-enzyme';
tsconfig.json につぎの行を挿入します。
"compilerOptions": {
"outDir": "build/dist",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
+ "typeRoots": ["node_modules/@types", "node_modules/jest-enzyme"],
"sourceMap": true,
Enzyme とは関係ないですが、のちのスナップショットテストのために react-test-renderer
も追加しておきましよう。2
yarn add react-test-renderer @types/react-test-renderer -D
準備が出来たのでテストを起動します。
yarn test
以降、テスト環境の変化を検知して自働でテストが走るようになります。
テストの実装
それではコンポーネントのテストを書いていきましょう。
今回は説明の都合で完成後に書いていますが、ある程度複雑なロジックを導入する場合はその段階でテストを書いたほうがよいです。
create-react-app
ではコンポーネントと同じ階層に "component-name.test.tsx" という名前で配置することが推奨されていますのでそのようにしましょう。
今回は以下のように各 Props の値のレンダリング結果の確認とスナップショットだけ行っています。
import * as React from 'react';
import * as renderer from 'react-test-renderer';
import { shallow } from 'enzyme';
import { Comment } from 'semantic-ui-react';
import Message from './Message';
describe('<Message>', () => {
const element = <Message speaker="John" speech="Hello." time="1:00" />;
const wrapper = shallow(element);
it('has <Comment.Author> with speaker', () => {
expect(wrapper.find(Comment.Author)).toBePresent();
expect(wrapper.find(Comment.Author).dive()).toHaveText('John');
});
it('has <Comment.Text> with speech', () => {
expect(wrapper.find(Comment.Text)).toBePresent();
expect(wrapper.find(Comment.Text).dive()).toHaveText('Hello.');
});
it('has <Comment.Metadata> with time', () => {
expect(wrapper.find(Comment.Metadata)).toBePresent();
expect(wrapper.find(Comment.Metadata).dive()).toHaveText('1:00');
});
it('matches snapshot', () => {
const tree = renderer.create(element).toJSON();
expect(tree).toMatchSnapshot();
});
});
describe('<Message.Group>', () => {
it('is rendered as <Comment.Group>', () => {
const wrapper = shallow(<Message.Group/>);
expect(wrapper.type()).toBe(Comment.Group);
});
it('matches snapshot', () => {
const tree = renderer.create(<Message.Group/>).toJSON();
expect(tree).toMatchSnapshot();
});
});
JS のテストになれた方であれば、コードだけで意味がつかめるでしょう。
Enzyme の shallow レンダリングと Jest のスナップショットテストについて深く知りたい方は、他の解説をお読みくださればと思います。どちらも実践的な環境ではそろそろ 知らないではすまされない事項 だと思っています。
つづいて MessageBoard のテストです。こちらはコレクション型のコンポーネントなのでもう少し複雑になります。
import * as React from 'react';
import * as renderer from 'react-test-renderer';
import { shallow } from 'enzyme';
import MessageBoard from './MessageBoard';
import Message, { MessageProps } from './Message';
describe('<MessageBoard>', () => {
it('is rendered as <Message.Group>', () => {
const wrapper = shallow(<MessageBoard />);
expect(wrapper.find(Message.Group)).toBePresent();
expect(wrapper.children()).toBeEmpty();
});
it('has children of <Message />', () => {
const propsArray: MessageProps[] = [{
speaker: 'a', speech: 'b', time: 'c',
}, {
speaker: 'd', speech: 'e', time: 'f',
}];
const wrapper = shallow(<MessageBoard messages={propsArray} />);
expect(wrapper.children(Message)).toHaveLength(2);
expect(wrapper.children(Message).at(0).props()).toEqual(propsArray[0]);
expect(wrapper.children(Message).at(1).props()).toEqual(propsArray[1]);
});
it('has same number of children as input', () => {
const props: MessageProps = {
speaker: '',
speech: '',
time: '',
};
for (let i = 0; i < 5; i += 1) {
const wrapper = shallow(<MessageBoard messages={repeat(props, i)} />);
expect(wrapper.find(Message)).toHaveLength(i);
}
});
it('retains snapshot', () => {
const tree = renderer.create(<Message.Group/>).toJSON();
expect(tree).toMatchSnapshot();
});
});
function repeat<T>(x: T, n: number) {
const result: T[] = [];
for (let i = 0; i < n; i += 1) {
result.push(x);
}
return result;
}
ポイントは Message
コンポーネントのレベルで記述されていることです。つまり、Message のレンダリング結果が変わってもテストが壊れません(ためしてみてください)。これが shallow レンダリングの効果です。
カバレッジ
最後にカバレッジを測定してみましよう。ターミナルから yarn test --coverage
を実行してください。3
component 以下に関しては 100% になるはずです。
$ yarn test --coverage
yarn run v1.3.2
$ react-scripts-ts test --env=jsdom --coverage
PASS src/App.test.tsx
PASS src/components/Message.test.tsx
PASS src/components/MessageBoard.test.tsx
Test Suites: 3 passed, 3 total
Tests: 10 passed, 10 total
Snapshots: 2 passed, 2 total
Time: 3.809s
Ran all test suites.
---------------------------|----------|----------|----------|----------|----------------|
File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
---------------------------|----------|----------|----------|----------|----------------|
All files | 41.25 | 8 | 30 | 40.79 | |
src | 26.56 | 0 | 12.5 | 26.23 | |
App.tsx | 100 | 100 | 100 | 100 | |
index.tsx | 0 | 100 | 100 | 0 |... 7,8,9,10,11 |
polyfill.ts | 100 | 100 | 100 | 100 | |
registerServiceWorker.ts | 0 | 0 | 0 | 0 |... 5,96,97,101 |
setupTests.ts | 100 | 100 | 100 | 100 | |
src/components | 100 | 100 | 100 | 100 | |
Message.tsx | 100 | 100 | 100 | 100 | |
MessageBoard.tsx | 100 | 100 | 100 | 100 | |
---------------------------|----------|----------|----------|----------|----------------|
✨ Done in 5.62s.
まとめ
プロトタイピング段階のコードに必要なのは、シンプルさと変更への耐性です。
TypeScript の型情報と Jest のスナップショットテストを活用することで、メンテ対象のコードを最小に保ちつつ、壊れにくいテストを実現できます。
それではよい React ライフを!