プロローグ(読み飛ばしてください)

今は昔、ひとつのモダンなWebアプリ「りあくと君」というものがありました。レガシーさや人依存といったクラシカルな趣のあるシステムを開発・保守し続けた人々が一念発起して作り上げた近代的なWebアプリ、それがりあくと君でした。

ルーチンワークをしていた人々の心にはどす黒い「飽き」と「ストレス」が渦巻いていました。その負のエネルギーを全解放してつくられたりあくと君は大変賢く、斬新で、急成長を遂げる期待の星でした。一縷の望みを背負ったりあくと君でしたが、間もなくして成長はとまり、バグが増え、ソースの各所が絡まり、みるみるうちに業にまみれた存在となってしまいました。

そして、りあくと君は朽ちました。

エンジニア各々のオレオレ実装でツギハギまみれになりガチガチに仕様で固められて朽ちてしまったりあくと君……きみを蘇生させてみせる!! そう言って立ち上がった一人のエンジニアの物語が、今ここにはじまろうとしています。

まえおき

  • リファクタリングしたいのでまずはテストを用意しようと思いました
  • Facebook謹製のJestというオールインワンなツールでテストをします
  • Airbnb謹製のEnzymeというTest Utilitiesを使ってテストコードを書きます
  • もとのWebアプリはcreate-react-appでつくられています

create-react-app + Jest + Enzyme でテストを書きはじめようと思ったときにしたこと、つまづいたことを書き留めておく回です。

Jestの公式サイト内にもcreate-react-appを使った解説が載っているので、公式派の人はそちらをどうぞ。

Environments

  • React 15.4.2
  • create-react-app 0.8.5
  • Jest 17.0.2
  • Enzyme 2.8.2

での経験談を元にしています。

create-react-app のおさらい

コマンド一発でReactの開発環境をつくることができるツールです。
(参考:Reactを秒速で使い始められるcreate-react-appの使い方と使い心地)

これを使って構築した環境には標準でJestが組み込まれていて、yarn testコマンドですぐにテストを実行することができます。

Create React App uses Jest as its test runner. To prepare for this integration, we did a major revamp of Jest so if you heard bad things about it years ago, give it another try.

create-react-app/README.md at master · facebookincubator/create-react-app · GitHubより

Jestとは、Enzymeとは

Jest

  • Jest · 🃏 Delightful JavaScript Testing
  • Reactのテストをするためのツールです
  • 単体テストを書くためのfunction群と、テストを実行するテストランナーが含まれています(オールインワン)

Enzyme

  • Enzyme
  • Reactのテストコードを書くためのUtilityです
  • Jestだけではできない高度なテストを書くことができるfunction群です(Jestと重なる部分もあります)
  • Reactコンポーネントをrenderingするshallow mount renderの3つのfunctionがキモです

テストの書き始め方

1. テストファイルの配置場所を知る

Jestは任意のディレクトリからテストコードを探し出し順に実行してくれます。本来このディレクトリはpackage.jsonrootDirという名前で定義するものなのですが、create-react-appでつくられた環境ではsrc/と決められています。

// react-scripts/utils/createJestConfig.js
module.exports = (resolve, rootDir, isEjecting) => {
  // (省略)
  if (rootDir) {
    config.rootDir = rootDir;  // ← ここで引数をもとにrootDirを指定している
  }
  return config;
};

// react-scripts/scripts/test.js
argv.push('--config', JSON.stringify(createJestConfig(
  relativePath => path.resolve(__dirname, '..', relativePath),
  path.resolve(paths.appSrc, '..'), // ← ここで引数rootDirを指定している
  false
)));

したがって、src/にテストファイルを配置していきます。

2. テストファイルを作成する

Jestにテストファイルを認識させるには2つの方法があります。

  1. __test__というディレクトリ以下にテストファイルを置く
  2. .test.jsという拡張子のファイルを作成する

私は②を採用しました。

src
  ├ actions
  ├ components
  ︙    ├ CheckboxWithLabel.js  (Buttonコンポーネント)
        └ test
            └ CheckboxWithLabel.test.js  (Buttonコンポーネントのテスト)

3. テストコードを書く

Jestの各functionの使い方はこの記事が分かりやすかったです。   Facebook製のJavaScriptテストツール「Jest」の逆引き使用例 - Qiita

ここでは、「チェックボックスのOn/Offに応じてラベルの文言が切り替わるコンポーネント」のテストの実装例を挙げます。ソースを読んで雰囲気がつかめるかと思います(がいかがでしょう)。

src/components/CheckboxWithLabel.js

import React, { Component } from 'react';

export default class CheckboxWithLabel extends Component {

  constructor(props) {
    super(props);
    this.state = {isChecked: false};
    this.onChange = this.onChange.bind(this);
  }

  onChange() {
    this.setState({isChecked: !this.state.isChecked});
  }

