30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Fetch API を Stub にしてテストする

SPAなどは特にそうだと思うけど、fetchを使って非同期にAPIを叩き、その結果に基いて部分的にビューを書き換える、というのはよく行われる。

そういう時に面倒なのがテスト。
fetchwindowオブジェクトに入っているからテスト環境には存在しないし、そもそもAPIを叩こうにもクロスオリジンやら何やらで上手くいかないことが多い。

イベントが発火した際にAPIを叩きに行くか、APIからのレスポンスに基いてビューが書き換えられるか、ということをテストしたい場合、いちいちAPIと通信できるように環境を整えるのは手間が掛かり過ぎる。
こういうケースでは、fetchをスタブにしてしまったほうがよい。

テスト対象

以下のReactのコンポーネントについて、テストを書いていく。

import React from 'react';

export default class ApiRequest extends React.Component {
  constructor() {
    super();
    this.state = {result: 'ボタンを押してください。'};
    this.callApi = this.callApi.bind(this);
  }
  callApi(statusCode) {
    fetch(`https://httpbin.org/status/${statusCode}`).then(res => {
      this.setState({
        result: res.status >= 200 && res.status < 300 ? '成功' : '失敗',
      });
    });
  }
  render() {
    const {result} = this.state;
    return (
      <div>
        <button id="200" onClick={() => this.callApi(200)}>
          200を叩く
        </button>
        <button id="500" onClick={() => this.callApi(500)}>
          500を叩く
        </button>
        <div id="result">{result}</div>
      </div>
    );
  }
}

ボタンを押すとAPIを叩きに行き、そのレスポンスに応じてthis.stateを変更し、ビューが書き換わる。
仕組みとしては、よくあるものだと思う。

fetch がないと言われる

まずはこんな感じで書いてみる。

import assert from 'assert';
import React from 'react';
import {shallow} from 'enzyme';

import ApiRequest from '../ApiRequest';

describe('ApiRequest', () => {
  let wrapper;
  beforeEach(() => {
    wrapper = shallow(<ApiRequest />);
  });
  it('最初は「ボタンを押してください。」と表示されている。', () => {
    assert(wrapper.find('#result').text() === 'ボタンを押してください。');
  });
  it('200のボタンを押下すると「成功」と表示される。', () => {
    wrapper.find('#200').simulate('click');
    assert(wrapper.find('#result').text() === '成功');
  });
  it('500のボタンを押下すると「失敗」と表示される。', () => {
    wrapper.find('#500').simulate('click');
    assert(wrapper.find('#result').text() === '失敗');
  });
});

当然、失敗する。

  ApiRequest
    ✓ 最初は「ボタンを押してください。」と表示されている。
    1) 200のボタンを押下すると「成功」と表示される。
    2) 500のボタンを押下すると「失敗」と表示される。


  1 passing (37ms)
  2 failing

  1) ApiRequest 200のボタンを押下すると「成功」と表示される。:
     ReferenceError: fetch is not defined
  2) ApiRequest 500のボタンを押下すると「失敗」と表示される。:
     ReferenceError: fetch is not defined

なのでまず、グローバルオブジェクトにfetchを作る。
これは、個別のテストファイルに書くのではなく、設定ファイルか何かに書くのが一般的だと思う。

global.fetch = () => {};

もちろんこれだけではテストは通らず、TypeError: Cannot read property 'then' of undefinedと言われる。
ここから、fetchをスタブにしていく。

fetch をスタブにする

先程のテストコードを以下のように書き換えると、テストが動くようになる。

import assert from 'assert';
import React from 'react';
import {shallow} from 'enzyme';
import sinon from 'sinon';

import ApiRequest from '../ApiRequest';

