POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Dave Ceddia

本記事は、原著者の許諾のもとに翻訳・掲載しております。

最小限の設定の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-dom react-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. それぞれのテストが enzymechai 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のモックアップがあります。

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の有無に作業が左右されてしまいます。

始める前に、 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. コンストラクタ内で、一度関数をバインドする。
  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つの問題があります。

  1. items に由来するコンポーネントのエラーは、未定義またはnullである。
  2. 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 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。