Zeals TECH BLOG

チャットボットでネットにおもてなし革命を起こす、チャットコマース『Zeals』を開発する株式会社Zealsの技術やエンジニア文化について発信します。現在本ブログは更新されておりません。新ブログ: https://medium.com/zeals-tech-blog

React+Redux環境に『Jest』と『 Enzyme』を導入し、フロントエンドでのテスト環境を構築しました

f:id:zeals-engineer:20190930124820p:plain みなさんこんにちは!
Zealsでフロントエンドエンジニアとしてインターンをしている栂瀬といいます。

この度Zealsで、テスティングフレームワークのJestを導入しました!
背景や導入にあたっての取り組みなど、何を行なったかを詳しくご紹介していきたいと思います!

Jest & Enzyme とは

JestとはFacebookが開発しており、細かい設定なしで簡単にフロントエンドのユニットテストを行うことができるテスティングフレームワークです。

jestjs.io

EnzymeとはAirbnbが開発しており、Reactのテストコード記述を簡単にしてくれるテストユーティリティツールです。

Enzymeには、shallowレンダリングフルDOMレンダリングの機能が備わっています。
shallowレンダリングを行うことで、インスタンス化またはレンダーされていない子コンポーネントの振る舞いを心配することなくユニットテストを行うことができます。

airbnb.io

背景

フロントエンドにテストを導入した背景としては、以下の課題を解決することにありました。

  1. 手動でデバッグを行うのが大変で、本番にデプロイ後に発覚するバグが多かった
  2. デバッグの難しさゆえに、継続的・定期的にリファクタリングを行うことが難しかった

これまでは、エディタでコードを書き終わった後にブラウザでアプリを立ち上げ、実装の影響が及ぶ箇所を1つずつ手動でテストしていました。
リファクタリングに関しては特に難しく、リファクタリング前と後で挙動が全く同じになっていることを目視で確認しなければいけなかったため、抜け漏れが発生してエラーに繋がることもありました。

実際に動かしてみた

【準備】React Componentをテストしよう

ZealsではフロントエンドのフレームワークにReact.jsを使っており、1ファイル1Componentというルールでファイルシステムを構築しています。
そのため、テスト対象のComponentを決めたらそのファイルと同名の.test.js(ts)ファイルを作成し、そこにテストコードを書いています。

import React, { Component } from 'react';

class Sample extends Component {
  render() {
    return (
      <div>
        <h1> {title} </h1>
      </div>
    )
  }

}

export default Sample;
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

Enzyme.configure({ adapter: new Adapter() });

describe('Sample.jsx', () => {
  const defaultValues = {
    title: 'hoge',
  };

  const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />);

  test('show h1', () => {
    const component = shallow(makeComponent());
    expect(component.find('h1').text()).toEqual('hoge');
  })

})

実際のテストコードをご紹介

それでは基本的なテストコードを紹介していきます。

タグの有無をテスト

以下はフラグ(ここでは isOpenModal )の true / false によって、タグの表示が切り替わっているかをテストするコードの例です。

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

const defaultValues = {
  isOpenModal: false,
}

const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />); // 値の指定がなければdefaultValuesを設定

describe('When isOpenModal is true', () => {
  test('Modal should be displayed', () => {
    const wrappper = shallow(makeComponent({ isOpenModal: true })); // shallowを使用するためshallow wrapperを作成
       expect(wrappper.exists(Modal)).toBeTruthy();
  })
})

describe('When isOpenModal is false', () => {
  test('Modal should not be displayed', () => {
    const wrappper = shallow(makeComponent());
       expect(wrappper.exists(Modal)).toBeFalsy();
  })
})

イベントのテスト

eventのテストではMock Functionを使用します。
フロントエンドチームの方針で、イベントが正しくcallされているかどうかもテスト対象に含めています。

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from 'enzyme';
import Sample from './Sample';

const makeComponent = (args = {}) => (<Sample {...Object.assign({}, defaultValues, args)} />);

