tyoshikawa1106のブログ

- Force.com Developer Blog -

React:TDD(テスト駆動開発)の始め方について勉強しました

ReactのTDDについて環境構築から解説している記事がありました。テストできる環境の構築方法は覚えておかないといけないことなのでこの記事を参考に試してみました

Quikをインストール

Quikを使うと手動でビルドを設定することなく即座に実行することができるそうです。勉強時にはこれが便利みたいです。

$ npm install -g quik

テストにアサーションを作るためのライブラリが用意

テスト環境を用意するときはこういったライブラリが必要になるとのことです。

テストに、アサーションを作るためのライブラリが必要です。Chaiがよく知られています。さらに、スパイを設定するライブラリ、Sinonもインストールしましょう。また、Airbnbが作った、Reactコンポーネントテストのためのライブラリ、Enzyme、それからJavaScriptでブラウザDOMをシミュレートするライブラリ、jsdomもインストールします。

インストールコマンドはこちら

$ npm install chai sinon enzyme jsdom

f:id:tyoshikawa1106:20160628115951p:plain

Reactのインストール

React関係もインストールします。

$ npm install react react-dom react-addons-test-utils

f:id:tyoshikawa1106:20160628120133p:plain

テストランナーのインストール

Mochaをインストールします。

テストランナーも要ります。これには、Mocha、Tape、Jasmineなどいくつか選択肢があります。ReactコミュニティではMochaが人気を集めているので、それを使います。mochaコマンドを使用できるよう、グローバルにインストールしましょう。

インストールコマンドはこちら。

$ npm install -g mocha

f:id:tyoshikawa1106:20160628120303p:plain

Babelのインストール

Reactを勉強するときに名前が出てくるBabelをインストールします。記事内にはなぜ必要かの理由も記載がありました。

テストファイルではES6とJSXを使いますので、Mochaが実行できるよう、テストをBabelで変換しなければなりません。そのために、Babelとプリセットをいくつか(ES2015、ES6用のes2015と、JSX用のreact)インストールします。

インストールコマンドはこちら

$ npm install babel-core babel-preset-es2015 babel-preset-react

f:id:tyoshikawa1106:20160628120554p:plain

.babelrcファイルの作成

Babelの設定ファイルを作成します。

{
  "presets": ["es2015", "react"]
}


ここまででこんな感じです。
f:id:tyoshikawa1106:20160628120729p:plain

setup.jsファイルの作成

フェイクのDOMを作成するsetup.jsファイルを用意します。

require('babel-register')();
 
var jsdom = require('jsdom').jsdom;
 
var exposedProperties = ['window', 'navigator', 'document'];
 
global.document = jsdom('');
global.window = document.defaultView;
Object.keys(document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    exposedProperties.push(property);
    global[property] = document.defaultView[property];
  }
});
 
global.navigator = {
  userAgent: 'node.js'
};


これで基本のテスト実行環境を準備できたみたいです。

環境をテストする

ただしく準備できているか動作確認を行います。

Mochaの動作のテスト

components.spec.jsという名前のファイルを作成し、次のコードをペーストします。

import { expect } from 'chai';
 
describe('the environment', () => {
  it('works, hopefully', () => {
    expect(true).to.be.true;
  });
});

f:id:tyoshikawa1106:20160628121110p:plain


次のコマンドでMochaを実行します。

$ mocha --require setup.js *.spec.js

エラーなく実行できました。
f:id:tyoshikawa1106:20160628121228p:plain

使用するツールの確認

省略しますが参考記事内には下記ツールがどのようなものかまでわかりやすく解説されていました。

  • Mocha
  • Chai
  • Sinon
  • Enzyme

テストの方法やテストをする理由を理解する

参考記事内にはこの辺りの解説もありました。

  • なぜテストをするのか
  • どのようにテストを行うのか
  • テストの対象
    • アウトプットをテストする
    • 状態をテストする
    • イベントをテストする
    • エッジケースをテストする

