ReactでTDD(テスト駆動開発)を始めよう : 環境構築からテスト作成、機能実装までの詳解ガイド

最小限の設定のTDD手法を使い、「何をテストすべきか?」から、よくある落とし穴の避け方まで、Reactコンポーネントをテストする方法を学びましょう。

導入

まず、Reactを触ったことがあり、更にはいくつかのテストも書いた経験があるとしましょう。それでも、コンポーネントをどうテストするのが最善なのか、よく分からないかもしれません。どこから始めるのでしょう。具体的には何をテストすればよいのでしょうか。

いくつかのReactコンポーネントは簡潔過ぎて、そもそもテストが必要なのかすらはっきりしません。

AngularからReactに乗り換えた人なら、テストには愛憎のような思いがあるかもしれません。

確かにAngularにはテストを支援するツールがたくさんありますが、同時にテストを書くのが難しくなる可能性があります。冗長ながら省略できない定型コードが多々ある上、$digestの呼び出しを忘れるとテストが当然パスすべきところで失敗する原因になるので、デバッグの時間がおびただしく増えてしまいます。

Reactのテストはそれよりも理解しやすく、Reactで行うTDDは、俊敏で高速な反復をキャプチャし、テストを楽しくします。

このチュートリアルではReactのみを中心に見ていきます。Reduxについては述べません。エコシステムというのは始めは押しつぶされそうになるほど巨大なトピックなので、まずは小さく始めましょう。

前提条件

  • Node.js(こちらnvmで取得可能)と、
  • npm(nodeにバンドルされています)

環境

何よりもまず、テスト環境が必要です。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-domreact-addon-test-utilsも使いますので、インストールしましょう。

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

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

npm install -g mocha

テストファイルではES6JSXを使いますので、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に特化したツールはこれだけです。

これらのツールがどのように連携して機能するかを以下に示します。

  1. mochaを、いくつかの引数と共にコマンドライン上で実行します。
  2. mochaがテストファイルを見つけ、変換します。
  3. mochaがJavaScript(今回の例ではES6)で書かれたテストを実行します。
  4. それぞれのテストがenzymechaiimportし、それらを使ってコンポーネントのレンダリングやアサーションの作成を行います。

これらのツールの役割は、テストを書き始めると、より明確になっていきます。

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

この記事の冒頭で、いくつかの動機づけについて述べました。なぜReactコンポーネントをテストし、更に重要な問題として、これらの一体何についてテストをする必要があるのでしょうか?

Reactコンポーネントはとてもシンプルな場合があります。そんなに単純ならテストなど必要ないのではないでしょうか。

なぜテストをするのか

それぞれのコンポーネントには、簡単なテストだとしても、テストをする価値が多かれ少なかれあります。一目で明らかかもしれませんが、それでも期待通りにコンポーネントが動いているという確信が得られますし、後で自信を持ってリファクタリングを行えます。

リファクタリングを行えるということは大切です。ユーザ名とメールアドレスをレンダリングするシンプルなコンポーネントのテストを行えば(一例として)、将来的に、コンポーネントをバラバラにしても、それぞれが正しく機能するという確信を持ってリファクタリングを行えます。

どのようにテストを行うのか

私たちが多用するテクニックは、シャローレンダリングです。

これは、コンポーネントをレンダリングする時に、第一階層の深さのコンポーネントだけをレンダリングする方法のことです。つまり、あるコンポーネントのみを”実行”し、その子は一つも”実行”しないということです。

例を挙げてみましょう。nameageを保持するpersonというオブジェクトがあるとします。こちらがpersonを表示するためのコンポーネントです。

let Person = ({person}) => (
  <span>
    <Name person={person}/>
    <Age person={person}/>
  </span>
)

シャローレンダリングを用いて実行すると、最終的に以下の要素が表示されます。NameAgeが全く変わっていない点に着目してください。これらの内部は評価されていません。

<span>
  <Name person={person}/>
  <Age person={person}/>
</span>

