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 を使ったテストも書きます。
参照したサイト・書籍
axios / axios
https://github.com/axios/axiosnode-nock / nock
https://github.com/node-nock/nockaxiosを乗りこなす機能についての知見集
https://qiita.com/inuscript/items/ccb56b6fc05aa7821c42Set a code/status for "Network Error"
https://github.com/axios/axios/issues/383@jest-environment jsdom not working
https://github.com/facebook/jest/issues/3280
目次
- axios、Nock をインストールする
- axios は JSONP をサポートしていないので、今回は郵便番号検索API ではなく OpenWeatherMap を使用する
- axios を使用したテスト用モジュールを作成する
- まずは Nock を使わずにテストを書いて実行してみる
- Nock を使ってテストを書いて実行する
- xhr-mock をインストールして、サーバ側をモック化+document.body.innerHTMLも使用するテストを書いてみる
手順
axios、Nock をインストールする
npm install --save axios
コマンドを実行して axios をインストールします。
npm install --save-dev nock
コマンドを実行して Nock をインストールします。
axios は JSONP をサポートしていないので、今回は郵便番号検索API ではなく OpenWeatherMap を使用する
郵便番号検索API を呼び出すならば JSONP に対応している必要があるのですが、axios で JSONP ってどうやるのか調べたところ、It seems axios does not support jsonp call の Issue に「対応する予定はない」と書かれていました。。。 まあ CORS があるのに今さら JSONP に対応する必要はないですよね。最近 JSONP なんて使うのかな?とは思っていましたが、やっぱりそんな取扱いになるんだなと改めて認識しました。
今回のテストでは OpenWeatherMap を使うことにします。以下の手順でアカウントを新規登録して、API の Key を取得します。
- https://home.openweathermap.org/users/sign_up からアカウントを登録します。
- 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 }
テストを実行すると成功しました。
axios は Promise オブジェクトを返すので、then, catch を書いて then に処理がいくことを確認してみます。src/test/assets/tests/lib/util/OpenWeatherMapHelper.test.js に以下のように test("then, catch を記述して then に処理がいくことを確認する", () => { ... });
のテストを追加して、
テストを Debug 実行すると、なぜか catch の方で処理が止まりました。。。 エラーメッセージは Network Error
で、Node.js 環境で実行しているはずなのに request になぜか XMLHttpRequest が表示されています。どうも axios に Node.js 環境ではなくブラウザ環境と判別されているようです。
いろいろ調べたところ、以下のことが分かりました。
- 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
を以下のように追加します。
テストを Debug 実行すると、今度は then の方で処理が止まりました。
でもよく考えたら最初のテストも testEnvironment
の設定は jsdom
なのだから async / await を使っているとはいえ、XMLHttpRequest なのでは?と思い、@jest-environment docblock
を削除+ async / await を追加してから、
テストを Debug 実行してみると、then の方で処理が止まり、かつ使用しているのは XMLHttpReqeust のようです。
@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(""); });
テストを実行すると成功しました。
ちなみに expect(document.getElementById("weather").textContent).not.toBe("");
→ expect(document.getElementById("weather").textContent).toBe("");
に変更してテストを実行すると、テストがエラーになるので、天気(下の画像だと "Rain")がセットされていることが分かります。
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 は使いやすく、機能も豊富で便利そうです。
ちなみに 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秒を経過した時点でテストがタイムアウトしてしまいます。
また 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秒を経過した後にテストが終了しなかったと判断されます。
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秒でタイムアウトして、テストも成功します。
xhr-mock をインストールして、サーバ側をモック化 + document.body.innerHTML も使用するテストを書いてみる
Nock は便利なのですが、Nock を使用する場合 Jest を Node.js 環境にしないといけないため、document.body.innerHTML も一緒に使いたいテストが書けないのが少し難点です。サーバ側をモック化できて、かつ document.body.innerHTML も使えるモジュールがないか調べたところ xhr-mock というモジュールを見つけたので試してみます。
npm install --save-dev xhr-mock
コマンドを実行してインストールします。
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(); }); });
テストを実行すると成功しました。
xhr-mock も基本的なテストを書く分には書きやすく、動作上も特に問題はなさそうです。
履歴
2017/12/31
初版発行。
2018/01/01
* npm install --save-dev axios
→ npm install --save axios
に修正しました。