はじめに
Reactコンポーネントのテストは、与えられたpropsを元に意図した振る舞いをすることができているかでアサーションをすることが多いと思います。
この場合の振る舞いとは主に、stateの変更やDOMの変更を指します。
作るコンポーネントによっては、DOMの値を取得してstateを変更したりlocationやuserAgentなどのwindowオブジェクトの値を取得してレンダリングを制御したりといったことがあると思います。
上記のような少し手のこんだコンポーネントをテストする際、普通にenzymeのshallow関数などでレンダーしただけではテストできないケースが多々あります。
おそらく、コンソールには
○○ is not a function
TypeError: Cannot read property ○○ of undefined
などのエラーが出ていると思います。
enzymeでこれらのコンポーネントをテストできるように解説していきます!
使うモジュール
- mocha
- enzyme
- jsdom
- power-assert
- testdouble.js(モックライブラリ sinonと同等のことができます)
- testdouble-timers
power-assert,testdouble.jsは必須ではなく、僕の好みです。
実DOMにアクセスしているコンポーネント
実DOMにアクセスする場合、refを設定する必要があります。
React@16.3では、refの設定に3つの方法があります。
- stringRef
- RefCallback
- React.createRef
これらはそれぞれでテストの仕方が異なることに注意してください。
また、カバレッジを計測している場合、RefCallbackは実行しないとその部分がuncovered lineとされてしまうという地味な落とし穴があります。
stringRefを使っている人はもう殆どいないと思うので(さすがに)、RefCallbackとcreateRefのテストの仕方を説明します。
元コンポーネント
Ref Callback
import React from 'react';
class TestComp extends React.Component {
constructor() {
super();
this.state = { top: 0, bottom: 0};
}
componentDidMount() {
const {
top,
bottom,
} = this.divRef.getBoundingClientRect();
this.setState({ top, bottom });
}
render() {
const { top, bottom } = this.state;
return (
<div ref={ref => { this.divRef = ref; }}>
{`Top: ${top}, Bottom: ${bottom}`}
</div>
)
}
}
export default TestComp;
React.createRef
import React from 'react';
class TestComp extends React.Component {
constructor() {
super();
this.divRef = React.createRef();
this.state = { top: 0, bottom: 0};
}
componentDidMount() {
const {
top,
bottom,
} = this.divRef.current.getBoundingClientRect();
this.setState({ top, bottom });
}
render() {
const { top, bottom } = this.state;
return (
<div ref={this.divRef}>
{`Top: ${top}, Bottom: ${bottom}`}
</div>
)
}
}
export default TestComp;
テストコード
callbackRef
import React from 'react';
import TestComp from '../../src/components/TestComo';
import assert from 'power-assert';
import { shallow } from 'enzyme';
describe('TestComp', () => {
const wrapper = shallow(
<TestComp />,
{ disableLifecycleMethods: true },
);
it('クライアント座標が設定されているか', () => {
wrapper.find('div').getElement().ref({
getBoundingClientRect() { return { top: 100, bottom: 100 }},
});
wrapper.instance().componentDidMount();
wrapper.update();
const _div = wrapper.find('div').props().children;
assert.deepEqual(wrapper.state(), { top: 100, bottom: 100 });
assert(_div === 'Top: 100, Bottom: 100');
});
});
enzymeはReactコンポーネントの情報をオブジェクトで返却してくれますが、
この返却されるオブジェクトはレンダーされた状態になっています。
つまり、componentDidMountが実行された後のstate値を元にレンダーされたコンポーネントオブジェクトを返却します。
このテストコードは
disabledLifeCycleMethods: trueで、componentDidMountの実行を止めて
wrapper.find('div').getElement().ref()でRef Callbackを実行して
その際、ref()にgetBoundingClientRectという関数をオブジェクトとして定義する
これでレンダーする準備が整ったので、componentDidMountを実行して
update関数でコンポーネントの状態を更新する
ということをしています。
updateをしないと、divの描画している文字がTop 0, Bottom: 0
となってしまいます。
React.createRef
基本的に変わりませんが、getBoundingClientRectの設定の仕方が変わります。
before
wrapper.find('div').getElement().ref({
getBoundingClientRect() { return { top: 100, bottom: 100 }},
});
after
wrapper.find('div').getElement().ref.current = {
getBoundingClientRect() { return { top: 100, bottom: 100 }},
};
createRefで作成したref情報はcurrentプロパティの中に入りますので、currentに直接代入します。
今回はgetBoundingClientRectだけを例にとって説明しましたが、querySelectorなどのテストも同じ要領でテストすることができます。
node環境ではDOMのプロパティがないので、ないなら自分で作ってしまおう!のスタンスでいきましょう。
イベントオブジェクトへのアクセス
ボタンやテキストフィールドのアクションを受けてevent.stopPropagation()
やevent.preventDefault()
しているコンポーネントのテストの仕方です。
元コンポーネント
import React from 'react';
import TextFiled from 'material-ui/TextField'
class TestComp extends React.Component {
constructor() {
super();
this.state = { textValue: '' };
this._handleChange = this._handleChange.bind(this);
}
_handleChange(e, textValue) {
e.stopPropagation();
this.setState({ textValue });
}
render() {
return (
<TextFiled
id="TextField"
onChange={this._handleChange}
/>
);
}
}
export default TestComp;
テストコード
イベントハンドラの実行はenzymeのsimulate関数を使う他、instance関数から直接実行する方法の2通りがありますが
いずれの場合もイベントオブジェクトがありませんので、それを作成するようにします。
import React from 'react';
import TestComp from '../../src/components/TestComo';
import assert from 'power-assert';
import td from 'testdouble';
import { shallow } from 'enzyme';
describe('TestComp', () => {
const stopPropagation = td.function();
const e = { stopPropagation };
const wrapper = shallow(<TestComp />);
afterEach(() => {
td.reset();
});
it('onChangeからstateを変更することができるか', () => {
wrapper.simulate('change', e, 'hoge');
assert(wrapper.state.textValue === 'hoge');
td.verify(stopPropagation());
});
});
スタブしたstopPropagationをsimulate関数の第二引数にオブジェクトとして渡すことで、e.stopPropagationでアクセスすることができるようになります。
testdoubleの各メソッドは以下のことを行うことができます。
- td.function():関数のスタブを作成する(sinon.stubと同じ)
- td.verify():スタブした関数が実行されたかをアサーションする
- td.reset():スタブの解除
td.veridyはtestdoubleが提供する簡易なアサーションで、第一引数に与えられた関数通りに実行されたかを見てくれます。
なのでtd.verify(stopPropagation());
はstopPropagationが引数無しで実行されたかをアサーションしています。
setTimeoutを使ったコンポーネント
時間が絡むものは、テスト環境の時間をモックして時間を進めるようにしていきます。
元コンポーネント
例えば、ダブルクリックできないようにボタンを押してすぐにdisabledをかけて、数ms後に解除するコンポーネントがあったとします。
import React from 'react';
import RaisedButton from 'material-ui/RaisedButton';
class TestComp extends React.Component {
constructor() {
super();
this.state = { disabled: false };
this._handleClick = this._handleClick.bind(this);
}
_handleClick() {
this.setState({ disabled: true });
window.setTimeout(
() => { this.setState({ disabled: false }); },
);
}
render() {
return (
<RaisedButton
label="button"
disabled={this.state.disabled}
onClick={this._handleClick}
/>
);
}
}
export default TestComp;
テストコード
import React from 'react';
import TestComp from '../../src/components/TestComo';
import assert from 'power-assert';
import td from 'testdouble';
import timers from 'testdouble-timers';
import { shallow } from 'enzyme';
describe('TestComp', () => {
const wrapper = shallow(<TestComp />);
timers.use(td);
const clock = td.timers();
afterEach(() => {
td.reset();
});
it('ボタン押下で disabled => true となるか', () => {
wrapper.find('RaisedButton').simulate('click');
assert(wrapper.state().disabled);
});
it('300ms後に disabled => false となるか', () => {
assert(wrapper.state().disabled);
clock.tick(300);
assert(!wrapper.state().disabled);
});
});
testdouble-timersは、testdouble.jsで時間をモックするためのモジュールになります。
コードこそ違えど、sinonのuseFakeTimersと同等のことをしています。
windowオブジェクトへアクセスしているコンポーネント
consoleやlocalStorage、userAgentなどwindowオブジェクトは非常にたくさんありますが、これが中々の曲者で一筋縄ではいかないことが多いです。
node環境下ではグローバルオブジェクトはglobalオブジェクトでありwindowオブジェクトはありません。
jsdomやdominoでwindowオブジェクトを作ることができますが、
特定のwindowオブジェクトは作成されなかったり、書き換え不能になっているプロパティになっていたりします。
また、元のコンポーネントでwindowからアクセスするかどうかでテストの仕方が変わるものもあったります。
jsdomで作成したwindowオブジェクト
jsdomでwindowオブジェクトを作成した場合は以下のことに注意してください。
- localStorageとsessionStorageは作成されない
- userAgentの書き換えができない
- locationの書き換えができない
- matchMediaがない
基本的に、ないプロパティはmochaの設定ファイルにglobal.window.innerWidth = 200
などのように直接作ることで使えるようになります。
localStorageとsessionStorage
localStorageとsessinoStorageはmock-local-storageというモジュールを使えば作ることができますが、
これはglobalオブジェクトの直下にストレージプロパティを作成します。
import mockStorage from 'mock-local-storage';
require(mockStorage);
localStorage.setItem('hoge', 'hogehoge');
localStorage.getItem('hoge'); // 'hogehoge'
window.localStorage.getItem('hoge'); // TypeError: Cannot read property 'getItem' of undefined
ですので、このようにwindowからアクセスするかどうかでテスト結果が変わるようになってしまいます。
winodwからアクセスさせたい場合は
global.window.localStorage = mockStorage;
global.window.sessionStorage = mockStorage;
windowオブジェクトにmockStorageを代入してあげればアクセスできるようになります。
ちなみに、getItem,setItem,removeItem,clearという関数を持つオブジェクトもしくはクラスを作成することでも同じことを実現できます。
必ずしも、モジュールに頼る必要はないです。
console.error
例えば、意図していないpropsが渡されたらconsole.errorを実行するような実装にしていた場合はそれを確認したいですね。
実行はされますが、そのエラー文はコンソールに出力されてテストコードで確認するすべがありません。
このような時、consoleオブジェクトをスタブすることで確認ができるようになります。
componentWillMount() {
if (!Array.isArray(this.props.todoList)) console.error('配列で渡せ');
}
このconsole.errorの中身を確認したい場合はtestdouble.jsでは以下のコードでアサーションができるようになります。
td.replace(console, 'error');
const props = { todoList: 'hoge' };
const wrapper = shallow(<TestComp {...props} />);
td.verify(console.error('配列で渡せ'));
td.replaceはその名の通り、対象のオブジェクトのプロパティを作り変えます。
このコードでは、console.errorがtesddouble用のオブジェクトに変換されています。
userAgent
先ほどのlocalStorageと同様に、userAgentもwindowからアクセスするかどうかでテストの仕方が変わります。
更に、userAgentはjsdomで作成することができますが、なんと上書き不可能なオブジェクトとして定義されています。
navigator.userAgent = 'windows';
console.log(navigator.userAgent); // 'windows'
window.navigator.userAgent = 'windows';
// TypeError: Cannot set property userAgent of #<Navigator> which has only a getter
エラーを見る限り、ゲッターしかないみたいですね。
大人しく元のコードからwindowを消せば楽ですが、どうしてもwindowからアクセスしたい場合はuserAgentを作り直してやりましょう。
class ChangeUserAgent {
constructor(userAgent) {
this.userAgent = userAgent || 'Macintosh;'
}
replace(agent) {
Object.defineProperty(global.window.navigator, 'userAgent', {
get() { return agent },
enumerable: true,
configurable: true,
});
}
reset() {
Object.defineProperty(global.window.navigator, 'userAgent', {
get() { return this.userAgent },
enumerable: true,
configurable: true,
});
}
}
export default ChangeUserAgent;
これでreplace関数に変更したいuserAgentを与えれば変更することができるようになります。
location
今までは、作り変えたりモックを作成することでテストしてこれましたが、locationが1番厄介です。
loactionに関しては、完璧にテストする方法が分かっていないです・・・。
userAgent同様、こちらも書き換え不能なオブジェクトですが、変更する方法が1つだけあります。
jsdom.reconfigure({ url: 'http://hogehoge.co.jp' });
jsdom.reconfigure
という関数を通してのみlocation情報を書き換えることができます。
ただ、飽くまでこのメソッドのみが書き換えることができるだけですのでlocation.search = '';
やlocation.assing(url)
などを実行できるようになるわけではないので注意してください。
Object.getOwnPropertyDescriptor(globalw.window.location, 'search')
// {
// get: [Function: get],
// set: [FUnction: set],
// enumerable: true,
// configurable: false
// }
configurableがfalseとなっているので、作り変えることもできません。(userAgentはtrueなので作り変えることができる)
location.assignはtestdouble.jsでreplaceすることで動くようにはなりますが。。。