  render() {
    return (
      <label>
        <input type="checkbox" checked={this.state.isChecked} onChange={this.onChange} />
        {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
      </label>
    );
  }
}

src/components/test/CheckboxWithLabel.test.js

import React from 'react';
import { shallow, mount } from 'enzyme';
import CheckboxWithLabel from '../CheckboxWithLabel';

describe('ChecboxWithLabel', () => {
  test('Changes the label after click', () => {
    // Componentをレンダリングする
    const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
    // expect(検査対象).toEqual(想定結果)
    expect(checkbox.text()).toEqual('Off');
    // shallowでレンダリングされた要素から特定のセレクタを取得する
    checkbox.find('input').simulate('change');
    // expect(検査対象).toEqual(想定結果)
    expect(checkbox.text()).toEqual('On');
  });
});

ここでつかったshallowという関数は、その名の通りComponentを浅くレンダリングします。Componentの中にComponentがある入れ子構造の場合、一番外側のComponentしかレンダリングされません。子コンポーネントに依存していない結果を検査することができます。

Shallow rendering is useful to constrain yourself to testing a component as a unit, and to ensure that your tests aren’t indirectly asserting on behavior of child components.

Shallow Renderingより

Enzymeを使ってスナップショットのテストをする

ここからはJest + Enzymeの話です。
これらを組み合わせることで、Componentのレンダリング結果をスナップショットとして残しておき、それと比較してレンダリング結果に差異がないかのテストをすることができます。

上記のCheckboxWithLabelでその例を書いてみます。

describe('CheckboxWithLabel', () => {
  test('Matches with the snapshot when checkbox is OFF', () => {
    const dom = mount(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
    expect(dom).toMatchSnapshot();
  });
});

toMatchSnapshot()は、既存のスナップショットと比較してレンダリング結果が一致しているかどうかを判定します。最初の一回目はsnapshotがないため必ずpassします。snapshotを更新する場合はjest --updateまたはjest -uコマンドを叩きます。

ここで使ったmountという関数は、Componentを完全にレンダリングします。その分テストの実行速度が遅くなるため、入れ子の深いComponentをmountしたり、mountを使ったテストをたくさん書くとなかなかテストが終わりません。 その場合はテストを分割して行うなどの工夫が必要でした。

Full DOM rendering is ideal for use cases where you have components that may interact with DOM APIs, or may require the full lifecycle in order to fully test the component (i.e., componentDidMount etc.)

Full DOM Renderingより

ハマったところ

🙅localStorage is not defined

テストするComponentが依存しているあるモジュールは、内部でlocalStorageを利用していました。そのためimport時にlocalStorage is not definedのエラーが起きました。

Jestはテスト実行前にテスト環境を構築するコードを実行することができるので、それを利用してlocalStorageモックを定義することで対処できます。一般的には、package.jsonsetupFilesまたはsetupTestFrameworkScriptFileとして定義したファイルがテスト前に実行されます。
(参考: Configuring package.json · Jest)

"jest": {
  "setupFiles": ["createLocalStorageMock.js"]
}

ただし、create-react-appを使っている場合はやはりこの設定が効かず、独自の設定ファイルsrc/setupTests.jsにモックを書く必要がありました。

src/setupTests.js

const localStorageMock = (() => {
  var storage = {};
  return {
    setItem: (key, value) => {
      storage[key] = value || '';
    },
    getItem: (key) => {
      return storage[key] || null;
    },
    removeItem: (key) => {
      delete storage[key];
    },
    get length() {
      return Object.keys(storage).length;
    },
    key: (i) => {
      var keys = Object.keys(storage);
      return keys[i] || null;
    }
  };
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });

🙅ReactTestUtils has been moved to react-dom/test-utils.

テスト結果に以下のワーニング文が表示されていました。

Warning: ReactTestUtils has been moved to react-dom/test-utils. Update references to remove this warning.

ReactTestUtilsの依存元が変わったってことだと思いReact公式を調べたところ、以下の対応をするように記載がありました。

import ReactTestUtils from 'react-dom/test-utils'; // ES6
var ReactTestUtils = require('react-dom/test-utils'); // ES5 with npm

言われるがままにためしたものの、Cannot find module 'react-dom/test-utils’と怒られる。いかがなものですかね。

ということで、今度はワーニング文のUpdate referencesにバカ正直に従い依存モジュールを丸々アップデートしてみました。

$ yarn cache clean
$ yarn upgrade

解決しちゃいました。

おわりに

リファクタしてまるっと書き直したい部分があったのですが、デグレしない保証をとる手段がなさそうだったのでひとまずテストを用意しよう! ということで書き始めたテストですが(遅い)、思った以上に融通が効くし書き心地が良くて充実したテストコーディングとなりました。とくにレンダリング結果をsnapshotで比較できるのは重宝しそうな感じです。

そんな感じでJestとEnzymeはよかったものの、create-react-appは便利な反面制約が多くハマりどころが多々あるので、ハマったときはこうして記録を残していこうと思います。

それでは。