Edited at

Reactコンポーネントのテストを書いてみた

More than 1 year has passed since last update.


最初に

Reactのテストの良い技法を紹介するものではありません。

私が手探りで、Reactコンポーネントのテストを書いた奮闘記です。


事の始まり

Reactのテスト書いてみたいな。

コンポーネントもクラスなのだから、他の言語と大してやることは変わらないはず!?

TDD風に実践したら、何か掴めるかもしれない。


実験用のコンポーネントを考える

私のReactコンポーネントのイメージは、


  • render

  • state(constructor)

  • props

  • イベントの関数

で構成されているイメージなので、それを全て内包しているものを考える。

ので、


  • ボタン

  • ボタンの活性|非活性が存在する(state)

  • ボタンの表示名やクリック時の処理は外からもらう(props, 関数)

とかでどうじゃろか。


実験


テストツールを入れる

別になんでもよかったのですが、Reactのテストということもあり、

Facebook製のJestを使ってみることに。

npm install -D jest babel-jest babel-preset-env babel-preset-react react-test-renderer


.babelrc

{

"presets": ["env", "react"]
}


jest.config.js

module.exports = {

transform: {
'^.+\\.js?$': 'babel-jest'
}
};

scriptsも足しておきます。


package.json

"scripts": {

"jest": "jest"
},


ボタンであることのテスト

enzymeとやらを使うとできそう。

https://facebook.github.io/jest/docs/ja/tutorial-react.html

http://airbnb.io/enzyme/docs/api/shallow.html


enzymeの準備

Reactのバージョンに合わせたAdapterもいるらしい。


インストール

npm install -D enzyme enzyme-adapter-react-16



シャローレンダリング

第1階層の深さのコンポーネントだけレンダリングする。

enzymeの場合、

shallow()を用いると、シャローレンダリングされたShallowWrapperオブジェクトを取得できるようです。

このオブジェクトをテストとして使うようです。


やってみよう


手順

①テストしたいコンポーネントを、ShallowWrapperオブジェクトにする

②ShallowWrapperオブジェクトからノードを抽出する

③Jestを使ってテスト


【その前に】ShallowWrapperでノードを抽出する話

ノードを抽出する関数は色々用意されているようですが・・・。

例を見る限り、


  • CSSセレクタを指定したfind()などでノードを抽出(これもまたShallowWrapper)

  • 抽出したオブジェクトはlengthを持っている

ので、このlengthの数で存在確認を行えばいいわけですね。


【その前に】Jestの基本的な使い方の話

Jestの基本的な使い方は、こんな感じ・・に見えます。


Jest

describe('テストスィート', () => {

test('テスト', () => {
// expect <TestTarget> to be <ExpectedValue>
expect(TestTarget).toBe(ExpectedValue);
});
});


書いてみよう


src/my-button.test.js

import React from 'react';

import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import MyButton from './my-button';

Enzyme.configure({ adapter: new Adapter() });

describe('view', () => {
test('button', () => {
const wrapper = shallow(<MyButton />); // shallowWrapper取得

const buttonWrapper = wrapper.find('input[type="button"]'); // ボタン取得

expect(buttonWrapper).toHaveLength(1); // ボタンの存在確認
});
});


で、テスト開始。

$ npm run jest

FAIL src/my-button.test.js
● Test suite failed to run

Cannot find module './my-button' from 'my-button.test.js'

2 | import Enzyme, { shallow } from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
> 4 | import MyButton from './my-button';
| ^
5 | import renderer from 'react-test-renderer';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });

at Resolver.resolveModule (node_modules/jest-resolve/build/index.js:210:17)
at Object.<anonymous> (src/my-button.test.js:4:1)

Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.165s, estimated 2s

失敗しました。ボタン用意していないですしね。

用意しましょう。


js

import React, {Fragment} from 'react';

export default () => (
<Fragment>
<input type="button" />
</Fragment>
);


もう一回テスト。

$ npm run jest

