背景
先日、React.jsで作ったアプリのベータ版をユーザーに公開した。ユーザーは早速このアプリを事務所の大画面に映し出して使ってくれているそうだ。ありがたや。ありがたや。
該当のReact.jsアプリは、これまでは機能があまり多くないこともあって単体テストは行わずにマニュアルでテストしてきたが、今後はユーザーが常時使用していることもあってテストを導入し、不具合があったらデプロイ前に気づくようにしたい。
概要
React.jsのテストは意外に標準というものが見当たらなかった(調べ方が甘いだけかもしれない)。
Airbnbが作ったEnzymeが人気のようだが、まずはシンプルに行きたい。それで必要になったら順次追加でライブラリを追加していく。
Jestによるテスト
Jestの導入についてはTesting React Appsに詳しく書いてある。ので、こっちを見てもらったほうが良いと思うのだが、自分なりにまとめることで頭の整理をしたい。
ちなみに npm
や yarn
はすでに導入済みのものとする。
導入
インストール
新規アプリの場合は、まずは以下のコマンドでReactアプリを作る。
$ create-react-app test-sample
$ cd test-sample
続いて必要なライブラリをインストール。
$ yarn add --dev jest babel-jest @babel/preset-env @babel/preset-react react-test-renderer
babel.config.jsの作成
アプリケーションのルートディレクトリに babel.config.js
を作る。
ちなみにこのファイルを作り忘れていると、 <Hello />
のところでJSXが解釈されずエラーが出る。
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
};
package.json
に、 yarn test
したらJestが実行されるように変更。
具体的にはscript
の test
の実行スクリプトをjest
に変更。
{
"name": "test-sample",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/preset-env": "^7.9.5",
"@babel/preset-react": "^7.9.4",
"babel-jest": "^25.3.0",
"jest": "^25.3.0",
"react-test-renderer": "^16.13.1"
}
}
クラスコンポーネントの実装とテスト
React.jsでコンポーネントを実装する場合、クラスコンポーネントで実装する方法とFunctionalコンポーネントで実装する方法がある。まずはクラスコンポーネントからやってみる。
テストされるクラスの作成
ここでは ./src
ディレクトリ以下に Hello
という、ごく単純なコンポーネントをクラスにより作成する。
import React from 'react';
export default class Hello extends React.Component {
render() {
return (
<div>
<p>Hello World!</p>
</div>
)
}
}
テストの作成
続いて、アプリケーションのルートディレクトリに test
ディレクトリを作って、そこにごく簡単なテストファイルを作る。
import React from 'react';
import Hello from '../src/Hello';
import renderer from 'react-test-renderer';
test('Hello', () => {
const component = renderer.create(
<Hello />
)
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
クラスコンポーネントのテスト
では実行してみよう。
$ yarn test
yarn run v1.22.4
$ jest
PASS test/Hello.test.js
✓ Hello (18ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 1 passed, 1 total
Time: 1.76s, estimated 2s
Ran all test suites.
Done in 2.99s.
テストが成功した。なお、初回のテスト実行で、 ./test/__snapshots__/
以下に出力のスナップショットが作られて、次回以降はこのスナップショットと、コンポーネントが出力するスナップショットが比較され、変化がなければOKとなる。
スナップショットテストについては、 Snapshot Testingに詳しい。
例えば、ここでちょっと実装側を変えてみよう。
import React from 'react';
export default class Hello extends React.Component {
render() {
return (
<div>
<p>Hello Japan!</p>
</div>
)
}
}
Hello World!
だったところを Hello Japan!
に変更した。
テストを実行してみる。
$ yarn test
yarn run v1.22.4
$ jest
FAIL test/Hello.test.js
✕ Hello (22ms)
● Hello
expect(received).toMatchSnapshot()
Snapshot name: `Hello 1`
- Snapshot - 1
+ Received + 1
<div>
<p>
- Hello World!
+ Hello Japan!
</p>
</div>
9 |
10 | let tree = component.toJSON();
> 11 | expect(tree).toMatchSnapshot();
| ^
12 | });
13 |
at Object.<anonymous> (test/Hello.test.js:11:18)
› 1 snapshot failed.
Snapshot Summary
› 1 snapshot failed from 1 test suite. Inspect your code changes or run `yarn test -u` to update them.
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 1 failed, 1 total
Time: 2.083s
Ran all test suites.
error Command failed with exit code 1.
テスト失敗。
上記出力の内、以下の部分を見るとわかるとおり、Hello World!
だったところが Hello Japan!
に変わったぞと怒っている。前回の出力と比較しているのだ。
<div>
<p>
- Hello World!
+ Hello Japan!
</p>
</div>
このままでは何度テストしても失敗する。これが意図しない変化なら実装側でなんらかの不具合が発生しており、実装側を修正する必要がある。仕様変更に伴うもので意図したものなら、テスト側をアップデートする必要がある。
その場合は以下を実行
$ yarn test --updateSnapshot
これにより、スナップショットが更新されている。確認してみよう。
$ cat ./test/__snapshots__/Hello.test.js.snap
exports[`Hello 1`] = `
<div>
<p>
Hello Japan!
</p>
</div>
`;
スナップショットが更新されている。これで再びテストを通過するようになった。
テスト側から値を渡してテストする
実装側に name
を渡せるようにする。
import React from 'react';
export default class Hello extends React.Component {
render() {
return (
<div>
<p>Hello { this.props.name } </p>
</div>
)
}
}
テスト側も値を渡すように変更する。
import React from 'react';
import Hello from '../src/Hello';
import renderer from 'react-test-renderer';
test('Hello', () => {
const component = renderer.create(
<Hello name="Neko" />
)
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
スナップショットを更新してテストを実行。
$ yarn test --updateSnapshot
$ yarn test
yarn run v1.22.4
$ jest
PASS test/Hello.test.js
✓ Hello (17ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 1 passed, 1 total
Time: 1.616s
Ran all test suites.
Done in 2.86s.
Functionalコンポーネントの実装とテスト
テストされるFunctionalコンポーネントの実装
./src/HelloFunc.js
に簡単なFunctionalコンポーネントを実装する。
import React from 'react';
export const HelloFunc = (props) => {
return (
<div>
<p>HelloFunc { props.name } </p>
</div>
)
}
テストを作成する
import React from 'react';
import { HelloFunc } from '../src/HelloFunc';
import renderer from 'react-test-renderer';
test('HelloFunc', () => {
const component = renderer.create(
<HelloFunc name="Neko" />
)
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
テストを実行
$ yarn test
yarn run v1.22.4
$ jest
PASS test/Hello.test.js
PASS test/HelloFunc.test.js
› 1 snapshot written.
Snapshot Summary
› 1 snapshot written from 1 test suite.
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 1 written, 1 passed, 2 total
Time: 2.281s
Ran all test suites.
Done in 3.50s.
テストを通過。HelloFunc
コンポーネントについては初めての実行なので新たなスナップショットが生成された。
テスト・スナップショット再生成時のファイル指定
yarn test
を実行すると全てのテストが走るが、特定のテストだけを行いたいばあいや、特定のコンポーネントのみスナップショットを更新したい場合は --testNamePattern [テスト名]
オプションをつけて実行すれば良い。
HelloFunc
テストのみ実行する場合
$ yarn test --testNamePattern HelloFunc
yarn run v1.22.4
$ jest --testNamePattern HelloFunc
PASS test/HelloFunc.test.js
Test Suites: 1 skipped, 1 passed, 1 of 2 total
Tests: 1 skipped, 1 passed, 2 total
Snapshots: 1 passed, 1 total
Time: 2.107s
Ran all test suites with tests matching "HelloFunc".
Done in 3.04s.
HelloFunc
で生成されるスナップショットのみ更新したい場合
$ yarn test --updateSnapshot --testNamePattern HelloFunc
Reduxを導入してテストする
とりあえずRedux関連の依存ライブラリをインストール
$ yarn add react-fetch redux-thunk redux react-redux
$ yarn add --dev @testing-library/react
続いて実装する。まずは ./src/redux/acrion.js
をつくる。
export function mapStateToProps(state) {
return state;
}
export function mapDispatchToProps(dispatch) {
return {
/* nameの値を変更する. */
changeName: (text) => {
dispatch( {type: 'CHANGE_NAME', name: text} );
}
}
}
つづいてReducerを。
const initialState = {
name: "Anonymouse"
}
export default function reducer(state = initialState, action) {
switch(action.type) {
/* nameを変更する */
case 'CHANGE_NAME':
return {
...state,
name: action.name,
};
default:
return state
}
}
ここで前の ./src/Hello.js
をコピーして以下のようにRedux流に実装する.
import React from 'react';
import { connect } from 'react-redux';
import { mapStateToProps, mapDispatchToProps } from './redux/action.js';
class HelloRedux extends React.Component {
render() {
return (
<div>
<p className="helloLabel">Hello { this.props.name } </p>
<a href="#" onClick={ () => this.props.changeName("NEKO") }>NEKO</a>
<br />
<a href="#" onClick={ () => this.props.changeName("INU") } >INU</a>
</div>
)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(HelloRedux);
この実装をテストするコードはこんな感じで良いのかな?
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from '../src/redux/reducer.js';
import HelloRedux from '../src/HelloRedux';
import renderer from 'react-test-renderer';
import {cleanup, fireEvent, render} from '@testing-library/react';
import { act } from '@testing-library/react-hooks';
const store = createStore(reducer);
afterEach(cleanup);
test('HelloRedux default output', () => {
const { container, getByText } = render(
<Provider store={ store }>
<HelloRedux />
</Provider>
);
expect(container).toMatchSnapshot();
});
test('HelloRedux click event', () => {
const { container, getByText } = render(
<Provider store={ store }>
<HelloRedux />
</Provider>
);
act(() => { fireEvent.click(getByText("NEKO")); });
expect(container).toMatchSnapshot();
act(() => { fireEvent.click(getByText("INU")); });
expect(container).toMatchSnapshot();
});
アプリであれば ./src/index.js
でやっていた処理を、ここではテストの中でやってやらなければならない。
更にボタンをクリックする前と、NEKO
リンクをクリックした後、INU
リンクをクリックした後の表示をチェックしている。
./test/__snapshots__/HelloRedux.test.js.snap
を見るとそれぞれの表示を表現するHTMLが出力されているからチェック。2回目以降はこのスナップショットとの比較でテストが行われる。
最後に
コンポーネントのスナップショットのテストは割と簡単に行えた。まずはこの辺から導入していきたい。
実際のアプリではReduxを導入しているので、Reduxの場合のテストについても調査の必要があるがそれはまた今度。
それと気になるのは ./test/__snapshots__
以下に作られたスナップショットファイルをバージョン管理に含めるかどうか。基本的な考え方として、ソースから生成されるものはgitなどのバージョン管理に含めないが、この場合は自動生成されたものとはいえ、テストの一部と考えてgit管理に入れておくのが良さそうだ。そうすれば他のメンバーの変更によりコンポーネントの出力が変わったときにテストが通らなくなり、メンバーはそれが自分のミスなのか、それとも正当な仕様変更によるものかで意図的な行動が取れるし、gitの差分を見ればどのような変更がなされたのか一目瞭然になる。