2016年6月17日
ReactでTDD(テスト駆動開発)を始めよう : 環境構築からテスト作成、機能実装までの詳解ガイド
(2016-05-25)by Dave Ceddia
本記事は、原著者の許諾のもとに翻訳・掲載しております。
最小限の設定のTDD手法を使い、「何をテストすべきか?」から、よくある落とし穴の避け方まで、Reactコンポーネントをテストする方法を学びましょう。
導入
まず、 React を触ったことがあり、更にはいくつかのテストも書いた経験があるとしましょう。それでも、コンポーネントをどうテストするのが最善なのか、よく分からないかもしれません。どこから始めるのでしょう。具体的には何をテストすればよいのでしょうか。
いくつかのReactコンポーネントは簡潔過ぎて、そもそもテストが必要なのかすらはっきりしません。
AngularからReactに乗り換えた 人なら、テストには愛憎のような思いがあるかもしれません。
確かに Angular にはテストを支援するツールがたくさんありますが、同時にテストを書くのが難しくなる可能性があります。冗長ながら省略できない定型コードが多々ある上、 $digest
の呼び出しを忘れるとテストが当然パスすべきところで失敗する原因になるので、デバッグの時間がおびただしく増えてしまいます。
Reactのテストはそれよりも理解しやすく、Reactで行うTDDは、俊敏で高速な反復をキャプチャし、テストを楽しくします。
このチュートリアルではReactのみを中心に見ていきます。 Redux については述べません。エコシステムというのは 始めは押しつぶされそうになるほど巨大なトピック なので、まずは小さく始めましょう。
前提条件
環境
何よりもまず、テスト環境が必要です。 Testing React Components with Enzyme and Mocha(EnzymeとMochaでReactコンポーネントをテストする) は、スタート地点として最適であり、プロセスを丁寧に説明してくれます。この記事を読んだなら、あるいは今読む時間があるなら、そこから始めましょう。
あるいは、とりあえず近道をとりたい場合は、次のステップを踏んでください。
Quik をインストールします。このパッケージがあれば、手動でビルドを設定することなく、即座に実行できます。ここではグローバルにインストールするため -g
を使い、新規の quik
コマンドがインストールされるようにします。
npm install -g quik
テストに、アサーションを作るためのライブラリが必要です。 Chai がよく知られています。さらに、スパイを設定するライブラリ、 Sinon もインストールしましょう。また、 Airbnb が作った、Reactコンポーネントテストのためのライブラリ、 Enzyme 、それからJavaScriptでブラウザDOMをシミュレートするライブラリ、 jsdom もインストールします。
npm install chai sinon enzyme jsdom
Enzymeは同位の依存パッケージとしてReactを必要とします。また react-dom
と react-addon-test-utils
も使いますので、インストールしましょう。
npm install react react-dom react-addons-test-utils
テストランナー も要ります。これには、Mocha、Tape、Jasmineなどいくつか選択肢があります。Reactコミュニティでは Mocha が人気を集めているので、それを使います。 mocha
コマンドを使用できるよう、グローバルにインストールしましょう。
npm install -g mocha
テストファイルでは ES6 と JSX を使いますので、Mochaが実行できるよう、テストをBabelで 変換 しなければなりません。そのために、 Babel とプリセットをいくつか(ES2015、ES6用の es2015
と、JSX用の react
)インストールします。
npm install babel-core babel-preset-es2015 babel-preset-react
最後に、Babelにこれら2つのプリセットを使うよう指示する必要があります。これは .babelrc
という名前のファイルに設定します。 .babelrc
ファイルを作成し、次のコードをペーストしてください。
{
"presets": ["es2015", "react"]
}
波括弧を忘れずに。
もう1つ、フェイクのDOMを初期化する setup.js
ファイルも必要です。 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;
});
});
そして、以下のとおりMochaを実行します。
mocha --require setup.js *.spec.js
これでテストにパスするはずです。エラーが出た場合は、上のステップに戻り、漏れがないか確認してください。
Quikの機能のテスト
Quikが正しく動くかどうかもテストしましょう。ファイル index.js
を作成し、次のコードをペーストします。
import React from 'react';
import ReactDOM from 'react-dom';
let Hello = () => <span>Hi</span>
ReactDOM.render(<Hello/>, document.querySelector('#root'));
それから、次のようにQuikを実行します。
quik
ブラウザウィンドウに「Hi」というテキストが表示されるはずです。表示されない場合は、ブラウザを再度読み込むか、 quik
を再起動してください。
興味がある人のために、Quikが動く過程を述べます。Quikをインストールすると、独自のホットリロードWebpackビルドがバンドルされています。Quikは、呼び出された全てのプロジェクトにおいてこれを適用します。
quik
コマンドを実行すると、 index.js
ファイルを探し、それをアプリケーションのルートとして扱います。 index.js
ファイルは最低でも ReactDOM.render()
を呼び出します。このファイル内に少しでも多くでも好きなだけ置いて、他のファイルを必要なだけ import
しましょう。
使用するツールの確認
それでは私たちの”商売道具”、Reactのコードをテストする時に使うライブラリとアプリケーションを見直してみましょう。
Mocha はテストランナー、またはテストの”フレームワーク”です。ここに挙げるツールの中では、階層の最上位に位置します。Mochaには、テストファイルを探して読み込み、それを変換し、テストを構成する describe
ブロックや it
ブロックなどのテストコードそのものを実行するという役割があります。
Chai はアサーションライブラリです。全てが正しく動作しているかを確認するためのテストで使う expect
コールや assert
コールなどを提供します。
Sinon はスパイを作り出して検査するためのライブラリです。テスト中のコンポーネントだけにテストを集中させるために、スパイによって部分的な機能のモックやスタブを使うことが可能になります。
Enzyme はReactコンポーネント上でレンダリングをしたり、アサーションを作ったりするためのライブラリです。ここに挙げたツールの中で、Reactに特化したツールはこれだけです。
これらのツールがどのように連携して機能するかを以下に示します。
mocha
を、いくつかの引数と共にコマンドライン上で実行します。- mochaがテストファイルを見つけ、変換します。
- mochaがJavaScript(今回の例ではES6)で書かれたテストを実行します。
- それぞれのテストが enzyme と chai を
import
し、それらを使ってコンポーネントのレンダリングやアサーションの作成を行います。
これらのツールの役割は、テストを書き始めると、より明確になっていきます。
テストの方法やテストをする理由を理解する
この記事の冒頭で、いくつかの動機づけについて述べました。なぜReactコンポーネントをテストし、更に重要な問題として、これらの一体何についてテストをする必要があるのでしょうか?
Reactコンポーネントはとてもシンプルな場合があります。そんなに単純ならテストなど必要ないのではないでしょうか。
なぜテストをするのか
それぞれのコンポーネントには、簡単なテストだとしても、テストをする価値が多かれ少なかれあります。一目で明らかかもしれませんが、それでも期待通りにコンポーネントが動いているという確信が得られますし、後で自信を持ってリファクタリングを行えます。
リファクタリングを行えるということは大切です。ユーザ名とメールアドレスをレンダリングするシンプルなコンポーネントのテストを行えば(一例として)、将来的に、コンポーネントをバラバラにしても、それぞれが正しく機能するという確信を持ってリファクタリングを行えます。
どのようにテストを行うのか
私たちが多用するテクニックは、 シャローレンダリング です。
これは、コンポーネントをレンダリングする時に、第一階層の深さのコンポーネントだけをレンダリングする方法のことです。つまり、あるコンポーネントのみを”実行”し、その子は一つも”実行”しないということです。
例を挙げてみましょう。 name
と age
を保持する person
というオブジェクトがあるとします。こちらがpersonを表示するためのコンポーネントです。
let Person = ({person}) => (
<span>
<Name person={person}/>
<Age person={person}/>
</span>
)
シャローレンダリングを用いて実行すると、最終的に以下の要素が表示されます。 Name
と Age
が全く変わっていない点に着目してください。これらの内部は評価されていません。
<span>
<Name person={person}/>
<Age person={person}/>
</span>
これに対し、フル(ディープ)レンダリングを実行すれば、Reactは Name
と Age
を評価し、以下のような要素が表示されるはずです。
<span>
<span className="name">Dave</span>
<span className="age">32</span>
</span>
シャローレンダリングを使うと、どのように子コンポーネントが実装されているかを気にする必要がなくなるため、シャローレンダリングはとても有効な方法です。”モック”に少し似ていますが、モックとは異なりシャローレンダリングはコストをかけず利用できます。また、DOMも不要となります。
この例では、どのように Person
が動作するか、ということにテストを集中させることができます。 Person
の実装を Name
や Age
の動作と密接に結合させる必要はありません。
ディープレンダリングしたコンポーネントでテストを行い、ファーストネームだけだった Name
の実装を”ラストネーム、ファーストネーム”に変えていたら、どうなっていたでしょうか。 Person
の実装が何も変わっていないにも関わらず、 Person
のテストはアップデートが必要となったでしょう。
これが、コンポーネントのテストにシャローレンダリングばかりを使う理由です。
最近行った入力処理を扱うテストの中で、コンポーネント全体のレンダリングが必要となる場合がいくつかありました。 jsdom
のインストールや、 setup.js
ファイルが必要なのはそのためです。
テストの対象
必ずレンダリングされること 最低限、コンポーネントがエラーを出さずに、確実にレンダリングされるようにします。JSXの構文エラーがなく、全ての変数が定義されていることなどを確認します。レンダリングされたアウトプットがnullではないことを確認する程度の簡単なことでしょう。
アウトプットをテストする: 上記の”レンダリングする”というステップは、”正しいものをレンダリングする”ということです。任意のプロパティのセットにおいて、どんなアウトプットが予想されるでしょうか。 Person
はnameとageをレンダリングするでしょうか。または、nameと”TODO: age coming in v2.1″をレンダリングするのでしょうか。
状態をテストする: 全ての条件に対応できなければいけません。もしclassNamesが条件付きなら(例えば、enabled/disabled、success/warning/errorなど)、classNamesを決定するロジックがうまく働いているかを確認するテストが必要です。条件付きでレンダリングされた子にも同じことが言えます。例えば、ユーザがログインした時にだけ Logout
ボタンが現れるなら、必ずそれをテストしなければいけません。
イベントをテストする: コンポーネントが相互作用するなら(例えば input
または button
と onClick
、 onChange
、 onAnything
など)、そのイベントが期待通りに働き、 this
をバインディングすることも含めて、正しい引数と共に一意の関数を呼び出すかということを、重要なものに関してテストします。
エッジケースをテストする: 配列上で行う操作には、どんなものでも境界線上のケースが存在します。空の配列、1つの要素を持つ配列、25項目で切り捨てなければならないページ付けされたリストなどです。考えられる全てのエッジケースを試し、全てが正しく動作することを確認します。
テストの対象
私たちは、とてもシンプルな”リスト”のアプリケーションを構築しようとしています。項目を追加したり、項目のリストを閲覧したりできるアプリケーションです。
こういった単純な機能の集まりであっても、実装にはいくつかの方法があります。ボトムアップ方式かトップダウン方式です。
また、自分のアプリケーションを構築する際には、「UI優先」にするのか「データ優先」にするのか決めようと思うでしょう。つまり、最初は仮のデータを使って、見たいと思うUIを作るのか、それともデータ構造から着手して、データに合わせてUIを構築するのかということです。ここではUI優先の手法を採用します。
ここにUIのモックアップがあります。
コンポーネントに名前をつけてから、テストを始めましょう。
- BeerListContainer: 最上位のラッパーコンポーネント
- __InputArea:__inputとbuttonのラッパー
- input: ごく普通のHTML5のinputタグ
- button: ごく普通のHTML5のbutton
- BeerList: 項目のリスト(ルートは
ul
)- li: 各行は普通の
li
- li: 各行は普通の
始める前に、Githubから 完成リポジトリ をクローンしておくと、上手くいかない時に作業をチェックすることができます。
TDDのプロセスを開始する
ほとんど空のコンテナをレンダリングする、基本的なコードから始めましょう。
index.js
ファイルを開いて、ファイル全体を以下の内容に置き換えます。
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import {BeerListContainer} from './components';
ReactDOM.render(
<BeerListContainer/>,
document.querySelector('#root'));
この index.js
ファイルは、ルートコンポーネントをレンダリングする役割を担います。
コンポーネント自体は、 components.js
に記述します。ファイルを作成し、次のように入力します。
import React, { Component } from 'react';
export class BeerListContainer extends Component {
render() {
return <span>Beer!</span>
}
}
簡単にするために、この例題では、あらゆるものを1つのファイルに保存しておくことにします。皆さんが自分のコードを書く場合には、こうしたコンポーネントを別々のファイルに分けるでしょう。
なぜファイルを分けるのか疑問に思うかもしれません。どうして index.js
に全部まとめておかないのかと思うでしょう。その理由は、テストにコンポーネントを import
する必要があるからです。もし index.js
ファイルからインポートすることになれば、 ReactDOM.render()
が実行されます。そうすると(シャローレンダリングを使うので)テストではほとんどDOMが不要であるにもかかわらず、DOMの有無に作業が左右されてしまいます。
始める前に、 quik
と mocha
を起動しましょう。そうすれば、テストのフィードバックが即座に得られ、それと同時にUIの挙動が分かります。
プロジェクトディレクトリに戻って、Quikを起動します。
quik
次に別のターミナルウィンドウを開いて、Mochaを起動します。
mocha --watch --require setup.js *.spec.js
ブラウザが開いて、「Beer!」と表示されるはずです。
それでは、最初のテストを記述しましょう。先ほど作成した 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);
});
});
InputArea
も BeerList
も未定義なので、このテストはすぐにエラーとなります。
ReferenceError: InputArea is not defined
修正を行う前に、このテストの処理の様子を見てみましょう。
最初に、必要な部品を全てインポートします。JSX( React.createElement
の呼び出しのためにコンパイルされます)を使っているので、Reactは必要です。また、コンポーネントと同様に、 expect
と shallow
もインポートします。次に mount
をインポートしていますが、これを使うのはもっと後になってからです。
JSXの表現である <BeerListContainer/>
に渡して shallow
を呼び出します。
BeerListContainer
に InputArea
と BeerList
を含めたいので、これらの子コンポーネントを wrapper.containsAllMatchingElements
でチェックします。
ここで気を付けてほしいのですが、コンテナをシャローレンダリングする場合でも、子コンポーネントの存在がチェックできるように、名前を定義しておく必要があります。今回は名前がまだ定義されていないために、テストはエラーになってしまいました。それでは修正しましょう。
components.js
に戻って、次の2つのコンポーネントを最後に追加します。
export class InputArea extends Component {
render() {
return <input/>
}
}
export class BeerList extends Component {
render() {
return <ul/>
}
}
最低限の内容だけを追加して、後で修正することにします。しかし、今度は子コンポーネントが存在するので、 components.spec.js
に戻って次の行を先頭のインポートの部分に追加します。
import { InputArea, BeerList } from './components';
ところが、テストはまだ通りません。エラーにはならないので前進したと言えますが、 BeerListContainer
を修正する必要があります。 components.js
に戻り、読み込む BeerListContainer
コンポーネントを次のように変更します。
export class BeerListContainer extends Component {
render() {
return (
<div>
<InputArea/>
<BeerList/>
</div>
);
}
}
これでテストは通ります。
シャローレンダリングの深さは1階層だけではないことに注意してください。実際には、全てのビルトインコンポーネント( div
や span
など)をレンダリングするものの、カスタムコンポーネントをレンダリングするには至りません。
確かめる場合には、 div
を他の div
でラップしても、テストに通るか試してみてください。
テスト2:コンテナの状態をテストする
コードの構築の見地からすると、コンテナがリストの管理、つまり状態の管理やリストへの項目追加といった役割を担うのが望ましいと言えます。子コンポーネントへ作業を進める前に、そうした機能性について確認しましょう。
最初、リストには、項目を持たない空の配列が格納されています。 components.spec.js
に、次のようにテストを記述します。
describe('BeerListContainer', () => {
...
it('should start with an empty list', () => {
const wrapper = shallow(<BeerListContainer/>);
expect(wrapper.state('beers')).to.equal([]);
});
});
これはエラーになります。
Cannot read property ‘beers’ of null
初期化していないため、コンポーネントの state
はnullです。
BeerListContainer
にコンストラクタを追加して、状態を初期化する必要があります。 components.js
に戻りましょう。
export class BeerListContainer extends Component {
constructor(props) {
super(props);
this.state = {
beers: []
};
}
...
}
与えられたプロパティを用いて super
を呼び出すのはいい考えなので、そのようにします。これを保存してからテストを実行すれば、今度は通るはずです。
ところが、今度は別のエラーになってしまいました。
AssertionError: expected [] to equal []
これは、 ===
演算子を使ってオブジェクトの等価性をテストする .equal
を使ったためです。2つの空の配列は同じオブジェクトではないので、完全に等しいとは言えなかったのです。
その代わりに eql
を使えば、テストは通ります。 components.spec.js
内のエクスペクテーションを以下のように変更します。
expect(wrapper.state('beers')).to.eql([]);
これでテストは通りました。
テスト 3:アイテムを追加する
これで、コンテナに空のリストができたので、そのリストにアイテムを足すための道筋を作りましょう。
コンテナはリストの状態の保持を担うことを覚えておいてください。ここに、後で InputArea
に渡すことになる addItem
関数を持たせます。
components.spec.js
内に、存在しない addItem
関数のテストを追加してください。
describe('BeerListContainer', () => {
...
it('adds items to the list', () => {
const wrapper = shallow(<BeerListContainer/>);
wrapper.addItem('Sam Adams');
expect(wrapper.state('beers')).to.eql(['Sam Adams']);
});
});
addItem
は存在しないので失敗し、次のメッセージが表示されます。
wrapper.addItem is not a function
そこで、その関数を、 components.js
に追加します。
export class BeerListContainer extends Component {
...
addItem(name) {
// do nothing for now
}
...
}
まだテストにパスしません。同じエラーが表示されました。
wrapper.addItem is not a function
shallow(<BeerListContainer/>)
から返されたオブジェクトが、実際は BeerListContainer
のインスタンスになっていない、と言っているわけです。しかし、 wrapper.instance()
で、クラスインスタンスには接続できます。その行、つまり以下のような行を
wrapper.addItem('Sam Adams');
以下のように変更します。
wrapper.instance().addItem('Sam Adams');
次は別のエラーが出ました。
expected [] to deeply equal [ ‘Sam Adams’ ]
そこで、 state
を内部の addItem
から更新することにします。 addItem
を次のように変えましょう。
export class BeerListContainer extends Component {
...
addItem(name) {
this.setState({
beers: [].concat(this.state.beers).concat([name])
});
}
...
}
テストに通りました。
以上のような配列の更新方法は見慣れないかもしれません。こうすることで、既存の状態を誤って変化させたりせずに済むのです。 state
の変化を避けるのを習慣にしたいものです。Reduxを使っている、使う予定がある際はなおさらそれが重要でしょう。常に確実にレンダリングビューを現在の状態と同期させることができるからです。
Immutable.js のようなライブラリを使用すると、上述のような不変のコードの記述が容易です。このチュートリアルでは、なるべく複雑にしないためImmutable.jsを使いませんが、基礎を押さえた後に試してみる価値はあります。
テスト 4: 関数を渡す
コンテナの内部が全て正常に動作するようになったところで、 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);
});
});
InputArea
への参照を取得し、その onSubmit
プロパティに 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>
);
}
}
この時点までで、4つのテストに通りました。
テスト 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']);
});
});
すると失敗し、次のエラーが表示されます。
Cannot read property ‘setState’ of undefined
ES6のクラスをReactでそのまま使うのは、難しい場合があります。つまり、ここの場合の addItem
のようなインスタンスメソッドは、自動的にインスタンスにバインドされるわけではないのです。
ちなみに、ドットで関数を呼び出すのは、直接呼び出すことと同じではありません。
// Calls addItem, setting 'this' === theInstance
theInstance.addItem()
// Save a reference to the addItem function
let addItemFn = theInstance.addItem;
// Calls addItem, setting 'this' === undefined
addItem()
Reactでこの問題を解決するには、2つの一般的な方法があります。
- コンストラクタ内で、一度関数をバインドする。
- プロパティとして渡されるたびに毎回関数をバインドする。
1の方法のほうが良いので、ここでは1を使います。 BeerListComponent
( components.js
内)のコンストラクタを次のように修正します。
export class BeerListContainer extends Component {
constructor(props) {
super(props);
this.state = {
beers: []
};
this.addItem = this.addItem.bind(this);
}
...
}
末尾の新しい行が addItem
を一度で全てをバインドするので、これでテストに通ります。
テスト 6: InputAreaをポピュレートする
BeerListContainer
の処理が完了したので、 InputArea
の階層に移ります。既にコンポーネントは存在していますが、動作するものはほとんどありません。
InputArea
が input
と button
を含めるようテストを書きましょう。 components.spec.js
の中に、新規で最上階層の describe
ブロックを作ります。
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
見覚えがあるでしょうか。 state
が初期化されていない時に、テスト 2で出たエラーと同じです。
状態を初期化し、 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});
}
...
}
このようなコンストラクタを前に見てきました。 setText
メソッドは、通常のパターンを使用し、入力された新規の値で状態を更新します。
テストは再び失敗しますが、今回は別のエラーが出ます。
AssertionError: expected ” to equal ‘Resin’
これは、 input
がつながっていないためです。 setText
メソッドを onChange
プロパティとして渡し、 state
にあるテキストを value
プロパティとして渡す必要があります。
export class InputArea extends Component {
...
render() {
return (
<div>
<input value={this.state.text} onChange={this.setText}/>
<button>Add</button>
</div>
);
}
}
しかしこの変更の後も、まだ動きません。同じエラーが表示されます。
ただし、実行に失敗する場所は以前とは違います。状態をチェックする最初の expect
は問題なく実行できています。ところが次の行の expect
は実行に失敗しています。入力の value
プロパティが更新されていないからです。
随分話を戻しますが、入力のハンドリングには(シャローではなく)フルレンダリングが必要だと、本稿の冒頭で触れたことを思い出してください。いよいよそれを実行する時が来ました。テストを更新して、 shallow
ではなく mount
を呼び出すようにします。
describe('InputArea', () => {
...
it('should accept input', () => {
const wrapper = mount(<InputArea/>);
...
これでまた、テストは全部正常に完了するはずです。
テスト8: 「Add」ボタンを有効化する
この時点ではまだ「Add」(追加)ボタンは何も実行しません。これの修正に取り掛かりましょう。
ボタンがクリックされたら、 InputArea
へ渡される onSubmit
プロパティを呼び出す操作を実装したいのです。 addItem
関数が正常に動作することを検証するテストは既に書いたので、これは、アイテムをリストに追加する前に実装する機能の、最後のピースと言えます。
テストを書く前に、 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);
});
});
spyを作成して、 onSubmit
プロパティの呼び出しを追跡します。次はユーザーが値を入力する場面なので、状態を表す text
を設定し、ボタンをクリックします。最後に、spyが呼び出されること、しかも正しい値を使って呼び出されることを検証します。
当然ながら、テストは次のメッセージを表示して終了します。
AssertionError: expected false to equal true
ここで中間ハンドラ関数 handleClick
が必要になります。この関数はクリックに反応し、現在入力されているテキストで onSubmit
を呼び出します。これはコンストラクタとバインドする必要があります。その後、ボタン上の onClick
プロパティに渡されます。
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: リストのレンダリング
まずは、リストが「空」のケースを扱う場合をテストしましょう。これは BeerList
に対して実行する最初のテストなので、まず最上位階層の記述ブロックを新規作成し、テストを追加します。
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);
});
});
空のリストを使ったテストはパスしましたが、驚くほどのことではありません。 BeerList
コンポーネントは今のところ骨組みだけで、空の“`
“`タグが1個入っているだけだからです。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>
);
}
}
現時点で「未定義のアイテム」テストは失敗していますが、それ以外の2つのテストはパスしています。
TypeError: Cannot read property ‘map’ of undefined
この状況は当然です。なぜなら this.props.items
は未定義だからです。ここでは、以下の2つの問題があります。
items
に由来するコンポーネントのエラーは、未定義またはnullである。items
はpropTypes
内でのチェックを行っていない。
これらの問題を解決するには、 BeerList
レンダリング関数を修正し、 items
の値がtrueであることをチェックしてからレンダリングするようにします。また、末尾に 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
};
これで再び、全てのテストにパスするようになります。
コードは今度こそ正常に動作するでしょう。このテストをまだ開発環境サーバのQuik上で実行していた場合は、実環境のブラウザに切り替えます。その際、タブをリフレッシュして、リストにアイテムを幾つか追加する操作を試みなければならない場合があります。
「Add」をクリックしたのにアイテムが表示されない場合は、まずコンソールをチェックしてください。ただし次のような警告が表示されます。ここで items
を渡すのを忘れているからです。
Warning: Failed propType: Required prop items was not specified in BeerList. Check the render method of BeerListContainer.
これで、私たちが注目するべき場所が特定できました。
テスト12: アイテムのレンダリング
問題を解決する前に、失敗することが分かっているテストをあえて書いてみましょう。ここは components.spec.js
で、アイテムを幾つか指定して BeerListContainer
のディープレンダリングを実行すれば、アイテムが表示されることを確認したいところです。
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);
});
}
しかしテストは予測通り、次のメッセージを表示してエラーで終了します。
AssertionError: expected 0 to equal 2
この場合は、 BeerListContainer
を更新してビールを渡します。
export class BeerListContainer extends Component {
...
render() {
return (
<div>
<InputArea onSubmit={this.addItem}/>
<BeerList items={this.state.beers}/>
</div>
);
}
}
この最終テストにパスしたことで、アプリケーションの全機能が正常に実装されていることが証明されたはずです。Quikの自動更新のトリガーが作動しなかった場合はブラウザの表示を更新して、以上のコードが動作することを確認してください。
まとめ
ここまでやって、ようやく非常にシンプルな、しかし機能的なリストが完成しました。この方式の開発をさらに続ける際には、次に示す改善のアイデアが参考になるでしょう。
- 「追加」ボタンがクリックされたタイミングで入力ボックスをクリアする
- ユーザーが Enter を押すだけでアイテムを追加できるようにする
- リストの各アイテムの隣に評価を追加できるようにして、
BeerListContainer
コンポーネントの状態の追跡を継続する
いざ実行すると恐らく、本稿で触れていない事態に直面することでしょう。その場合、いつも頼りになるGoogle以外にも、公式ドキュメントが役立つことがあります。参考文献のリンクを以下に示します。
次のステップ
ここまでやれば、 ReactでのTDD はどう進めればいいか、感覚がだいぶつかめたと思います。次にやるべきこととしては、各自の実際の環境で試す以外にありません。「習うより慣れよ」とよく言いますが、TDDも例外ではないのです。
ここで説明した手法を使い、このようにシンプルなリストコンポーネントになるように改善し、TDDで、さらに野心的なコンポーネントをビルドすることにも挑戦してください。TDDを日常の作業ルーチンに組み込めば、慣れるにつれて実行の効率も上がるでしょう。そうすればコードの質も向上します。
TDDを導入したReactの世界へ皆さんがスムーズに移行するために、本稿がいくらかでも皆さんのお役に立つことを願っています。本稿に対する感想や質問があれば、どうぞ下記のコメント欄に書き込んでください。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa