Jest+EnzymeでReactコンポーネントをテストする
JestはそもそもReactアプリをテストするためにあるのだと思うのですよ。作り手が同じFacebookだし、ドキュメントにもそういうことを言ってます。そこでJestでReactをテストしようとドキュメントにあたるとAirbnbの作るEnzymeを使うことを推奨されていました。FacebookとAirbnbの遭遇です。なんか面白い。
// package.json "jest": { "setupFiles": [ "./enzyme.setup.js" ], "transform": { "\\.(js|jsx)$": "babel-jest" } }, "devDependencies": { // 関係あるところ以外は省略... "enzyme": "^2.9.1", "jest": "^20.0.4", "jsdom": "^11.2.0", "react-test-renderer": "^15.6.1", }
enzyme、jsdom、react-test-rendererを新たにインストールします。私はReactが最新版なのでreact-test-renderer。そうでなければインストールドキュメントに従って違うものに変える。jest設定に"setupFiles"エントリを足します。
// enzyme.setup.js /* eslint-disable import/no-extraneous-dependencies */ const { JSDOM } = require('jsdom'); /* eslint-enable */ // material-uiのコンポーネントを描画するために必要 require('react-tap-event-plugin')(); const jsdom = new JSDOM('<!doctype html><html><body></body></html>'); const { window } = jsdom; function copyProps(src, target) { const props = Object.getOwnPropertyNames(src) .filter(prop => typeof target[prop] === 'undefined') .map(prop => Object.getOwnPropertyDescriptor(src, prop)); Object.defineProperties(target, props); } global.window = window; global.document = window.document; global.navigator = { userAgent: 'node.js', }; copyProps(window, global);
EnzymeのためにJsDOMをglobalにアタッチしてヘッドレスに描画できるようにします。これはJasumineでもやりました。伝統的手法ですね。react-tap-event-pluginもここで起こしておきます。
まずはテスト対象のReactコンポーネントのソース。
// NavigationComponent.jsx import React from 'react'; import PropTypes from 'prop-types'; import { Toolbar, ToolbarGroup, ToolbarTitle, Drawer, IconButton, MuiThemeProvider } from 'material-ui'; import NavigationMenu from 'material-ui/svg-icons/navigation/menu'; export default class extends React.Component { static propTypes = { fetchBooks: PropTypes.func.isRequired, title: PropTypes.string, vol: PropTypes.string, page: PropTypes.number, menuItems: PropTypes.element.isRequired, children: PropTypes.element.isRequired, }; static defaultProps = { title: '', vol: '', page: 0, }; constructor(props) { super(props); this.state = { open: false }; } componentWillMount() { this.props.fetchBooks(); } render() { return ( <MuiThemeProvider> <div> <Toolbar noGutter> <ToolbarGroup> <IconButton onTouchTap={() => this.setState({ open: true })}> <NavigationMenu /> </IconButton> <ToolbarTitle text={`${this.props.title} - ${this.props.vol} (${this.props.page})`} /> </ToolbarGroup> </Toolbar> <Drawer open={this.state.open} docked={false} onRequestChange={open => this.setState({ open })} > {this.props.menuItems} </Drawer> {this.props.children} </div> </MuiThemeProvider> ); } }
次いでテストのコード。
import React from 'react'; import { mount } from 'enzyme'; import Navigation from '../NavigationComponent'; describe('NavigationComponent', () => { test('<Navigation>', () => { const fetchBooks = jest.fn(); const navigation = mount( <Navigation title="aaa" vol="bbb" page={2} fetchBooks={fetchBooks} menuItems={<div>menu</div>} > <div>contents</div> </Navigation>); // componentWillMountでfetchBooksを実行しているか? expect(fetchBooks.mock.calls.length).toBe(1); // キャプション表示のテスト expect(navigation.find('ToolbarTitle').prop('text')).toBe('aaa - bbb (2)'); // ドロワーの開閉テスト expect(navigation.state('open')).toBeFalsy(); expect(navigation.find('Drawer').prop('open')).toBeFalsy(); // CSSセレクターモドキはReactコンポーネントをチェーンしたこんな書き方もできる。 navigation.find('Toolbar ToolbarGroup IconButton').simulate('touchTap'); expect(navigation.state('open')).toBeTruthy(); expect(navigation.find('Drawer').prop('open')).toBeTruthy(); }); });
Enzymeにはmountを用いてフルにDOMレンダリングしてます。レンダリング方法は他にshallowレンダリングとstaticレンダリングがありますが、親子で関係しあうコンポーネントのテストを行いたかったので、shallowでは一通り書いて見てダメだった。mountによるフルDOMレンダリングではshallowでできることができた上でそれ以上のことができるので、一つやり方を習熟するのを求めるのであればmountでしょう、きっと。
jest.fn()で作ったモックをcomponentWillMountでちゃんと呼ばれているかを確かめたり、イベントを発火させてステートの変化をテストしたりできます。
構造としてはこのようにReactコンポーネント-その描画内容、というようにツリーが作られますので、findで探す時に、ToolbarTitleとかIconButtonで探せます。全部描画してるのでdivなどHTMLタグでも探せ、検索結果で複数返って来たときは.first()や.last()、.at(index)などで探します。findで引数に渡すのはCSSセレクターモドキなのでかなり柔軟に探せるけど、CSSセレクターを完璧に実装しているわけじゃないみたいで、思ったように引っ掛けない時がありました。慣れるまではここだけちょっと難しい。ちなみにshallowレンダリングするとまるっきり構造の違うものが作られます。
結構簡単にReactコンポーネントのテストが書けます。しかし、テスト対象の完成ソースを見ながらならテスト書けるけど、テストファーストでの初回ラウンドでバッチリ書くのは難しいかな。