かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( Jest + axios + Nock, xhr-mock でテストを書いてみる )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その44 )( Jest で setTimeout の処理のテストを書く ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Jest + axios + Nock を使ったテストを書きます。
    • Nock を使うと document.body.innerHTML を一緒に使うテストが書けないため、xhr-mock を使ったテストも書きます。

参照したサイト・書籍

  1. axios / axios
    https://github.com/axios/axios

  2. node-nock / nock
    https://github.com/node-nock/nock

  3. axiosを乗りこなす機能についての知見集
    https://qiita.com/inuscript/items/ccb56b6fc05aa7821c42

  4. Set a code/status for "Network Error"
    https://github.com/axios/axios/issues/383

  5. @jest-environment jsdom not working
    https://github.com/facebook/jest/issues/3280

  6. xhr-mock
    https://www.npmjs.com/package/xhr-mock

目次

  1. axios、Nock をインストールする
  2. axios は JSONP をサポートしていないので、今回は郵便番号検索API ではなく OpenWeatherMap を使用する
  3. axios を使用したテスト用モジュールを作成する
  4. まずは Nock を使わずにテストを書いて実行してみる
  5. Nock を使ってテストを書いて実行する
  6. xhr-mock をインストールして、サーバ側をモック化+document.body.innerHTMLも使用するテストを書いてみる

手順

axios、Nock をインストールする

npm install --save axios コマンドを実行して axios をインストールします。

f:id:ksby:20180101121102p:plain

npm install --save-dev nock コマンドを実行して Nock をインストールします。

f:id:ksby:20171230030805p:plain

axios は JSONP をサポートしていないので、今回は郵便番号検索API ではなく OpenWeatherMap を使用する

郵便番号検索API を呼び出すならば JSONP に対応している必要があるのですが、axios で JSONP ってどうやるのか調べたところ、It seems axios does not support jsonp call の Issue に「対応する予定はない」と書かれていました。。。 まあ CORS があるのに今さら JSONP に対応する必要はないですよね。最近 JSONP なんて使うのかな?とは思っていましたが、やっぱりそんな取扱いになるんだなと改めて認識しました。

今回のテストでは OpenWeatherMap を使うことにします。以下の手順でアカウントを新規登録して、API の Key を取得します。

  1. https://home.openweathermap.org/users/sign_up からアカウントを登録します。
  2. My Home の画面が表示されるので、「API Keys」のタブをクリックします。Key が表示されるのでコピーします。

取得した API Key は src/main/assets/js/lib/util の下に OpenWeatherMap.key.js を新規作成して、以下の内容を記述します。

module.exports = {

    appid: "ここに取得した API Key を記述します"

};

また git の管理対象外になるよう .gitignore を以下のように変更します。

..........

# Application File
**/OpenWeatherMap.key.js

axios を使用したテスト用モジュールを作成する

src/main/assets/js/lib/util の下に OpenWeatherMapHelper.js を新規作成し、以下の内容を記述します。

"use strict";

var axios = require("axios");
var openWeatherMapKey = require("lib/util/OpenWeatherMap.key.js");

var DEFAUL_TIMEOUT = 15000;
var timeout = DEFAUL_TIMEOUT;

module.exports = {

    /**
     * 内部の設定を初期値に戻す
     */
    init: function () {
        timeout = DEFAUL_TIMEOUT;
    },

    /**
     * タイムアウト時間を設定する
     * @param milliseconds
     */
    setTimeout: function (milliseconds) {
        timeout = milliseconds;
    },

    /**
     * 指定された都市の現在の天気情報を取得する
     * @param {string} cityName - 都市名
     */
    getCurrentWeatherDataByCityName: function (cityName) {
        return getAxiosBase().get("/data/2.5/weather", {
            params: {
                q: cityName,
                appid: openWeatherMapKey.appid
            }
        });
    }

};

/**
 * 共通設定が定義済の Axios オブジェクトを返す
 * @returns {Axios} OpenWeatherMap の API を呼び出す時の共通設定が定義済の Axios オブジェクト
 */
function getAxiosBase() {
    return axios.create({
        baseURL: "http://api.openweathermap.org",
        timeout: timeout
    });
}
  • timout のテストをする時にデフォルトの 15 秒のままでは長いので、時間を調整できるよう setTimeout メソッドと、初期値に戻すための init メソッドを用意しています。

まずは Nock を使わずにテストを書いて実行してみる

まずは簡単なテストを書いて動作確認してみます。src/test/assets/tests/lib/util の下に OpenWeatherMapHelper.test.js を新規作成し、以下の内容を記述します。

"use strict";

const openWeatherMapHelper = require("lib/util/OpenWeatherMapHelper.js");

describe("ZipcloudApiHelper.js のテスト", () => {

    test("OpenWeatherMap の API で東京の天気を取得する", async () => {
        const res = await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo");
        const json = res.data;
        expect(json.name).toBe("Tokyo");
        expect(json.weather.length).toBe(1);
        expect(json.weather[0]).toHaveProperty("main");
        expect(json.sys.country).toBe("JP");
    });

});

ちなみに http://api.openweathermap.org/data/2.5/weather?q=Tokyo&appid=... にアクセスすると以下のような JSON データが返ってきます。

{
  coord: {
    lon: 139.76,
    lat: 35.68
  },
  weather: [
    {
      id: 500,
      main: 'Rain',
      description: 'light rain',
      icon: '10d'
    }
  ],
  base: 'stations',
  main: {
    temp: 277.98,
    pressure: 1018,
    humidity: 44,
    temp_min: 277.15,
    temp_max: 279.15
  },
  visibility: 10000,
  wind: {
    speed: 3.1,
    deg: 30
  },
  clouds: {
    all: 90
  },
  dt: 1514690160,
  sys: {
    type: 1,
    id: 7607,
    message: 0.0058,
    country: 'JP',
    sunrise: 1514670628,
    sunset: 1514705850
  },
  id: 1850144,
  name: 'Tokyo',
  cod: 200
}

テストを実行すると成功しました。

f:id:ksby:20171231130141p:plain

axios は Promise オブジェクトを返すので、then, catch を書いて then に処理がいくことを確認してみます。src/test/assets/tests/lib/util/OpenWeatherMapHelper.test.js に以下のように test("then, catch を記述して then に処理がいくことを確認する", () => { ... }); のテストを追加して、

f:id:ksby:20171231130539p:plain

テストを Debug 実行すると、なぜか catch の方で処理が止まりました。。。 エラーメッセージは Network Error で、Node.js 環境で実行しているはずなのに request になぜか XMLHttpRequest が表示されています。どうも axios に Node.js 環境ではなくブラウザ環境と判別されているようです。

f:id:ksby:20171231131051p:plain

いろいろ調べたところ、以下のことが分かりました。

  • https://facebook.github.io/jest/docs/en/configuration.html#testenvironment-string を見ると、Jest の testEnvironment という設定項目はデフォルトが jsdom になっており、ブラウザ環境になるとのこと。Node.js 環境にするのは、この設定項目に node と設定する必要がある。
  • axios を Node.js 環境で実行するには、jest.config.json"testEnvironment": "node" を追加するか、テストのファイルに @jest-environment docblock を追加する。前者だと全てのテストが Node.js 環境になってしまうので、やるなら後者。
/**
 * @jest-environment jsdom
 */
  • @jest-environment jsdom not working の Issue によると、@jest-environment docblock はテスト単位で設定することはできず、必ずファイルの先頭に記述する必要があるとのこと。

src/test/assets/tests/lib/util/OpenWeatherMapHelper.test.js の先頭に @jest-environment docblock を以下のように追加します。

f:id:ksby:20171231132819p:plain

テストを Debug 実行すると、今度は then の方で処理が止まりました。

f:id:ksby:20171231132941p:plain

でもよく考えたら最初のテストも testEnvironment の設定は jsdom なのだから async / await を使っているとはいえ、XMLHttpRequest なのでは?と思い、@jest-environment docblock を削除+ async / await を追加してから、

f:id:ksby:20171231135018p:plain

テストを Debug 実行してみると、then の方で処理が止まり、かつ使用しているのは XMLHttpReqeust のようです。

f:id:ksby:20171231135302p:plain

@jest-environment docblock も async / await もない時にテストがなぜ失敗するのか、よく分かりません。。。 とりあえず axios のテストを Jest で書く時には async / await を必ず書きましょう、ということにしておきます。

天気を取得した後、then の方の処理で HTML に取得した天気をセットするテストも書いてみます。src/test/assets/tests/lib/util/OpenWeatherMapHelper.test.js に以下のテストを追加します。

    test("東京の天気を取得して div タグで囲んだ部分にセットする", async () => {
        document.body.innerHTML = `
            <div id="weather"></div>
        `;

        expect(document.getElementById("weather").textContent).toBe("");
        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                const json = response.data;
                document.getElementById("weather").textContent = json.weather[0].main;
            });
        expect(document.getElementById("weather").textContent).not.toBe("");
    });