describe('execute event', () => {

  // onChangeイベントが呼ばれたかどうかをテストする
  test('execute onChange event', () => {
    const onChangeMock = jest.fn()
    const wrappper = shallow(makeComponent({ ValueIsChange: onChangeMock })); // テスト対象のComponentの関数にMock Functionを代入
     wrapper.find('input').simulate('change');
     expect(onChangeMock).toHaveBeenCalled();
  })

  // onClickイベントが呼ばれたかどうかをテストする
  test('execute onClick event', () => {
    const onClickMock = jest.fn()
    const wrapper = shallow(makeComponent({ toggleSomething: onClickMock }));
     wrapper.find('button').simulate('click');
     expect(onClickMock).toHaveBeenCalled();
  })

  // onClickイベントに `e.stopPropagation()` が効いているかのテスト
  test('execute onClick event', () => {
    const onClickMock = jest.fn()
    const wrapper = shallow(makeComponent());
     wrapper.find('button').simulate('click', { stopPropagation: () => { onClickMock() } });
     expect(onClickMock).toHaveBeenCalled();
  })

})

Reduxのテスト

Zealsではステート管理にReduxを使用しており、そちらのテストも行なっています。

reducerのテスト

reducerのテストコードが書かれたファイルに、initialStateとactionCreatorをimportをしているのがポイントです。

initialStateをimportしていることによりコードベースの変更による影響を防いでいます。
また、actionCreatorの責務は主にactionをreturnするというシンプルなものなので、reducerのテストに移譲して効率よくテストできるようにしています。

import { cancel, expand, setData } from './actions/sample' // action
import { initialState } from './reducers/sample'
import { sample as reducer } from './reducers'

// importしたinitialState
// export const initialState = {
//   isCanceled: false,
//   isExpanded: false,
//   data: {},
// };