PASS src/my-button.test.js
view
✓ button (8ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.554s

通りましたヽ(*´∀`)/


propsとテスト

次に簡単そうなのはpropsに関してですかね!?(思い込み


単純なpropsのテスト

ボタンの表示名をコンポーネントのプロパティとして設定します。

ShallowWrapper.prop()でプロパティの値が取れそうです。

使ってみましょう。


src/my-button.test.js

import React from 'react';

import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import MyButton from './my-button';

Enzyme.configure({ adapter: new Adapter() });

describe('view', () => {
test('button', () => {
const value = "ボタンですよ";

const wrapper = shallow(<MyButton value={value} />);
const buttonWrapper = wrapper.find('input[type="button"]');

expect(buttonWrapper).toHaveLength(1);
expect(buttonWrapper.prop('value')).toBe(value);
});
});



src/my-button.js

import React, {Fragment} from 'react';

export default (props) => (
<Fragment>
<input type="button" value={props.value} />
</Fragment>
);


$ npm run jest

PASS src/my-button.test.js
view
✓ button (9ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.586s, estimated 2s
Ran all test suites.


propsのバリデーション

PropTypesで引っかかったら、Errorオブジェクトを返して、

それを検知するようなテストをかければいいなぁと思っていると。

こんな記事と出会いました。

ほほう、console.errorの処理を上書きするのですね。

というわけで以下のようにしました。

Errorが起こることを正常としたい場合、

expect().toThrow()が使えそうです。


src/my-button.test.js

    test('props.value is required', () => {

expect(() => {
shallow(<MyButton/>)
}).toThrow();
});


src/my-button.js

import React, {Fragment} from 'react';

import PropTypes from 'prop-types';

const MyButton = (props) => (
<Fragment>
<input type="button" value={props.value} />
</Fragment>
);

MyButton.propTypes = {
value: PropTypes.string.isRequired
};

export default MyButton;


$ npm run jest

PASS src/my-button.test.js
view
✓ button (9ms)
✓ props.value is required (1ms)

Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.434s
Ran all test suites.


関数のテスト

onClickのイベントにて、

propsとして定義された関数を実行する関数を用意してテストします。

定義する関数はMockを使い、呼び出されたかのテストをかいてみます。

テストスィート:functionを用意します。


src/my-button.test.js

describe('function', () => {

const mockObj = jest.fn();
const baseProps = {
value: 'button'
};

test('click', () => {
const props = Object.assign({}, baseProps);
props.onClick = mockObj;

const component = shallow(<MyButton {...props} />).instance();
component.click();

expect(mockObj.mock.calls.length).toBe(1);
});
});


shallowObject.instance()により、ReactComponentを抽出しています。

こうしないと、click()が実行できませんでした。

shallowObject.simulate()を使う方法もあるみたいですが、

こやつはonXXXXXイベントを実行しているだけに過ぎないみたいなので、

厳密に関数を実行しているわけではないと思い、不採用にしました。

テストします。

$ npm run jest

FAIL src/my-button.test.js
view
✓ button (10ms)
✓ props.value is required (1ms)
function
✕ click (5ms)

function › click

TypeError: component.click is not a function

37 | props.onClick = mockObj;
38 | const component = shallow(<MyButton {...props} />).instance();
> 39 | component.click();
| ^
40 | expect(mockObj.mock.calls.length).toBe(1);
41 | });
42 |

at Object.<anonymous> (src/my-button.test.js:39:19)

Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 passed, 3 total
Snapshots: 0 total
Time: 1.569s
Ran all test suites.

もちろん失敗です。click()を実装していませんので。

次に、click()を実装しますが、props.onClick()は内部で呼ばないようにします。


src/my-button.js

import React, {Component, Fragment} from 'react';

import PropTypes from 'prop-types';

class MyButton extends Component {
constructor(props) {
super(props);

this.click = this.click.bind(this);
}

render() {
return (
<Fragment>
<input type="button" value={this.props.value} />
</Fragment>
);
}

click() {
}
}

MyButton.propTypes = {
value: PropTypes.string.isRequired
};

export default MyButton;


テストします。

 FAIL  src/my-button.test.js

view
✓ button (9ms)
✓ props.value is required (1ms)
function
✕ click (7ms)

function › click

expect(received).toBe(expected) // Object.is equality

Expected: 1
Received: 0

38 | const component = shallow(<MyButton {...props} />).instance();
39 | component.click();
> 40 | expect(mockObj.mock.calls.length).toBe(1);
| ^
41 | });
42 |
43 | /*

at Object.<anonymous> (src/my-button.test.js:40:43)

Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 passed, 3 total
Snapshots: 0 total
Time: 1.515s, estimated 2s
Ran all test suites.

エラーの内容が変わりました。

モックの呼び出し回数で怒られています。

props.onClick()を呼びます。


src/my-button.js

    click() {

this.props.onClick();
}

テストします。

$ npm run jest

PASS src/my-button.test.js
view
✓ button (9ms)
✓ props.value is required (1ms)
function
✓ click (1ms)

Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.101s
Ran all test suites.

いい感じ。

ついでに、props.onClick()が指定されなくても動作するように、

テストと実装を行っておきます。


src/my-button.test.js

describe('function', () => {

const mockObj = jest.fn();
const baseProps = {
value: 'button'
};

test('click', () => {
const props = Object.assign({}, baseProps);
props.onClick = mockObj;

const component = shallow(<MyButton {...props} />).instance();
component.click();

expect(mockObj.mock.calls.length).toBe(1);
});

test('Not found props.onClick', () => {
const props = Object.assign({}, baseProps);

const component = shallow(<MyButton {...props} />).instance();

expect(component.click()).toBe(undefined);
});
});



src/my-button.js

    click() {

if ( this.props.hasOwnProperty('onClick') ) this.props.onClick();
}


stateのテスト

ボタンの活性|非活性をstateで管理して、

それを確認してみます。


src/my-button.test.js

describe('function', () => {

test('click', () => {
const props = Object.assign({}, baseProps);
props.onClick = mockObj;

const component = shallow(<MyButton {...props} />).instance();

component.click()
.then(() => {
expect(mockObj.mock.calls.length).toBe(1);
expect(component.state.disabled).toBe('disabled');
});
});

test('Not found props.onClick', () => {
const props = Object.assign({}, baseProps);

const component = shallow(<MyButton {...props} />).instance();

expect(component.click() instanceof Promise).toBe(true);
});
});



src/my-button.js

    render() {

return (
<Fragment>
<input type="button" value={this.props.value} disabled={this.state.disabled} onClick={this.click} />
</Fragment>
);
}

click() {
return new Promise(resolve => {
if ( this.props.hasOwnProperty('onClick') ) this.props.onClick();

this.setState({
disabled: 'disabled'
}, resolve());
});
}


さて、click()がPromiseを返すようになりました。

setState()が非同期であるため、expect()によるテストが失敗するからです。


setStateはめんどくさい

stateの確認はできましたが、

実際にボタンのdisabledが変わっているかどうかは上記では読み取れません。

そのため、なんとかReactComponentから抽出できないかと調べていましたが、

いい方法がありませんでした・・・。

shallowObject.simulate()でいける!と思ったのですが、

やはりsetState()の非同期性で引っかかります。

こちらの記事でも似たような話をしており、

simulate()使うんじゃないよとおっしゃっておりまする。

simulate()後に、

shallowObject.prop()でプロパティの確認をしたいのに・・・orz


結び

それっぽい体験はできましたが、疑問もいくつか残る内容になりました。

また、ライフサイクルメソッドや、子のカスタムコンポーネントの扱いも気になるところです。

(暇があればやらないと)

調べて行く途中で、似たようなことをすでにやっている記事があって、

最初からこっちを読めばよかったと嘆く時期がありました。

あと、なんとなくで選んだ割に、Jestがよかったです。

Reactに関しては、これよりいいテストツールがあるんですかね?

参考になる記事などあれば、誰か教えてください!


付録


flowはテスト用ではない?

propsのバリデーションを調べている時に、

今時はflow!PropTypesなんて古いぜ!と読んだので、使ってみました。

結論としては、

テストでは使えなさそう、でした。

実験手順は下記になります。


インストールや設定ファイルの作成

npm install -D flow-bin

node_modules/.bin/flow init

npm install -g flow-typed
flow-typed install



.flowconfig

[ignore]

.*/node_modules/.*

[include]

[libs]

[lints]

[options]

[strict]


テストファイルとコンポーネントファイルの最初に@flowをつけて


src/my-button.test.js

// @flow

import React from 'react';
・・・


src/my-button.js

// @flow

import React, {Fragment} from 'react';
・・・


flow実行

$ ./node_modules/.bin/flow

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ src/my-button.js:4:17

Missing type annotation for props.

1│ // @flow
2│ import React, {Fragment} from 'react';
3│
4│ export default (props) => (
5│ <Fragment>
6│ <input type="button" value={props.value} />
7│ </Fragment>

Found 1 error


いい感じに怒られました。

上記だとよくわかりませんが、実際の出力ではpropsの部分が赤字で強調されています。

指摘通り、propsに型付けしてみます。


src/my-button.js

// @flow

import React, {Fragment} from 'react';

export default (props: {value: string}) => (
<Fragment>
<input type="button" value={props.value} />
</Fragment>
);



flow実行

$ ./node_modules/.bin/flow

No errors!

jest-runner-flowtypeなるものがあったので、

これが使えないかと、ソース読みながら色々試したものの、

flowの結果をうまく捕まえてくれないので、

とりあえずnpm scriptsで繋げるだけにした。

(私の使い方がよくないのかもしれないけど・・)


package.json

"scripts": {

"jest": "flow ; jest"
},

いざ、必須チェックのテストを書くぞ!

と思ったのですが、


  • flowで失敗させるために、テストコードに無意味なコードを書く

  • flowを成功させると、テストコードは無意味になる

といった状況になり、使用を断念。


無意味なコード

  test('props.value is required', () => {

const wrapper = shallow(<MyButton/>);
});