テストを実行すると成功しました。

f:id:ksby:20171231140049p:plain

ちなみに expect(document.getElementById("weather").textContent).not.toBe("");expect(document.getElementById("weather").textContent).toBe(""); に変更してテストを実行すると、テストがエラーになるので、天気(下の画像だと "Rain")がセットされていることが分かります。

f:id:ksby:20171231140215p:plain

Nock を使ってテストを書いて実行する

Nock は Node.js の http requests モジュールをモック化するモジュールなので、テストファイルの先頭に @jest-environment docblock を記述して Node.js 環境でテストします。src/test/assets/tests/lib/util の下に OpenWeatherMapHelper.nodeEnv.test.js というファイルを新規作成し、以下の内容を記述します。

/**
 * @jest-environment node
 */
"use strict";

const openWeatherMapHelper = require("lib/util/OpenWeatherMapHelper.js");
const nock = require('nock');

describe("ZipcloudApiHelper.js + Nock によるテスト", () => {

    beforeEach(() => {
        // jest.setTimeout のデフォルト値である5秒に戻す
        jest.setTimeout(5000);
        openWeatherMapHelper.init();
    });

    test("Nock で 200 + コンテンツを返すと then の方で処理される", async () => {
        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .reply(200, {
                weather: [
                    {
                        id: 500,
                        main: 'Rain',
                        description: 'light rain',
                        icon: '10d'
                    }
                ],
                name: 'Tokyo'
            });

        const response = await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo");
        const json = response.data;
        expect(json.name).toBe("Tokyo");
        expect(json.weather.length).toBe(1);
        expect(json.weather[0]).toHaveProperty("main", "Rain");
    });

    test("Nock で 500 を返すと catch の方で処理される", async done => {
        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .reply(500);

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.response.status).toBe(500);
            });

        // test("...", async done => { ...}); と done を記述している場合には、テストの最後で done(); を呼び出さないと
        // 5秒待機した後、
        // Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.
        // のエラーメッセージを表示してテストが失敗する
        // ※https://facebook.github.io/jest/docs/en/asynchronous.html#callbacks 参照
        done();
    });

    test("Nock で 16秒で timeout させると catch の方で処理される", async done => {
        // openWeatherMapHelper の timeout の設定を変更せずに 15秒でテストする場合、
        // jest で async/await を使うと5秒くらいでタイムアウトさせられるので、30秒に延ばしておく
        jest.setTimeout(30000);

        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .delay(16000)
            .reply(200, {});

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
            });

        done();
    });

    test("Nock で 2秒で timeout させると catch の方で処理される", async done => {
        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .delay(2000)
            .reply(200, {});

        openWeatherMapHelper.setTimeout(1000);
        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
            });

        done();
    });

});

