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を使って非同期テストについてはこちらを参照。