describe('ApiRequest', () => {
  let wrapper;
  let stub;
  beforeEach(() => {
    wrapper = shallow(<ApiRequest />);
    stub = sinon.stub(global, 'fetch');
  });
  afterEach(() => {
    stub.restore();
  });
  it('最初は「ボタンを押してください。」と表示されている。', () => {
    assert(wrapper.find('#result').text() === 'ボタンを押してください。');
  });
  it('200のボタンを押下すると「成功」と表示される。', done => {
    stub.returns(Promise.resolve({status: 200}));
    wrapper.find('#200').simulate('click');
    setTimeout(() => {
      assert(wrapper.find('#result').text() === '成功');
      done();
    }, 0);
  });
  it('500のボタンを押下すると「失敗」と表示される。', done => {
    stub.returns(Promise.resolve({status: 500}));
    wrapper.find('#500').simulate('click');
    setTimeout(() => {
      assert(wrapper.find('#result').text() === '失敗');
      done();
    }, 0);
  });
});

fetchはPromiseベースなので、スタブにして、FulFilledなpromiseオブジェクトを返すようにすればいい。
Promiseを使っているため、doneを使わないと上手く動かないので注意が必要。

sinonやスタブについてはこちらを参照。

doneを使って非同期テストについてはこちらを参照。

Enzymeのv3でshallowすると自動的にcomponentDidMountが呼ばれる

Reactのバージョンを16に上げるのに伴いEnzymeのバージョンを3にしたら、一部のテストが壊れた。
調べたところ、v3からshallowの挙動が変わったことが原因だった。
https://github.com/airbnb/enzyme/blob/master/docs/guides/migration-from-2-to-3.md#lifecycle-methods

v2では、明示的に呼び出さない限りcomponentDidMountcomponentDidUpdateは実行されないが、v3ではshallowすると明示しなくても実行される。

v2の挙動

2.9.1で、挙動を確認してみる。

まず、テストの対象となるFooコンポーネントを作成した。

import React from 'react';

export default class Foo extends React.Component {
  constructor() {
    super();
    this.count = 0;
    this.history = [];
  }
  componentWillMount() {
    this.count = this.count + 1;
    this.history.push('componentWillMount');
  }
  componentDidMount() {
    this.count = this.count + 1;
    this.history.push('componentDidMount');
  }
  render() {
    this.count = this.count + 1;
    this.history.push('render');
    return <div>foo</div>;
  }
}

各メソッドが呼ばれる度にcountが増え、呼ばれたメソッドの名前がhistoryに追加されていく。

そのテストコードが以下。

import assert from 'assert';
import React from 'react';
import {shallow} from 'enzyme';

import Foo from '../Foo';

describe('Foo', () => {
  it('Lifecycle', () => {
    const wrapper = shallow(<Foo />);
    assert(wrapper.instance().count === 2);
    assert(wrapper.instance().history.length === 2);
    assert(wrapper.instance().history[0] === 'componentWillMount');
    assert(wrapper.instance().history[1] === 'render');
    wrapper.instance().componentDidMount();
    assert(wrapper.instance().count === 3);
    assert(wrapper.instance().history[2] === 'componentDidMount');
  });
});

shallowした段階では、メソッドが2回呼ばれている。
まずcomponentWillMount、そしてrender
componentDidMountは、明示的に呼び出さない限り実行されない。

v3の挙動

続いて、3.2.0での挙動を見てみる。

ちなみにv3からは、アダプターの設定が必要になる。

// テストのための設定ファイル
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-15';

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

v3で先程のテストを実行すると失敗する。
次のように書き換えることで、パスするようになる。

describe('Foo', () => {
  it('Lifecycle', () => {
    const wrapper = shallow(<Foo />);
    assert(wrapper.instance().count === 3);
    assert(wrapper.instance().history.length === 3);
    assert(wrapper.instance().history[0] === 'componentWillMount');
    assert(wrapper.instance().history[1] === 'render');
    assert(wrapper.instance().history[2] === 'componentDidMount');
    wrapper.instance().componentDidMount();
    assert(wrapper.instance().count === 4);
    assert(wrapper.instance().history[3] === 'componentDidMount');
  });
});

shallowの時点でcomponentDidMountが呼びされており、wrapper.instance().componentDidMount();とするとさらにもう一度呼ばれていることが分かる。

対策

v2と同じ挙動にしたい場合、shallowを次のように書けばいい。

const wrapper = shallow(<Foo />, {disableLifecycleMethods: true});

テスト全体に適用させたい場合は、テストの設定ファイルなどに{disableLifecycleMethods: true}を書けばいい。
つまり、こうなる。

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