テストの対象

最終的には以下のコンポーネントを実装してテストを試します。

  • BeerListContainer
  • InputArea
  • BeerList

f:id:tyoshikawa1106:20160628122816p:plain


完成リポジトリのリンクです。

TDDのプロセスを開始する

index.jsファイルを用意します。(ルートコンポーネントをレンダリングする役割)

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import {BeerListContainer} from './components';
 
ReactDOM.render(
  <BeerListContainer/>,
  document.querySelector('#root'));

components.jsファイルを用意します。

import React, { Component } from 'react';
 
export class BeerListContainer extends Component {
  render() {
    return <span>Beer!</span>
  }
}


プロジェクトディレクトリに戻って、Quikを起動します。

$ quik

f:id:tyoshikawa1106:20160628123205p:plain


localhost:3030にcomponentに記載した文字列が表示されることを確認できます。
f:id:tyoshikawa1106:20160628123300p:plain


次に別のターミナルウィンドウを開いて、Mochaを起動します。

$ mocha --watch --require setup.js *.spec.js

現時点ではエラーなく実行できます。(テスト未実装のため)
f:id:tyoshikawa1106:20160628123401p:plain


実際にテストを実装していきます。components.spec.jsを次のように変更します。

import React from 'react';
import { expect } from 'chai';
import { shallow, mount } from 'enzyme';
import { BeerListContainer } from './components';
 
describe('BeerListContainer', () => {
  it('should render InputArea and BeerList', () => {
    const wrapper = shallow(<BeerListContainer/>);
    expect(wrapper.containsAllMatchingElements([
      <InputArea/>,
      <BeerList/>
    ])).to.equal(true);
  });
});

テストを実行するとエラーになることを確認できます。
f:id:tyoshikawa1106:20160628123615p:plain

エラー内容

ReferenceError: InputArea is not defined

components.jsに次の2つのコンポーネントを最後に追加します。

export class InputArea extends Component {
  render() {
    return <input/>
  }
}
 
export class BeerList extends Component {
  render() {
    return <ul/>
  }
}

f:id:tyoshikawa1106:20160628124148p:plain


components.spec.jsに戻って次の行を先頭のインポートの部分に追加します。

import { InputArea, BeerList } from './components';

テストはまだ通りませんが『ReferenceError: InputArea is not defined』エラーは発生しなくなることが確認できます。


components.jsのBeerListContainerコンポーネントをつぎのように修正します。

export class BeerListContainer extends Component {
  render() {
    return (
      <div>
        <InputArea/>
        <BeerList/>
      </div>
    );
  }
}

f:id:tyoshikawa1106:20160628124646p:plain


これでテストが正常にPassされるようになりました。
f:id:tyoshikawa1106:20160628124723p:plain

テスト2:コンテナの状態をテストする

components.spec.jsに、次のようにテストを記述します。
describe('BeerListContainer', () => {
  ...
 
  it('should start with an empty list', () => {
    const wrapper = shallow(<BeerListContainer/>);
    expect(wrapper.state('beers')).to.equal([]);
  });
});

f:id:tyoshikawa1106:20160628125016p:plain


次のエラーになることを確認できます。

TypeError: Cannot read property 'beers' of null

コンポーネントのstateを初期化していないためです。

components.jsに初期化処理を追加します。

export class BeerListContainer extends Component {
  constructor(props) {
    super(props);
    this.state = {
      beers: []
    };
  }
 
  ...
}

f:id:tyoshikawa1106:20160628125307p:plain


この変更で先程のエラーは解決しますが、次のエラーが発生します。

AssertionError: expected [] to equal []


これは、===演算子を使ってオブジェクトの等価性をテストする.equalを使ったためです。2つの空の配列は同じオブジェクトではないことが原因とのことです。
f:id:tyoshikawa1106:20160628125541p:plain


