LoginSignup
15
18

More than 5 years have passed since last update.

負債を残さない React プロトタイピング

Posted at

React #1 Advent Calendar 2017 の 12/23 日分の記事になります。

「負債を残さない React プロトタイピング」というテーマで、UIプロトタイピングのやり方について書きました。
単品の技術ネタというよりは、複数のツールを組み合わせたプラクティス/開発フローの話になります。
中級者(すでに React でアプリを作れる人)向けの内容になりますので、あらかじめご了承ください。

TL;DR;

  • プロトタイピングには Storybook を活用しましょう。
  • TypeScript で Props の変更漏れをなくしましょう。
  • Jest のスナップショットテストでコンポーネントをゆるく保護しましょう。

動機

開発初期の UI プロトタイピングを考えます。
おおまかには以下のような状況です。

  1. UI主導でエンジニアが画面設計とそのプロトタイプを作る。当初はサーバーサイドはほぼ書かないまま、試行錯誤する。
  2. のちにデザイナーによる手が入る。大幅にパーツ構成が変わるが、ロジックは変わらない。
  3. 本開発で、まずフロントエンド側のロジック(典型的にはルーティングとアニメーション)が加わり、サーバーサイドとの接続が入る。

そこで、次の2つの目的を達成したいとします。

  1. 開発初期のUIプロトタイピングの速度は下げたくない。
  2. でも、のちにくる修正時における技術的負債を(なるべく)残したくはない。

さて、この2つはしばしば相反します。そこで、今のところこれくらいがいいバランスだな〜と思える妥協点として、最近落ち着いているツールとそのプラクティスを紹介したいと思います。

もちろん最適なソリューションは目的やチームメンバーによって違いますので、参考にしつつアレンジしていただければと思います。

使用技術

ここでは以下の技術を利用します。

個別に解説はしませんので、あしからずお願いします。

  1. Storybook-React
  2. TypeScript
  3. Jest
  4. Create-react-app
  5. Webpack
  6. Semantic-UI-React
  7. 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 に認識させる目的です。

tsconfig.json
-    "rootDir": "src",
+    "rootDirs": ["src", "stories"],

最後に Storybook 向けの Webpack 設定ファイルを追加します。 .storybook フォルダの下に webpack.config.js という名前で以下のファイルを保存します。ここでは Storybook のデフォルトの設定ファイルに TypeScript のコンパイルと Linter を足しています。
Storybook で用いられる Webpack は旧来の Webpack 1 なので、create-react-app でセットアップされる現行の Webpack とは記述方法が異なることに注意して下さい。

.storybook/webpack.config.js
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つのファイルを用意します。

Message.tsx
import * as React from 'react';

export interface MessageProps {
}

// tslint:disable-next-line:function-name
export default function Message(props: MessageProps) {
  return (
    <div>Message</div>
  );
}
MessageBoard.tsx
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 にリネームし、以下のようにします。

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/ を開くとリアルタイムで結果が確認できます。
あとはがりがりコンポーネントの中身を書いていきましょう。

仕上げ

最終的に以下のようなものが出来上がります。

storybook-chat.png

以下に手順とコードを記します。デモ用ということで、私がよく使う 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 側のファイルを修正します。

src/components/Message.tsx
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>;
  }
}
src/component/MessageBoard.tsx
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>;
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';
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

テスト向けのセットアップファイルを新規作成します。

src/setupTests.ts
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 文を追加。

src/setupTest.ts
+import 'jest-enzyme';

tsconfig.json につぎの行を挿入します。

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 の値のレンダリング結果の確認とスナップショットだけ行っています。

Message.test.tsx
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 のテストです。こちらはコレクション型のコンポーネントなのでもう少し複雑になります。

MessageBoard.test.tsx
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 ライフを!


  1. 本当は React 16 以降、 map, set, requestAnimationFrame の polyfill が追加で必要なのですが、ここでは省略します。詳しくは公式の移行ガイドをご覧ください。 

  2. React 16 から本体から分離されて別パッケージになりました。 

  3. Jest は組み込みでカバレッジ機能を持っています(実装は istanbul)。clobber 形式で出力することで CI との統合も簡単にできます。おためしあれ。 

15
18
0

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
18