LoginSignup
58
47

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-06-26

最初に

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/>);
  });
58
47
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
58
47