describe('reducer', () => {

  // initialStateがそのまま返ってくるか
  test('initialState should be returned', () => {
    expect(reducer(undefined, {})).toEqual(...initialState);
  });

  test('handle CANCEL', () => { // CANCELはcancel reducer自身が呼ばれたことを検知するための識別子
    const action = cancel()
    expect(reducer(initialState, action)).toEqual({
        ...initialState,
       isCanceled: !action.isCanceled, // cancelが呼ばれたらisCancelがtrueになる想定
    });
  });

  test('handle EXPAND', () => {
    const action = modalExpand(true) // action内では引数の値をisExpandedに代入している
    expect(reducer(initialState, action)).toEqual({
        ...initialState,
        isExpanded: !action.isExpanded,
    });
  });

  test('handle SET_DATA', () => {
    const value = 'testValue'
    const action = setData(value)
    expect(reducer(initialState, action)).toEqual(Object.assign({}, initialState, { data: Object.assign({}, initialState.data, { sampleData: action.value }) }));
  });

テストコード勉強会

Zealsではフロントエンド全体のレベルを底上げするため、毎週末にフロントエンドチームで集まって勉強会を開催しています。

今回のテスト導入にあたって、最近はテストに焦点を当てた勉強会を開催していました。
テスト導入時に丁寧にメンバーにノウハウを共有しながら進むことで、属人化の防止やテスト規約に対する共通認識をチーム内で得ることが可能です。

進め方としては、フロントエンドの各メンバーは、毎週コードベース内の小さなComponentや小さなロジックのテストを書いてプルリクエストを出します。
そして毎週末に、全員でプルリクエストをディスプレイに映しながら、お気に入りのテストや学んだことを説明し、議論します。

GitHub上のPRに対してレビュー

勉強会のコンテンツ:shallow is faster than mount ??

勉強会ではレビューのほか、ペアプログラミングを実施したり、より大きなComponentのテストに取り組むなど様々なことを行なっています。
例えば最近では、Enzymeのドキュメントには shallowmount より速いと書かれていましたが、それがどのくらい速いのかを検証してみました。

Difference between Shallow, Mount and render of Enzyme · GitHub

以下で実際のコードと共に詳しく紹介していきます。

▼Demo.test.js▼

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from 'enzyme';
import Parent from './Parent'

Enzyme.configure({ adapter: new Adapter() });

const makeComponent = () => <Parent/>

describe('shallow', () => {
  test('test Parent by shallow', () => {
    const wrapper = shallow(makeComponent())
    for (let i=0, sum=10000; i<sum; i++) {
      expect(wrapper.find('h1').text()).toEqual('Parent');
    }
  });
});

describe('mount', () => {
  test('test Parent by mount', () => {
    const wrapper = mount(makeComponent())
    for (let i=0, sum=10000; i<sum; i++) {
      expect(wrapper.find('h1').text()).toEqual('Parent');
    }
  });
});

▼Parent.jsx▼

import React, { Component } from 'react';
import Child from './Child'

class Parent extends Component {
  render() {
    return (
      <div>
        <h1>Parent</h1>
        <Child></Child>
      </div>
    )
  }
}

export default Parent;

▼Child.jsx▼

import React, { Component } from 'react';

class Child extends Component {
  render() {
    return (
      <div>
        <h2>Child</h2>
      </div>
    )
  }
}

export default Child;

テスト結果1

mount の方が実行速度が早くなりました。

なので実験として、 shallowmount の実行速度の速さは実はComponentの構造に依存するのではないか という仮説のもと、稼働中のサービスで使われている複雑なComponent(複数のstyled Component・importしたComponent・関数・state・propsが含まれる)に対して shallowmount で同じテストを行って実行速度を比べてみました。

ソースコードは載せることはできないのですが、以下のようなテストを実行しました。

▼Component.test.js▼

import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from 'enzyme';
import Component from './Component';

Enzyme.configure({ adapter: new Adapter() });

const defaultValues = { title: '' };
const makeComponent = (args = {}) => (<Component {...Object.assign({}, defaultValues, args)} />);

describe('shallow', () => {
  test('shallow is faster', () => {
    const component = shallow(makeComponent({ title: 'my test' }));
    component.setState({ isOpen: true }); // isOpenがtrueの時だけtitleが表示される

    expect(component.find('.title').text()).toEqual('my test');
  })
})

describe('mount', () => {
  test('mount is slower', () => {
    const component = mount(makeComponent({ title: 'my test' }));
    component.setState({ isOpen: true });

    expect(component.find('.title').text()).toEqual('my test');
  })
})

すると以下のように shallow の方が速いという結果になりました。

テスト結果2

検証結果

単純なComponentのテストでは、速度だけで shallowmount を比べると(僅差ではありますが)なんと mount の方が速い結果が得られてしまいました。

しかし、アプリケーションにおいてはComponentが1番目の検証コード(Child.jsx)のような単純な構造ではないという前提のもと、ドキュメントでは shallow の方が速くなると書かれているのかもしれませんね。

勉強会の成果

勉強会を行なった結果として、エンジニアメンバーが新しいテスト文化に早く慣れることができました。

また、古いComponentと比較すると、ユニットテストを導入した新しいComponentでは事前に問題が可視化されてエラーが発生しにくいなどのテストの効果が目に見えて生まれていて、「テストコードなしでこれまでどうやってアプリケーション作ったのだろう」と思うほど、テストの効果が高いことを実感できました。

テスト導入のメリット

当初の課題についてですが、以下のように解決することができました。

  1. テストが自動化され、デバッグに必要なエンジニアの手間とバグが少なくなった
  2. リファクタリングが正しく行えたのかをエンジニアがプログラマブルに判断しやすくなった

実装後に挙動を確かめるにはコマンドを入力してテストを実行するだけで良いため、手動でテストする工数が大幅に減りました。

それにより次のタスクに取り掛かるのも早くなり、全体的な実装スピードの向上に繋がりました。
2に関しては、特に正確性の向上が著しかったですね。

今後の展望

今後のフロントエンドテストに関してですが、いわゆるTDD(テスト駆動開発) での開発をフロントエンドで進めていきたいと考えています。

そのために、勉強会やPR上での議論を繰り返すことで、エンジニアのテストに対する免疫をなくし、新しく入ってきていただいたエンジニアの方への積極的なノウハウ共有を行っていきたいと思います。

まとめ

今回はテストコードに関する取り組みを紹介しましたが、他にも課題にフロントエンダー全員で挑戦したりと、Zealsならではの取り組みがたくさんあります。今後もテックブログで、そのような取り組みを紹介していければと思います。

Zealsでは新しい技術に共に取り組みながら、開発を通して自分とチームを高めていけるメンバーを募集しています。 そんなエンジニアの皆さんのご連絡お待ちしております!

hrmos.co