jest で React component をテストする 

[jestで非同期関数をテストする - Qiita](https://qiita.com/sand/items/3394f8a1244bd08fca3f)
[jest で React component をテストする - Qiita](https://qiita.com/sand/items/af2af0766ca00558457d)
[jest で Redux をテストする - Qiita](https://qiita.com/sand/items/72e816ef9deb1b425cae)

---

jest で React component をテストするときは**スナップショットテスト**を使います。また**Enzyme**を使えば、jQueryの様にDOMアクセスが可能となり、DOMのテストが行えます。


# １．通常のJavaScriptのテスト

まずはjestの復習の意味を込めて、簡単なJavaScriptのテスト環境を構築します。[Getting Started](https://jestjs.io/docs/ja/getting-started)


テストディレクトリを作成しjestをインストールします。

```
mkdir snap-test
cd snap-test

yarn add --dev jest
```

package.jsonに以下を追加します。


```json:package.json
{
  "scripts": {
    "test": "jest"
  }
}
```


これで簡単なJavaScriptのテストが行えます。sum関数を定義し、テストしてみましょう。ちなみにjsファイルはディレクトリのトップに作成することにします。


```javascript:sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;
```

テストプログラムは以下のようにします。xxxxx.test.jsという名前を付ければjestは自動的にテストプログラムを探し出し実行してくれます。

```javascript:sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});
```

テストを実行します。

```
yarn test
```

以上でテストがpassした旨のメッセージが表示されます。



# ２．Reactのスナップショットテスト

[Snapshot Testing](https://jestjs.io/docs/ja/snapshot-testing) / [Testing React Apps](https://jestjs.io/docs/ja/tutorial-react)


スナップショットのテストではUI が予期せず変更されていないかを確かめることができます。スナップショットテストのシナリオは以下の3ステップになります。

1. 最初にテストを実行しスナップショットを作成し、作成されたUI(スナップショット)が正しいことを確認する
2. プログラムに何らかの修正加える
3. テストを実行し、UI(スナップショット)への予期せぬ影響がないことを確認する

意図的に、UI(スナップショット)の変更が生じるプログラム修正を行った場合、上の3番目のテストが、当然失敗します。この場合は1番目で作成したスナップショットの正当性は失われるので、新しいスナップショットを上書きします。

今回はCreate React Appを使わない方法を試したいと思います。
まずはreactのテストに必要なパッケージをインストールします。

```
yarn add react react-router-dom --exact
yarn add --dev jest babel-jest @babel/preset-env @babel/preset-react react-test-renderer
```

更新されたpackage.jsonを確認しておきましょう。babelが7、jestが24、の最新のバージョンになっていることに注意してください。

```json:package.json
{
  "devDependencies": {
    "@babel/preset-env": "^7.4.5",
    "@babel/preset-react": "^7.0.0",
    "babel-jest": "^24.8.0",
    "jest": "^24.8.0",
    "react-test-renderer": "^16.8.6"
  },
  "scripts": {
    "test": "jest"
  },
  "dependencies": {
    "react": "16.8.6",
    "react-router-dom": "5.0.1"
  }
}
```

babelの設定ファイルを作成します。（ babel.config.jsと.babelrcのどちらを使うべきか？ => [Configure Babel](https://babeljs.io/docs/en/configuration) ）

```babel.config.js
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};
```


以下がテスト対象のReactプログラムです。onMouseEnter と onMouseLeave のイベントでclassを 'hovered' と 'normal'　に変更します。この動作をスナップショットに記録するテストを書きます。


```jsx:Link.react.js
import React from 'react';

const STATUS = {
  HOVERED: 'hovered',
  NORMAL: 'normal',
};

export default class Link extends React.Component {
  constructor(props) {
    super(props);

    this._onMouseEnter = this._onMouseEnter.bind(this);
    this._onMouseLeave = this._onMouseLeave.bind(this);

    this.state = {
      class: STATUS.NORMAL,
    };
  }

  _onMouseEnter() {
    this.setState({class: STATUS.HOVERED});
  }

  _onMouseLeave() {
    this.setState({class: STATUS.NORMAL});
  }

  render() {
    return (
      <a
        className={this.state.class}
        href={this.props.page || '#'}
        onMouseEnter={this._onMouseEnter}
        onMouseLeave={this._onMouseLeave}
      >
        {this.props.children}
      </a>
    );
  }
}
```


上のLink.react.jsをテストするプログラムです。

重要なのは **react-test-renderer** の renderer 関数です。

* react-test-renderer の renderer で componentを作成・描画する
* component の toMatchSnapshotを使用し、最初にテストを走らせ成功したときに、\_\_test\_\_/\_\_snapshots\_\_にスナップショットを作成・保存する
* componentに変更があった時に再度テストを走らせ、保存されているスナップショットと今回のものを比較し同じであれば成功とする。


```jsx:__tests__/link.react.test.js
import React from 'react';
import Link from '../Link.react';
import renderer from 'react-test-renderer';

test('Link changes the class when hovered', () => {
  const component = renderer.create(
    <Link page="http://www.facebook.com">Facebook</Link>,
  );
  let tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  // 手動でcallbackを呼びます
  tree.props.onMouseEnter();
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  //  手動でcallbackを呼びます
  tree.props.onMouseLeave();
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});
```

スナップショットは以下のようになります。3回スナップショットを撮っていて、classが変化しているのがわかります。

```js:__tests__/__snapshots__/Link.react.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Link changes the class when hovered 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

exports[`Link changes the class when hovered 2`] = `
<a
  className="hovered"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

exports[`Link changes the class when hovered 3`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;
```



# ３．jest.mock()

Reactのテストでmockが必要な場合を見てみます。

jest.mock()を、**jest.mock(path, moduleFactory)** のようにmodule factory引数を与えて呼ぶことができます。 **module factoryはmockを返す関数です。** ちなみにmockとはテストに必要な部品の値を疑似的に作り出したものです。 => [ES6 Class Mocks](https://jestjs.io/docs/ja/es6-class-mocks)

以下のHeader.jsを単体で動作させると、「You should not use \<Link\> outside a \<Router\>」というエラーになります。\<Link\>は階層的に\<Router\>の中で呼ばれる必要があります。ですから、ここでは\<Link\>をmock化する必要があります。

```jsx:Header.js
import React from "react";
import Link from "react-router-dom/Link";
const Header = () => (
 <ul>
   <li>
     <Link to="/">Home</Link>
   </li>
   <li>
     <Link to="/about">Services</Link>
   </li>
   <li>
     <Link to="/topics">Contact Us</Link>
   </li>
   <li>
     <Link to="/topics">Login</Link>
   </li>
 </ul>
);
export default Header;
```


テストプログラムでは以下のようにして<Link>をmock化して、"link2"という文字列に置き換えます。

```js
jest.mock("react-router-dom/Link", () => "Link2");
```

以下がテストプログラムです。


```jsx:__tests__/Header.test.js
import React from "react";
import renderer from "react-test-renderer";
import Header from "../Header";

jest.mock("react-router-dom/Link", () => "Link2");
it("should render correctly", () => {
  const component = renderer.create(<Header />);
  expect(component.toJSON()).toMatchSnapshot();
});
```


テストを実行します。

```
yarn test
```

テストは成功し、以下のようなスナップショットが作成されます。"link2"という文字列に置換されているのがわかります。

```js:__tests__/__snapshots__/Header.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<ul>
  <li>
    <Link2
      to="/"
    >
      Home
    </Link2>
  </li>
  <li>
    <Link2
      to="/about"
    >
      Services
    </Link2>
  </li>
  <li>
    <Link2
      to="/topics"
    >
      Contact Us
    </Link2>
  </li>
  <li>
    <Link2
      to="/topics"
    >
      Login
    </Link2>
  </li>
</ul>
`;
```




# ４．Enzyme

React componentsは、通常は小さく、propsにのみ依存しているので、テストがしやすいという特徴があります。React componentsのテストには[Enzyme](https://airbnb.io/enzyme/)が推奨されています。

**Enzyme** は React Componentsのアウトプットをテストするために作られたJavaScriptのテストルールです。 JqueryがDOMを扱うようなAPIを提供してくれます。

reactやbabel、enzyme関連のパッケージをインストールします。enzymeを使うためにはreact-domが必要なことに注意しましょう。

```
yarn add react react-dom react-router-dom --exact
yarn add --dev jest babel-jest @babel/preset-env @babel/preset-react
yarn add --dev enzyme enzyme-adapter-react-16
```

babelの設定ファイルです。

```javascript:babel.config.js
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};
```



テストの対象となるHeader.jsです。

```javascript:Header.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import TodoTextInput from './TodoTextInput'

class Header extends Component {
  handleSave(text) {
    if (text.length !== 0) { // textが空ならモック関数は呼ばれない
      this.props.addTodo(text)
    }
  }

  render() {
    return (
      <header className="header">
        <h1>todos</h1>
        <TodoTextInput
          newTodo={true}
          onSave={this.handleSave.bind(this)}
          placeholder="What needs to be done?"
        />
      </header>
    )
  }
}

Header.propTypes = {
  addTodo: PropTypes.func.isRequired
}

export default Header
```


import エラーにならないよう、Header.jsから参照されているTodoTextInput.jsも適当に作っておきます。

```javascript:TodoTextInput.js
import React, { Component, PropTypes } from 'react';

class TodoTextInput extends Component {
  render() {
    const { placeholder } = this.props;
    return (
      <input
        placeholder={placeholder}
        type="text"
        autoFocuse
      />
    );
  }
}

export default TodoTextInput;
```


以下がjest+Enzymeを使ったテストプログラムです。

React componentのユニットテストを行うときのレンダリングは、Enzymeの**shallow()**を使います。統合テストで複数のcomponentをテストするときは、**mount()**を使います。shallow（浅い）はテスト内でコンポーネントをレンダリングする際に、紐づいた子コンポーネントを無視してくれます。mountは子コンポーネントも含めてフルレンダリングしてテストを行うときに使います。

今回はshallow()でユニットテストを行います。またjestのモック関数も利用します。 ==> [Mock Functions](https://jestjs.io/docs/ja/mock-function-api)


```javascript:Header.test.js
import React from 'react'
import Enzyme, { shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import Header from './Header'

//　Enzymeの設定
Enzyme.configure({ adapter: new Adapter() })

function setup() {
  const props = {
    // addTodoはjestのモック関数
    addTodo: jest.fn()
  }

  // Headerのshallowレンダリング : propsとしてaddTodoを渡す
  const enzymeWrapper = shallow(<Header {...props} />)

  return {
    props,
    enzymeWrapper
  }
}

describe('components', () => {
  describe('Header', () => {

    // EnzymeのDOM操作を利用したテスト
    it('should render self and subcomponents', () => {
      const { enzymeWrapper } = setup()

      expect(enzymeWrapper.find('header').hasClass('header')).toBe(true)

      expect(enzymeWrapper.find('h1').text()).toBe('todos')

      const todoInputProps = enzymeWrapper.find('TodoTextInput').props()
      expect(todoInputProps.newTodo).toBe(true)
      expect(todoInputProps.placeholder).toEqual('What needs to be done?')
    })

    // 主にjestのモック関数を利用したテスト
    it('should call addTodo if length of text is greater than 0', () => {
      const { enzymeWrapper, props } = setup()
      const input = enzymeWrapper.find('TodoTextInput')
      input.props().onSave('') // textが空だからaddTodoは呼ばれない
      expect(props.addTodo.mock.calls.length).toBe(0)
      input.props().onSave('Use Redux') // addTodoが1回呼ばれる
      expect(props.addTodo.mock.calls.length).toBe(1)
    })
  })
})
```

実行結果は以下の通りです

```
$ yarn test
yarn run v1.16.0
warning package.json: No license field
$ jest
 PASS  ./Header.test.js
  components
    Header
      ? should render self and subcomponents (33ms)
      ? should call addTodo if length of text is greater than 0 (3ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.601s
Ran all test suites.
Done in 4.83s.
```


今回は以上です。




# 参考記事
[Testing in React with Jest and Enzyme: An Introduction](https://medium.com/@rossbulat/testing-in-react-with-jest-and-enzyme-an-introduction-99ce047dfcf8)