これに対し、フル(ディープ)レンダリングを実行すれば、ReactはNameAgeを評価し、以下のような要素が表示されるはずです。

<span>
  <span className="name">Dave</span>
  <span className="age">32</span>
</span>

シャローレンダリングを使うと、どのように子コンポーネントが実装されているかを気にする必要がなくなるため、シャローレンダリングはとても有効な方法です。”モック”に少し似ていますが、モックとは異なりシャローレンダリングはコストをかけず利用できます。また、DOMも不要となります。

この例では、どのようにPersonが動作するか、ということにテストを集中させることができます。Personの実装をNameAgeの動作と密接に結合させる必要はありません。

ディープレンダリングしたコンポーネントでテストを行い、ファーストネームだけだった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またはbuttononClickonChangeonAnythingなど)、そのイベントが期待通りに働き、thisをバインディングすることも含めて、正しい引数と共に一意の関数を呼び出すかということを、重要なものに関してテストします。

エッジケースをテストする:配列上で行う操作には、どんなものでも境界線上のケースが存在します。空の配列、1つの要素を持つ配列、25項目で切り捨てなければならないページ付けされたリストなどです。考えられる全てのエッジケースを試し、全てが正しく動作することを確認します。

テストの対象

私たちは、とてもシンプルな”リスト”のアプリケーションを構築しようとしています。項目を追加したり、項目のリストを閲覧したりできるアプリケーションです。

こういった単純な機能の集まりであっても、実装にはいくつかの方法があります。ボトムアップ方式かトップダウン方式です。

また、自分のアプリケーションを構築する際には、「UI優先」にするのか「データ優先」にするのか決めようと思うでしょう。つまり、最初は仮のデータを使って、見たいと思うUIを作るのか、それともデータ構造から着手して、データに合わせてUIを構築するのかということです。ここではUI優先の手法を採用します。

ここにUIのモックアップがあります。

UI Mockup

コンポーネントに名前をつけてから、テストを始めましょう。

  • BeerListContainer:最上位のラッパーコンポーネント
    • __InputArea:__inputとbuttonのラッパー
    • input:ごく普通のHTML5のinputタグ
    • button:ごく普通のHTML5のbutton
  • BeerList:項目のリスト(ルートはul
    • 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の有無に作業が左右されてしまいます。

始める前に、quikmochaを起動しましょう。そうすれば、テストのフィードバックが即座に得られ、それと同時に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);
  });
});

InputAreaBeerListも未定義なので、このテストはすぐにエラーとなります。

ReferenceError: InputArea is not defined

修正を行う前に、このテストの処理の様子を見てみましょう。

最初に、必要な部品を全てインポートします。JSX(React.createElementの呼び出しのためにコンパイルされます)を使っているので、Reactは必要です。また、コンポーネントと同様に、expectshallowもインポートします。次にmountをインポートしていますが、これを使うのはもっと後になってからです。

JSXの表現である<BeerListContainer/>に渡してshallowを呼び出します。

BeerListContainerInputAreaBeerListを含めたいので、これらの子コンポーネントを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階層だけではないことに注意してください。実際には、全てのビルトインコンポーネント(divspanなど)をレンダリングするものの、カスタムコンポーネントをレンダリングするには至りません。

確かめる場合には、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]

テストにパスさせるには、BeerListContainerrenderメソッドを修正し、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. コンストラクタ内で、一度関数をバインドする。
  2. プロパティとして渡されるたびに毎回関数をバインドする。

1の方法のほうが良いので、ここでは1を使います。BeerListComponentcomponents.js内)のコンストラクタを次のように修正します。

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

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

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

BeerListContainerの処理が完了したので、InputAreaの階層に移ります。既にコンポーネントは存在していますが、動作するものはほとんどありません。

InputAreainputbuttonを含めるようテストを書きましょう。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つの問題があります。

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

    これらの問題を解決するには、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の世界へ皆さんがスムーズに移行するために、本稿がいくらかでも皆さんのお役に立つことを願っています。本稿に対する感想や質問があれば、どうぞ下記のコメント欄に書き込んでください。