テストを実行すると成功しました。Nock は使いやすく、機能も豊富で便利そうです。

f:id:ksby:20171231214340p:plain

ちなみに test("Nock で 16秒で timeout させると catch の方で処理される", async done => { ... }); のテストで jest.setTimeout(30000);done(); を以下のようにコメントアウトしてから、

    test("Nock で 16秒で timeout させると catch の方で処理される", async done => {
        // openWeatherMapHelper の timeout の設定を変更せずに 15秒でテストする場合、
        // jest で async/await を使うと5秒くらいでタイムアウトさせられるので、30秒に延ばしておく
        // jest.setTimeout(30000);

        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .delay(16000)
            .reply(200, {});

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
            });

        // done();
    });

テストを実行すると OpenWeatherMapHelper.js で定義した 15秒のタイムアウト時間ではなく jest.setTimeout のデフォルト値である 5秒を経過した時点でテストがタイムアウトしてしまいます。

f:id:ksby:20171231221523p:plain

また jest.setTimeout(30000); だけコメントアウトを解除すると、

    test("Nock で 16秒で timeout させると catch の方で処理される", async done => {
        // openWeatherMapHelper の timeout の設定を変更せずに 15秒でテストする場合、
        // jest で async/await を使うと5秒くらいでタイムアウトさせられるので、30秒に延ばしておく
        jest.setTimeout(30000);

        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .delay(16000)
            .reply(200, {});

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
            });

        // done();
    });

今度は API 呼び出し自体は 15秒後にタイムアウトしていますが、jest は done(); が呼ばれるまでテストの終了を待つため、jest.setTimeout(30000); で設定した 30秒を経過した後にテストが終了しなかったと判断されます。

f:id:ksby:20171231222406p:plain

done();コメントアウトも解除すると、

    test("Nock で 16秒で timeout させると catch の方で処理される", async done => {
        // openWeatherMapHelper の timeout の設定を変更せずに 15秒でテストする場合、
        // jest で async/await を使うと5秒くらいでタイムアウトさせられるので、30秒に延ばしておく
        jest.setTimeout(30000);

        nock("http://api.openweathermap.org")
            .get(/^\/data\/2\.5\/weather/)
            .delay(16000)
            .reply(200, {});

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
            });

        done();
    });

