はじめに
React、便利ですね。
初めはJSXの独特の書き味にパンチを食らいましたが、慣れてくると各Componentを小さく作れるので設計がしやすいのと、データの流れが想像しやすいのは実際に自分で実装してみても感じるところでした。
ただ、いくらReactでComponent単位で管理していたとしても各Componentの処理の依存度が高く複雑に書かれてしまっていてはせっかくの恩恵を受けられません。
そこで今回はFormを対象にして、実装していく中で肥大化していったComponentをリファクタリングしながらユニットテストを整備していくことで、コードを解きほぐしていこうと思います。
対象バージョン
- react: 16.4.2
- jest: 23.5.0
- enzyme: 3.4.1
- enzyme-adapter-react-16: 1.2.0
- sinon: 6.1.5
事前準備
事前にユニットテストが実行できる環境をセットアップします。
Facebook製のJavaScriptテストツール「Jest」の逆引き使用例
上の記事がjestのセットアップから逆引き使用例まで幅広く網羅されていてわかりやすいので、こちらを参照ください。
テスト対象
今回のテスト対象として、以下のようなFormを取り扱います。
import React from 'react';
export class ProjectForm extends React.Component {
constructor(props) {
super(props);
this.state = {
colors: [],
projectName: "",
selectedUsersOption: [],
selectedColorId: ""
}
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.fetchColors();
}
handleInputChange(event) {
if (event.target.name === "project_name") {
this.setState({ projectName: event.target.value });
}
if (event.target.name === "color") {
this.setState({ selectedColorId: parseInt(event.target.value, 10) });
}
}
handleSubmit() {
console.log("submit button pushed");
console.log(this.state.selectedColorId);
console.log(this.state.projectName);
}
fetchColors() {
fetch(/** fetch from api */)
.then(response => response.json())
.then(response => {
this.setState({ colors: response.colors });
})
.catch(error => console.log(error));
}
render() {
return (
<div className="project-form">
<form action="javascript: void(0);" onSubmit={this.handleSubmit}>
<label>project name</label>
<input name="project_name" onChange={this.handleInputChange} />
<label>color</label>
<div>
{
this.state.colors.map(
(color) => {
return (
<label key={color.id} className="radio-label" style={{ background: color.code }}>
<input
type="radio"
name="color"
value={color.id}
onChange={this.handleInputChange} />
<div className="radio-check">
<div className="radio-check-icon">
<span className="icon-ok" />
</div>
</div>
</label>
);
}
)
}
</div>
<button type="submit">送信</button>
</form>
</div>
);
}
}
やりづらいところは主に
- 管理するstateの数が多い
- project_name, colorは別Componentで切り出して、ProjectForm内ではsubmitで取り扱う値だけを管理する形にしたい
- render()で描画するDOMがでかすぎる
- 描画するDOMに引っ張られてProjectForm内で管理する機能がどうしても肥大化してしまうため、分離させて修正しやすくしたい
の点が挙げられます。
今はただ単純なFormですが、今後Form内の項目が増えるほど1つのComponentの依存度が高くstateが管理しづらくなります。
Redux等を利用してstateを管理をしてもいいですが、このくらいの大きさのものならコードを整理すればうまく管理できそうなので、今回は素のReactを使って解きほぐしていきます。
テスト内容
まず、project_nameのinputタグに関してテストを記述していきます。
import React from 'react';
import { shallow } from 'enzyme';
import { spy } from 'sinon';
import InputProjectName from 'path/to/InputProjectName.js';
describe('InputProjectName', () => {
it('InputProjectName内にが存在すること', () => {
const wrapper = shallow(<InputProjectName />);
expect(wrapper.find('label').length).toBe(1);
expect(wrapper.find('input').length).toBe(1);
});
it('propsにproject名が渡された時にvalueに値がセットされること', () => {
const wrapper = shallow(<InputProjectName />);
wrapper.setProps({
projectName: 'project name',
});
expect(wrapper.find('input').prop('value')).toBe('project name');
});
it('inputが変化した時にonChangeイベントが発火すること', () => {
const onChange = spy();
const wrapper = shallow(<InputProjectName handleInputChange={onChange} />);
wrapper.find('input').simulate(
'change', {
target: {
value: 'a'
}
}
);
expect(onChange.calledOnce).toBe(true);
});
})
テスト内には最低限の
- Componentの存在可否
- タグ内に想定した属性が含まれるか
- eventが正しく実行されるか
を記述してテストを行っています。
このテストを実行した結果は以下の通りになります。
FAIL path/to/InputProjectName.test.js
InputProjectName
✕ InputProjectName内に必要タグが存在すること (17ms)
✕ propsにproject名が渡された時にvalueに値がセットされること (1ms)
✕ inputが変化した時にonChangeイベントが発火すること (1ms)
当然ですが、まだInputProjectNameを作っていないのでテストはこけます。
ここからこのテストをパスするComponentを作って行きます。
import React from 'react';
export default class InputProjectName extends React.Component {
constructor(props) {
super(props);
this.state = {
lebelText: 'project name',
name: 'project_name',
}
}
render() {
return (
<div className="project-name">
<label className="project-name-label">{this.state.lebelText}</label>
<input
className="project-name-input"
name={this.state.name}
value={this.props.projectName}
onChange={this.props.handleInputChange}
/>
</div>
)
}
}
再度テストを実行します。
結果は以下の通り
PASS path/to/project/form/InputProjectName.test.js
InputProjectName
✓ InputProjectName内にが存在すること (11ms)
✓ propsにproject名が渡された時にvalueに値がセットされること (3ms)
✓ inputが変化した時にonChangeイベントが発火すること (2ms)
テストが実行できてますね。
この流れでcolorのテストも作成してきます。
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import RadioColors from 'path/to/RadioColors.js';
describe('RadioColors', () => {
let stub;
beforeEach(() => {
stub = sinon.stub(global, 'fetch');
stub.returns(Promise.resolve({
colors: [
{ id: 1, code: '#000000' },
{ id: 2, code: '#000001' }
]
}));
});
afterEach(() => {
stub.restore();
});
it('stateにcolorをセットした時にRadioColorが増えること', () => {
const wrapper = shallow(<RadioColors />);
expect(wrapper.find('label').length).toBe(1);
wrapper.setState({
colors: [
{ id: 1, code: '#000000' },
{ id: 2, code: '#000001' }
]
})
expect(wrapper.find('RadioColor').length).toBe(2);
});
})
import React from 'react';
import { shallow } from 'enzyme';
import { spy } from 'sinon';
import RadioColor from './../../../../src/js/projects/RadioColor.js';
describe('RadioColor', () => {
it('RadioColor内に必要タグが存在すること', () => {
const wrapper = shallow(<RadioColor color={{ id: 1, code: '#000000' }} />);
expect(wrapper.find('input').length).toBe(1);
expect(wrapper.find('.radio-check').find('.radio-check-icon').length).toBe(1);
});
it('propsにcolor.codeが渡されてきた時にlabelのstyleに背景色が適応されること', () => {
const wrapper = shallow(<RadioColor color={{ id: 1, code: '#000000' }} />);
expect(wrapper.find('label').find('.radio-label').prop('style').background).toBe("#000000");
});
it('propsにcolor.idが渡されてきた時にラジオボタンのvalueに値が適応されること', () => {
const wrapper = shallow(<RadioColor color={{ id: 1, code: '#000000' }} />);
expect(wrapper.find('input').prop('value')).toBe(1);
});
it('選択されたinputのchecked属性はtrueになっているか', () => {
const wrapper = shallow(<RadioColor
color={{ id: 1, code: '#000000' }}
selectedColorId={1}
/>);
expect(wrapper.find('input').prop('checked')).toBe(true);
});
it('inputが変化した時にonChangeイベントが発火すること', () => {
const onChange = spy();
const wrapper = shallow(<RadioColor
color={{ id: 1, code: '#000000' }}
handleInputChange={onChange}
/>);
wrapper.find('input').simulate(
'change', {
target: {
value: 'a'
}
}
);
expect(onChange.calledOnce).toBe(true);
});
})
このテストの条件を満たすComponentを作って行きます。
import React from 'react';
import RadioColor from './RadioColor.js';
export default class RadioColors extends React.Component {
constructor(props) {
super(props);
this.state = {
labelText: 'color',
colors: [],
};
}
componentDidMount() {
this.fetchColors();
}
fetchColors() {
fetch(/** fetch from api */)
.then(response => response.json())
.then(response => {
this.setState({ colors: response.colors });
})
.catch(error => console.log(error));
}
render() {
return (
<div>
<label>color</label>
{
this.state.colors.map(
(color) => {
return (
<RadioColor
color={color}
handleInputChange={this.props.handleInputChange}
selectedColorId={this.props.selectedColorId}
/>
);
}
)
}
</div>
);
}
}
import React from 'react';
export default class RadioColor extends React.Component {
constructor(props) {
super(props);
}
isRadioSelected(colorId) {
return this.props.selectedColorId === colorId;
}
render() {
return (
<label className="radio-label" style={{ background: this.props.color.code }}>
<input
type="radio"
name="color"
value={this.props.color.id}
onChange={this.props.handleInputChange}
checked={this.isRadioSelected(this.props.color.id)} />
<div className="radio-check">
<div className="radio-check-icon">
<span className="icon-ok" />
</div>
</div>
</label>
);
}
}
再度テストを実行してみます。
PASS test/components/project/form/RadioColor.test.js
RadioColor
✓ RadioColor内に必要タグが存在すること (13ms)
✓ propsにcolor.codeが渡されてきた時にlabelのstyleに背景色が適応されること (2ms)
✓ propsにcolor.idが渡されてきた時にラジオボタンのvalueに値が適応されること (1ms)
✓ 選択されたinputのchecked属性はtrueになっているか (1ms)
PASS test/components/project/form/RadioColors.test.js
RadioColors
✓ stateにcolorをセットした時にRadioColorが増えること (35ms)
テスト成功してますね!
最終的に作成された初期コンポーネントは以下のようになります。
初期作られたComponentと比べると大分スッキリしました!
まとめ
今回簡単なFormを使ってリファクタしていくなかで、ユニットテストを整備しながらReactのコードを解きほぐしていきました。
Reactに限らずですが、こうやってテスト駆動で書くのはリファクタをしていく中でユニットテストを先に整備するかどうかは記述の時のスピードと安心感があっていいですね。
Reactまだ開発したことが無い人は是非試してみてください!
参考文献
Reactを使うとなぜjQueryが要らなくなるのか
Jest + enzymeで行うReactのUT(ユニットテスト )について | maesblog
Shallow Rendering · Enzyme