ここはeqlを使ってテストを行います。components.spec.js内のエクスペクテーション(expect)を以下のように変更します。

expect(wrapper.state('beers')).to.eql([]);


これでテストが通るようになりました。
f:id:tyoshikawa1106:20160628125633p:plain

テスト 3:アイテムを追加する

components.spec.jsに次のテストを追加します。

describe('BeerListContainer', () => {
  ...
 
  it('adds items to the list', () => {
    const wrapper = shallow(<BeerListContainer/>);
    wrapper.addItem('Sam Adams');
    expect(wrapper.state('beers')).to.eql(['Sam Adams']);
  });
});


追加するとテストで『TypeError: wrapper.addItem is not a function』エラーが発生するはずです。(addItemは存在しないため)


addItem関数を、components.jsに追加します。

export class BeerListContainer extends Component {
  ...
 
  addItem(name) {
    // do nothing for now
  }
 
  ...
}


この変更だけでは、shallow()から返されたオブジェクトが、実際はBeerListContainerのインスタンスになっていない状態になっています。


components.spec.jsの次の処理を...

wrapper.addItem('Sam Adams');


次のようにします。

wrapper.instance().addItem('Sam Adams');


すると『AssertionError: expected [] to deeply equal [ 'Sam Adams' ]』エラーが発生するようになります。


stateを内部のaddItemから更新するように変更します。

export class BeerListContainer extends Component {
  ...
 
  addItem(name) {
    this.setState({
      beers: [].concat(this.state.beers).concat([name])
    });
  }
 
  ...
}


これでテストが通るようになりました。
f:id:tyoshikawa1106:20160628140314p:plain

覚えておきたいこと

以上のような配列の更新方法は見慣れないかもしれません。こうすることで、既存の状態を誤って変化させたりせずに済むのです。stateの変化を避けるのを習慣にしたいものです。Reduxを使っている、使う予定がある際はなおさらそれが重要でしょう。常に確実にレンダリングビューを現在の状態と同期させることができるからです。

Immutable.jsのようなライブラリを使用すると、上述のような不変のコードの記述が容易です。このチュートリアルでは、なるべく複雑にしないためImmutable.jsを使いませんが、基礎を押さえた後に試してみる価値はあります。

テスト 4: 関数を渡す

はじめにPropTypsの定義についての解説がありました。

コンテナの内部が全て正常に動作するようになったところで、addItem関数をプロパティとして、後にaddItemの呼び出しを担うInputAreaに渡しましょう。

新規プロパティをコンポーネントに追加する際にはいつも、そのためのPropTypesの定義を作るようにするのは非常に良いアイデアです。PropTypesが重要な理由についてはこの記事に詳しいですが、要は、PropTypes で期待するプロパティと型を定義すると、必要なプロパティを渡し忘れたり、誤った型を渡したりした場合に、Reactが警告を出してくれるということです。

PropTypesでデバッグがとても簡単になります。最初にコンポーネントを書く時だけでなく、将来それを再利用する時も同様です。


components.js内にPropTypesを追加します。

export class InputArea extends Component {
  ...
}
InputArea.PropTypes = {
  onSubmit: React.PropTypes.func.isRequired
};


テストをcomponents.spec.jsに追加します。

describe('BeerListContainer', () => {
  ...
 
  it('passes addItem to InputArea', () => {
    const wrapper = shallow(<BeerListContainer/>);
    const inputArea = wrapper.find(InputArea);
    const addItem = wrapper.instance().addItem;
    expect(inputArea.prop('onSubmit')).to.eql(addItem);
  });
});


この時点では『expected undefined to deeply equal [Function: addItem]』エラーが発生します。テストにパスさせるには、BeerListContainerのrenderメソッドを修正し、onSubmitプロパティをInputAreaに渡すようにします。

export class BeerListContainer extends Component {
  ...
 