15秒でタイムアウトして、テストも成功します。

f:id:ksby:20171231222933p:plain

xhr-mock をインストールして、サーバ側をモック化 + document.body.innerHTML も使用するテストを書いてみる

Nock は便利なのですが、Nock を使用する場合 Jest を Node.js 環境にしないといけないため、document.body.innerHTML も一緒に使いたいテストが書けないのが少し難点です。サーバ側をモック化できて、かつ document.body.innerHTML も使えるモジュールがないか調べたところ xhr-mock というモジュールを見つけたので試してみます。

npm install --save-dev xhr-mock コマンドを実行してインストールします。

f:id:ksby:20171231155727p:plain

src/test/assets/tests/lib/util の下に OpenWeatherMapHelper.xhrmock.test.js というファイルを新規作成し、以下の内容を記述します。

"use strict";

const openWeatherMapHelper = require("lib/util/OpenWeatherMapHelper.js");
const xhrmock = require('xhr-mock');

describe("ZipcloudApiHelper.js + xhr-mock によるテスト", () => {

    beforeEach(() => {
        // jest.setTimeout のデフォルト値である5秒に戻す
        jest.setTimeout(5000);
        xhrmock.setup();

        document.body.innerHTML = `
            <div>東京: <span id="weather"></span></div><br>
            <div id="error-msg"></div>
        `;
    });

    afterEach(() => {
        xhrmock.teardown();
    });

    test("xhr-mock で 200 + コンテンツを返すと then の方で処理される", async done => {
        xhrmock.get(/^http:\/\/api\.openweathermap\.org\/data\/2\.5\/weather/
            , (req, res) => {
                return res
                    .status(200)
                    .body({
                        weather: [
                            {
                                id: 500,
                                main: 'Rain',
                                description: 'light rain',
                                icon: '10d'
                            }
                        ],
                        name: 'Tokyo'
                    });
            });

        expect(document.getElementById("weather").textContent).toBe("");
        expect(document.getElementById("error-msg").textContent).toBe("");

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                const json = response.data;
                expect(json.name).toBe("Tokyo");
                expect(json.weather.length).toBe(1);
                expect(json.weather[0]).toHaveProperty("main", "Rain");
                document.getElementById("weather").textContent = json.weather[0].main;
            })
            .catch(e => {
                done.fail("catch に処理が来たらエラー");
            });

        expect(document.getElementById("weather").textContent).toBe("Rain");
        expect(document.getElementById("error-msg").textContent).toBe("");

        done();
    });

    test("xhr-mock で 500 を返すと catch の方で処理される", async done => {
        xhrmock.get(/^http:\/\/api\.openweathermap\.org\/data\/2\.5\/weather/
            , (req, res) => {
                return res
                    .status(500);
            });

        expect(document.getElementById("weather").textContent).toBe("");
        expect(document.getElementById("error-msg").textContent).toBe("");

        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.response.status).toBe(500);
                document.getElementById("error-msg").textContent = "エラー: HTTPステータスコード = " + e.response.status;
            });

        expect(document.getElementById("weather").textContent).toBe("");
        expect(document.getElementById("error-msg").textContent).toBe("エラー: HTTPステータスコード = 500");

        done();
    });

    test("xhr-mock で 2秒で timeout させると catch の方で処理される", async done => {
        xhrmock.get(/^http:\/\/api\.openweathermap\.org\/data\/2\.5\/weather/
            , (req, res) => {
                return res
                    .timeout(true);
            });

        expect(document.getElementById("weather").textContent).toBe("");
        expect(document.getElementById("error-msg").textContent).toBe("");

        openWeatherMapHelper.setTimeout(1000);
        await openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo")
            .then(response => {
                done.fail("then に処理が来たらエラー");
            })
            .catch(e => {
                expect(e.message).toContain("timeout");
                document.getElementById("error-msg").textContent = "エラー: timeout";
            });

        expect(document.getElementById("weather").textContent).toBe("");
        expect(document.getElementById("error-msg").textContent).toBe("エラー: timeout");

        done();
    });

});

テストを実行すると成功しました。

f:id:ksby:20171231220147p:plain

xhr-mock も基本的なテストを書く分には書きやすく、動作上も特に問題はなさそうです。

履歴

2017/12/31
初版発行。
2018/01/01
* npm install --save-dev axiosnpm install --save axios に修正しました。