  render() {
    return (
      <div>
        <InputArea onSubmit={this.addItem}/>
        <BeerList/>
      </div>
    );
  }
}

f:id:tyoshikawa1106:20160628141639p:plain

テスト 5:バインディングを確かめる

InputAreaに渡された関数がまだ機能し続けていることを確認します。

describe('BeerListContainer', () => {
  ...
 
  it('passes a bound addItem function to InputArea', () => {
    const wrapper = shallow(<BeerListContainer/>);
    const inputArea = wrapper.find(InputArea);
    inputArea.prop('onSubmit')('Sam Adams');
    expect(wrapper.state('beers')).to.eql(['Sam Adams']);
  });
});


『TypeError: Cannot read property 'setState' of undefined』エラーが発生します。addItemのようなインスタンスメソッドは、自動的にインスタンスにバインドされません。コンストラクタ内で、一度関数をバインドする方法で対応できます。

export class BeerListContainer extends Component {
  constructor(props) {
    super(props);
    this.state = {
      beers: []
    };
    this.addItem = this.addItem.bind(this);
  }
  ...
}


末尾の新しい行がaddItemを一度で全てをバインドするので、これでテストに通ります。
f:id:tyoshikawa1106:20160628143036p:plain

テスト 6: InputAreaをポピュレートする

InputAreaがinputとbuttonを含めるようテストを書きます。

describe('InputArea', () => {
  it('should contain an input and a button', () => {
    const wrapper = shallow(<InputArea/>);
    expect(wrapper.containsAllMatchingElements([
      <input/>,
      <button>Add</button>
    ])).to.equal(true);
  });
});


『AssertionError: expected false to equal true』エラーが発生するはずです。


components.jsに戻って、InputAreaが正しくレンダリングされるよう修正します。

export class InputArea extends Component {
  render() {
    return (
      <div>
        <input/>
        <button>Add</button>
      </div>
    );
  }
}


これでこのテストが通りました。

テスト 7:入力を受け取る

inputボックスが変更を受け取るよう、連携させていきましょう。次のテストを書きます。

describe('InputArea', () => {
  ...
 
  it('should accept input', () => {
    const wrapper = shallow(<InputArea/>);
    const input = wrapper.find('input');
    input.simulate('change', {target: { value: 'Resin' }});
    expect(wrapper.state('text')).to.equal('Resin');
    expect(input.prop('value')).to.equal('Resin');
  });
});


ここではinput.simulateを使い、引数として指定されたオブジェクトでonChangeイベントを発火します。この動作により、内部状態の一部が入力のvalueプロパティにフィードバックされます。


テストを追加すると『TypeError: Cannot read property 'text' of null』が発生します。



状態を初期化し、setTextメソッドも追加して、直後に必要になるバインディングで完成です。

export class InputArea extends Component {
  constructor(props) {
    super(props);
    this.state = {
      text: ''
    };
    this.setText = this.setText.bind(this);
  }
 
  setText(event) {
    this.setState({text: event.target.value});
  }
 
  ...
}


まだ、『AssertionError: expected ” to equal ‘Resin’』エラーが発生します。

export class InputArea extends Component {
  ...
 
  render() {
    return (
      <div>
        <input value={this.state.text} onChange={this.setText}/>
        <button>Add</button>
      </div>
    );
  }
}


変更後もまだ同じエラーが発生しています。入力のハンドリングには(シャローではなく)フルレンダリングが必要なためとのことです。

describe('InputArea', () => {
  ...
 
  it('should accept input', () => {
    const wrapper = mount(<InputArea/>);
    ...

mountに変更するとテストが通るようになりました。
f:id:tyoshikawa1106:20160628144012p:plain

テスト8: 「Add」ボタンを有効化する

この時点ではAddボタンをクリックしても何も動作しません。
f:id:tyoshikawa1106:20160628145049p:plain


components.spec.jsに処理を追加します。

import { spy } from 'sinon';


テスト内で次の通りspy()関数を使います。

describe('InputArea', () => {
  ...
 
  it('should call onSubmit when Add is clicked', () => {
    const addItemSpy = spy();
    const wrapper = shallow(<InputArea onSubmit={addItemSpy}/>);
    wrapper.setState({text:'Octoberfest'});
    const addButton = wrapper.find('button');
 
    addButton.simulate('click');
 
    expect(addItemSpy.calledOnce).to.equal(true);
    expect(addItemSpy.calledWith('Octoberfest')).to.equal(true);
  });
});

f:id:tyoshikawa1106:20160628145246p:plain


InputAreaコンポーネントに処理を追加します。

export class InputArea extends Component {
  constructor(props) {
    super(props);
    this.state = {
      text: ''
    };
    this.setText = this.setText.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }
 
  ...
 
  handleClick() {
    this.props.onSubmit(this.state.text);
  }
 
  render() {
    return (
      <div>
        <input value={this.state.text} onChange={this.setText}/>
        <button onClick={this.handleClick}>Add</button>
      </div>
    );
  }
}


これでテストもパスします。

テスト9~11: リストのレンダリング

リストが「空」のケースを扱う場合をテストからです。

describe('BeerList', () => {
  it('should render zero items', () => {
    const wrapper = shallow(<BeerList items={[]}/>);
    expect(wrapper.find('li')).to.have.length(0);
  });
 
  it('should render undefined items', () => {
    const wrapper = shallow(<BeerList items={undefined}/>);
    expect(wrapper.find('li')).to.have.length(0);
  });
 
  it('should render some items', () => {
    const items = ['Sam Adams', 'Resin', 'Octoberfest'];
    const wrapper = shallow(<BeerList items={items}/>);
    expect(wrapper.find('li')).to.have.length(3);
  });
});


テストを追加することで次のエラーが発生します。

AssertionError: expected { Object (root, unrendered, ...) } to have a length of 3 but got 0


BeerListを更新し、items プロパティを通じて受け取った配列をレンダリングするようにします。

export class BeerList extends Component {
  render() {
    return (
      <ul>
        {this.props.items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    );
  }
}

f:id:tyoshikawa1106:20160628150119p:plain


次は『TypeError: Cannot read property 'map' of undefined』エラーです。this.props.itemsは未定義のため発生します。

  • itemsに由来するコンポーネントのエラーは、未定義またはnullである。
  • itemsはpropTypes内でのチェックを行っていない。


修正します。

export class BeerList extends Component {
  render() {
    return this.props.items ?
      (<ul>
        {this.props.items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>)
    : null;
  }
}
BeerList.propTypes = {
  items: React.PropTypes.array.isRequired
};

テストが通ります。
f:id:tyoshikawa1106:20160628150317p:plain


テストは通りましたがまだ、Addボタンは動作しません。
f:id:tyoshikawa1106:20160628150437p:plain


最後にここを解決して完了です。

テスト12: アイテムのレンダリング

テストを追加します。

describe('BeerListContainer', () => {
  ...
 
  it('renders the items', () => {
    const wrapper = mount(<BeerListContainer/>);
    wrapper.instance().addItem('Sam Adams');
    wrapper.instance().addItem('Resin');
    expect(wrapper.find('li').length).to.equal(2);
  });
}


BeerListContainerの処理を変更します。

render() {
    return (
      <div>
        <InputArea onSubmit={this.addItem}/>
        <BeerList items={this.state.beers}/>
      </div>
    );
}


テストが通りました。
f:id:tyoshikawa1106:20160628150654p:plain


Addボタンも正常に実行されるようになりました。
f:id:tyoshikawa1106:20160628150737p:plain



これでTDDによるReact開発を進めることができました。実際の開発ではまだ覚えないといけないことがたくさんありますが、ひとまず開発箇所をテストする方法について勉強できたので良かったと思います。

できたやつ