かんがるーさんの日記

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

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( MobX を使用してみる2 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( MobX を使用してみる ) の続きです。

参照したサイト・書籍

  1. danielearwicker / computed-async-mobx
    https://github.com/danielearwicker/computed-async-mobx

目次

  1. autorun を複数定義するとどのように動作するのか?
  2. computed-async-mobx を使用して API を呼び出す非同期処理をクラス内に記述する
  3. action で API を呼び出す非同期処理をクラス内に記述する
  4. reaction で API を呼び出す非同期処理をクラス内に記述する
  5. reaction で複数のプロパティを監視するには [] で囲む

手順

autorun を複数定義するとどのように動作するのか?

mobx.autorun(...) を複数定義すると最後に定義されたものが最初に実行されるようです。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("autorun を複数定義すると最後に定義したもの→最初に定義したものの順に実行される", () => {
        document.body.innerHTML = `
            <div id="value"></div>
            <div id="data"></div>
            <div id="all"></div>
        `;

        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: "",
                    data: "",
                    all: mobx.computed(() => `${this.value}, ${this.data}`)
                });
            }
        }

        const sample = new Sample();
        mobx.autorun(() => {
            console.log("PASS1");
            $("#value").text(sample.value);
        });
        mobx.autorun(() => {
            console.log("PASS2");
            $("#data").text(sample.data);
        });
        mobx.autorun(() => {
            console.log("PASS3");
            $("#all").text(sample.all);
        });

        expect($("#value").text()).toBe("");
        expect($("#data").text()).toBe("");
        expect($("#all").text()).toBe(", ");

        console.log("★★★");
        sample.value = "1";
        expect($("#value").text()).toBe("1");
        expect($("#data").text()).toBe("");
        expect($("#all").text()).toBe("1, ");

        console.log("●●●");
        sample.data = "a";
        expect($("#value").text()).toBe("1");
        expect($("#data").text()).toBe("a");
        expect($("#all").text()).toBe("1, a");
    });

テストを実行すると PASS3 → PASS1、PASS3 → PASS2 と最後に定義されたものから順に実行されることが確認できます。

f:id:ksby:20180106204147p:plain

computed-async-mobx を使用して API を呼び出す非同期処理をクラス内に記述する

プロパティの値が更新されたら外部の API を呼び出して別のプロパティの値を更新する処理を mobx.autorunAsync(...) で定義してもいいのですが、mobx.computed(...) のようにクラス内で定義できると便利かなと思って調べたところ、danielearwicker/computed-async-mobx というモジュールを見つけましたので試してみます。

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

f:id:ksby:20180106205528p:plain

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加します。

    test("computed-async-mobx を使用するとクラス内に API 呼び出しの非同期処理を定義できる", done => {
        // xhr-mock でモックを定義する
        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"
                    });
            });

        document.body.innerHTML = `
            <div id="area">
                <input type="text" name="name" id="name" value="">
                <div id="weather"></div>
            </div>
        `;

        // API を呼び出す処理を mobx.autorunAsync(...) ではなく、
        // computed-async-mobx.asyncComputed(...) を使用してクラス内に定義する
        class Area {
            constructor() {
                mobx.extendObservable(this, {
                    name: "",
                    weather: computedAsyncMobx.asyncComputed("", 0, async () => {
                        console.log("PASS1-1");
                        if (this.name === "") {
                            return "";
                        }
                        console.log("PASS1-2");
                        console.log("(3) area.name が更新されたら OpenWeatherMap の API で天気を取得し、area.weather にセットする");
                        const res = await openWeatherMapHelper.getCurrentWeatherDataByCityName(this.name);
                        const json = res.data;
                        return json.weather[0].main;
                    })
                });
            }
        }

        const area = new Area();
        // area.weather が更新されたら $("#weather").text() にセットする
        mobx.autorun(() => {
            // area.weather ではなく area.weather.get() で値を取得する点に注意する
            console.log("(4) area.weather が更新されたら $(\"#weather\").text() にセットする");
            $("#weather").text(area.weather.get());
        });

        // $("#name").val() に入力された値を area.name にセットする
        $("#name").on("blur", event => {
            console.log("(2) $(\"#name\").val() に入力された値を area.name にセットする");
            area.name = $(event.target).val();
        });

        console.log("(1) $(\"#name\").val() に Tokyo と入力する");
        $("#name").val("Tokyo").blur();

        let doneFlg = false;
        setTimeout(() => {
            console.log("(5) $(\"#weather\").text() に Rain がセットされていることを確認する");
            expect($("#weather").text()).toBe("Rain");
            // なぜかここに done(); を書くと console.log("(5) ..."); のログが出力されない
            // done();
            doneFlg = true;
        }, 1000);

        setTimeout(() => {
            if (doneFlg) {
                done();
            }
        }, 1100);
    });

テストを実行すると、以下のことが分かります。

  • computedAsyncMobx.asyncComputed(...) は初回定義時に1度実行される(console.log("(1) ..."); の前に "PASS1-1" がログに出力されている)。
  • $("#name").val() を更新すると (1)~(5) の順で実行される。

f:id:ksby:20180106223021p:plain

action で API を呼び出す非同期処理をクラス内に記述する

MobX のマニュアルを読んでいたら Writing asynchronous actions のページを見つけました。mobx.action(...) を使用すれば API を呼び出す非同期処理をクラスのメソッドとして実装できます。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("mobx.action を使用して API 呼び出しの非同期処理を記述する", done => {
        // xhr-mock でモックを定義する
        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"
                    });
            });

        document.body.innerHTML = `
            <div id="area">
                <input type="text" name="name" id="name" value="">
                <div id="weather"></div>
            </div>
        `;

        // API を呼び出す処理を mobx.autorunAsync(...) ではなく、
        // mobx.action(...) を使用してクラス内に定義する
        class Area {
            constructor() {
                mobx.extendObservable(this, {
                    name: "",
                    weather: "",
                    getWeather: mobx.action(async () => {
                        console.log("(3) area.name が更新されたら OpenWeatherMap の API で天気を取得し、area.weather にセットする");
                        const res = await openWeatherMapHelper.getCurrentWeatherDataByCityName(this.name);
                        const json = res.data;
                        this.weather = json.weather[0].main;
                    })
                });
            }
        }

        const area = new Area();
        // area.weather が更新されたら $("#weather").text() にセットする
        mobx.autorun(() => {
            console.log("(4) area.weather が更新されたら $(\"#weather\").text() にセットする");
            $("#weather").text(area.weather);
        });

        // $("#name").val() に入力された値を area.name にセットする
        $("#name").on("blur", event => {
            console.log("(2) $(\"#name\").val() に入力された値を area.name にセットする");
            area.name = $(event.target).val();
            // action は自動実行はされないので、ここで area.getWeather(); を呼び出す
            area.getWeather();
        });

        console.log("(1) $(\"#name\").val() に Tokyo と入力する");
        $("#name").val("Tokyo").blur();

        let doneFlg = false;
        setTimeout(() => {
            console.log("(5) $(\"#weather\").text() に Rain がセットされていることを確認する");
            expect($("#weather").text()).toBe("Rain");
            // なぜかここに done(); を書くと console.log("(5) ..."); のログが出力されない
            // done();
            doneFlg = true;
        }, 1000);

        setTimeout(() => {
            if (doneFlg) {
                done();
            }
        }, 1100);
    });

テストを実行すると、(1)~(5) の順で処理が進むことが確認できます。

f:id:ksby:20180107070022p:plain

reaction で API を呼び出す非同期処理をクラス内に記述する

mobx.action(...) でもいいのですが、MobX の機能だけでプロパティを更新したら自動的に API を呼び出して天気を取得するように出来ないかマニュアルを見ていたところ、Reaction というページを見つけました。mobx.reaction(...) で監視対象のプロパティと実行する処理を記述すればよいようです。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("mobx.reaction を使用すれば、プロパティ更新時に自動で非同期処理を実行できる", done => {
        // xhr-mock でモックを定義する
        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"
                    });
            });

        document.body.innerHTML = `
            <div id="area">
                <input type="text" name="name" id="name" value="">
                <div id="weather"></div>
            </div>
        `;

        // API を呼び出す処理を mobx.autorunAsync(...) ではなく、
        // mobx.reaction(...) を使用してクラス内に定義する
        class Area {
            constructor() {
                mobx.extendObservable(this, {
                    name: "",
                    weather: ""
                });
                mobx.reaction(
                    () => this.name,
                    async () => {
                        console.log("(3) area.name が更新されたら OpenWeatherMap の API で天気を取得し、area.weather にセットする");
                        const res = await openWeatherMapHelper.getCurrentWeatherDataByCityName(this.name);
                        const json = res.data;
                        this.weather = json.weather[0].main;
                    }
                );
            }
        }

        const area = new Area();
        // area.weather が更新されたら $("#weather").text() にセットする
        mobx.autorun(() => {
            console.log("(4) area.weather が更新されたら $(\"#weather\").text() にセットする");
            $("#weather").text(area.weather);
        });

        // $("#name").val() に入力された値を area.name にセットする
        $("#name").on("blur", event => {
            console.log("(2) $(\"#name\").val() に入力された値を area.name にセットする");
            area.name = $(event.target).val();
        });

        console.log("(1) $(\"#name\").val() に Tokyo と入力する");
        $("#name").val("Tokyo").blur();

        let doneFlg = false;
        setTimeout(() => {
            console.log("(5) $(\"#weather\").text() に Rain がセットされていることを確認する");
            expect($("#weather").text()).toBe("Rain");
            // なぜかここに done(); を書くと console.log("(5) ..."); のログが出力されない
            // done();
            doneFlg = true;
        }, 1000);

        setTimeout(() => {
            if (doneFlg) {
                done();
            }
        }, 1100);
    });

テストを実行すると、(1)~(5) の順で処理が進むことが確認できます。computedAsyncMobx.asyncComputed(...)` を使わなくてもこれで良さそうです。

f:id:ksby:20180107071154p:plain

またプロパティを再度更新したら API を呼び出す処理が実行されるのか、テストを以下のように修正してcomputedAsyncMobx.asyncComputed(...)mobx.action(...)mobx.reaction(...) の3パターンで試してみましたが、これは全て API を呼び出す処理を実行しました。mobx.reaction(...)mobx.when(...) と同じで1度しか実行されないのでは?と思いましたが、そうではないようです。

        let doneFlg = false;
        setTimeout(() => {
            console.log("(5) $(\"#weather\").text() に Rain がセットされていることを確認する");
            expect($("#weather").text()).toBe("Rain");
            // なぜかここに done(); を書くと console.log("(5) ..."); のログが出力されない
            // done();
            doneFlg = true;
            $("#name").val("Oosaka").blur();
        }, 1000);

        setTimeout(() => {
            if (doneFlg) {
                done();
            }
        }, 2000);
  • doneFlg = true; の後に $("#name").val("Oosaka").blur(); を追加します。
  • done(); を呼び出す時の遅延時間を 1100 → 2000 へ変更します(1100 のままだと動作しない場合があったため)。

reaction で複数のプロパティを監視するには [] で囲む

mobx.reaction(...) で複数プロパティを監視したい場合には配列で指定するとできるようです。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("mobx.reaction で複数プロパティを監視するには [] で囲んで指定する", () => {
        document.body.innerHTML = `
            <div id="sample"></div>
        `;

        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: "",
                    data: "",
                    pass: ""
                });
                mobx.reaction(
                    () => [this.value, this.data, this.pass],
                    ([value, data, pass]) => {
                        $("#sample").text(`value = ${value}, data = ${data}, pass = ${pass}`);
                    }
                );
            }
        }

        const sample = new Sample();
        console.log("★★★ " + $("#sample").text());
        sample.value = "a";
        console.log("★★★ " + $("#sample").text());
        sample.data = "1";
        console.log("★★★ " + $("#sample").text());
        sample.pass = "PASS1";
        console.log("★★★ " + $("#sample").text());
        sample.value = "b";
        console.log("★★★ " + $("#sample").text());
        sample.data = "2";
        console.log("★★★ " + $("#sample").text());
        sample.pass = "PASS2";
        console.log("★★★ " + $("#sample").text());
    });

実行すると、mobx.reaction(...) が実行されて反映されることが確認できます。

f:id:ksby:20180109004444p:plain

履歴

2018/01/09
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( MobX を使用してみる )

概要

記事一覧はこちらです。

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

  • 今回の手順で確認できるのは以下の内容です。
    • 状態管理をするための MobX というモジュールがあり、これを使えば jQuery でも状態管理ができるらしいので Jest でテストを書いていろいろ試してみます。

参照したサイト・書籍

  1. Reactでも使える!シンプルなJavaScriptステート管理ライブラリー Mobxを試す
    https://www.webprofessional.jp/manage-javascript-application-state-mobx/

  2. MobX
    https://mobx.js.org/index.html

  3. mobx - npm
    https://www.npmjs.com/package/mobx

  4. MobX shopping cart demo
    https://jsfiddle.net/mweststrate/vxn7qgdw/

  5. jQueryプラグインを自作するには?($.fn)
    http://www.buildinsider.net/web/jqueryref/031

  6. Mobx vs Reactive Stream Libraries (RxJS, Bacon, etc)
    https://github.com/mobxjs/mobx/wiki/Mobx-vs-Reactive-Stream-Libraries-(RxJS,-Bacon,-etc)

  7. これからMobXをはじめる人へ
    http://lealog.hateblo.jp/entry/2016/12/15/113825

  8. MobX 3.0.0 の変更点について
    http://lealog.hateblo.jp/entry/2017/01/11/140607

  9. 0からはじめる MobX Part.1
    http://lealog.hateblo.jp/entry/2016/09/07/110823

  10. 0からはじめる MobX Part.2
    http://lealog.hateblo.jp/entry/2016/09/18/125202

  11. 0からはじめる MobX Part.3
    http://lealog.hateblo.jp/entry/2016/09/27/185127

  12. 0からはじめる MobX Part.4
    http://lealog.hateblo.jp/entry/2016/11/21/104613

  13. mobxjs / mobx-utils
    https://github.com/mobxjs/mobx-utils

目次

  1. MobX とは?
  2. MobX をインストールする
  3. extendObservable でプロパティを1つ持つクラスを定義し、動作を確認する
  4. extendObservable でプロパティを2つ持つクラスを定義し、動作を確認する
  5. autorun から computed で定義したプロパティを参照している場合の動作を確認する
  6. autorun からメソッドを参照している場合の動作を確認する
  7. autorun はプロパティの値を変更した時だけでなく、最初の定義時にも関数を実行する
  8. when は条件を満たした時に1度だけ動作する
  9. autorunAsync はプロパティが更新された時に動作する(定義時は動作しない)

手順

MobX とは?

試してみた感想としては、

  • オブジェクト(observable なオブジェクトとしてあらかじめ定義しておく)のプロパティの値を変更すると、そのプロパティを参照する関数が自動で実行される仕組みを記述するためのライブラリ。
  • 関数を実行するか否かを判断するための監視対象のプロパティを MobX が自動で判別してくれる。おそらくこれがかなり便利。

という感じです。あと React とか全然やったことがないので状態管理が何をするものなのかが分からなくて、書き方と動作を理解するのに結構時間を取られました。。。 また React と組み合わせるサンプルは見かけますが、Vue.js と組み合わせることはないようです(Vue.js は Vuex と組み合わせればいいので不要ということでしょうか?)。

MobX をインストールする

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

f:id:ksby:20180103191740p:plain

extendObservable でプロパティを1つ持つクラスを定義し、動作を確認する

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

"use strict";

global.$ = require("jquery");
const mobx = require("mobx");

describe("MobX の動作確認", () => {

    test("extendObservable でプロパティを1つ持つクラスを定義し、動作を確認する", () => {
        document.body.innerHTML = `
            <div id="value"></div>
        `;

        // mobx.extendObservable メソッドを使用して、Sample クラスを Observable として定義する
        //
        // function Sample() {...} で定義するなら以下のように記述する
        // function Sample() {
        //     mobx.extendObservable(this, {
        //         value: ""
        //     });
        // }
        //
        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: ""
                });
            }
        }

        // sample.value が変更されたら $("#value").text() を更新するよう定義する
        const sample = new Sample();
        mobx.autorun(() => {
            $("#value").text(sample.value);
        });

        // sample.value の値を変更すると、
        // mobx.autorun で定義した関数が実行されて、
        // $("#value").text() に sample.value の値がセットされる
        expect($("#value").text()).toBe("");
        sample.value = "test";
        expect($("#value").text()).toBe("test");
    });

});

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

f:id:ksby:20180103193651p:plain

以下のような書き方・動作になります。

  1. 変更を検知して何かさせる元となるオブジェクトを mobx.observable(...)mobx.extendObservable(...) を使用して定義する。
  2. オブジェクトが変更されたら実行する関数を mobx.autorun(...) で定義する。
  3. オブジェクトのプロパティを変更すると、mobx.autorun(...) で定義した関数が実行される。

extendObservable でプロパティを2つ持つクラスを定義し、動作を確認する

mobx.autorun(...) 内で参照していないプロパティを変更しても mobx.autorun(...) で定義した関数は実行されません。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("extendObservable でプロパティを2つ持つクラスを定義し、動作を確認する", () => {
        document.body.innerHTML = `
            <div id="value"></div>
        `;

        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: "",
                    data: ""
                });
            }
        }

        const sample = new Sample();
        // mbox.autorun 内では sample.value しか参照していない
        mobx.autorun(() => {
            console.log(`★★★ mobx.autorun: sample.value = ${sample.value}`);
            $("#value").text(sample.value);
        });

        // sample.value を変更すると mbox.autorun で定義した関数が実行されるが、
        // sample.data を変更しても実行されない
        console.log("●●● sample.value = \"PASS1\"");
        sample.value = "PASS1";
        console.log("▲▲▲ sample.data = \"1\"");
        sample.data = "1";
        console.log("●●● sample.value = \"PASS2\"");
        sample.value = "PASS2";
    });

テストを実行すると、sample.value を変更した時には mobx.autorun(...) で定義した関数が実行されますが、sample.data を変更しても実行されませんでした。MobX の方で何が変更されたら関数を実行するか自動的に判別してくれるようです。

f:id:ksby:20180103195146p:plain

autorun から computed で定義したプロパティを参照している場合の動作を確認する

mobx.autorun(...) で定義した関数から mobx.computed(...) で定義したプロパティを参照している場合、mobx.computed(...) で参照しているプロパティが変更されると mobx.autorun(...) で定義した関数が実行されます。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("autorun から computed で定義したプロパティを参照している場合の動作を確認する", () => {
        document.body.innerHTML = `
            <div id="personal">
                <input type="text" name="firstname" id="firstname" value="">
                <input type="text" name="lastname" id="lastname" value="">
                <div id="fullname"></div>
            </div>
        `;

        // mobx.computed() で定義した fullname プロパティから firstname + lastname の文字列
        // が取得できるようにする
        class Personal {
            constructor() {
                mobx.extendObservable(this, {
                    firstname: "",
                    lastname: "",
                    fullname: mobx.computed(() => `${this.firstname} ${this.lastname}`)
                });
            }
        }

        const personal = new Personal();
        // mobx.autorun では personal.fullname を参照する
        mobx.autorun(() => {
            $("#fullname").text(personal.fullname);
        });

        // blur イベント発生時に View から入力された値を personal オブジェクトにセットする
        [
            "firstname",
            "lastname"
        ].forEach(item => {
            $("#" + item).on("blur", event => {
                personal[item] = $(event.target).val();
            });
        });

        // $("#firstname").val() に "taro" と入力すると $("#fullname").text() も変更される
        $("#firstname").val("taro").blur();
        console.log("★★★ " + $("#fullname").text());
        // $("#lastname").val() に "tanaka" と入力しても $("#fullname").text() も変更される
        $("#lastname").val("tanaka").blur();
        console.log("★★★ " + $("#fullname").text());
    });

テストを実行すると、personal.firstname を変更しても personal.lastname を変更しても mobx.autorun(...) で定義した関数が実行されていることが確認できます。

f:id:ksby:20180103202540p:plain

autorun からメソッドを参照している場合の動作を確認する

mobx.computed(...) で定義したプロパティの場合 mobx.computed(...) から参照されているプロパティの値が変更されれば mobx.autorun(...) で定義した関数が実行されますが、メソッドの場合どうなるのか確認してみます。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加します。

    test("autorun からメソッドを参照している場合の動作を確認する", () => {
        document.body.innerHTML = `
            <div id="personal">
                <input type="text" name="firstname" id="firstname" value="">
                <input type="text" name="lastname" id="lastname" value="">
                <div id="fullname"></div>
            </div>
        `;

        // fullname メソッドから firstname + lastname の文字列
        // が取得できるようにする
        class Personal {
            constructor() {
                mobx.extendObservable(this, {
                    firstname: "",
                    lastname: "",
                    fullname() {
                        return `${this.firstname} ${this.lastname}`
                    }
                });
            }
        }

        const personal = new Personal();
        // mobx.autorun では personal.fullname() を参照する
        mobx.autorun(() => {
            $("#fullname").text(personal.fullname());
        });

        // blur イベント発生時に View から入力された値を personal オブジェクトにセットする
        [
            "firstname",
            "lastname"
        ].forEach(item => {
            $("#" + item).on("blur", event => {
                personal[item] = $(event.target).val();
            });
        });

        // $("#firstname").val() に "taro" と入力すると $("#fullname").text() も変更される
        $("#firstname").val("taro").blur();
        console.log("★★★ " + $("#fullname").text());
        // $("#lastname").val() に "tanaka" と入力しても $("#fullname").text() も変更される
        $("#lastname").val("tanaka").blur();
        console.log("★★★ " + $("#fullname").text());
    });

テストを実行すると、先程と同様に personal.firstname を変更しても personal.lastname を変更しても mobx.autorun(...) で定義した関数が実行されていることが確認できます。

f:id:ksby:20180103203530p:plain

autorun はプロパティの値を変更した時だけでなく、最初の定義時にも関数を実行する

mobx.autorun(...) で定義した関数はプロパティが変更された時だけでなく、最初の定義時にも実行されます。実行した時に監視するプロパティをチェックするようです。mobx.when(...)mobx.autorunAsync(...) は最初の定義時には実行されません。

mobx.autorunAsync(...) は定義時に実行しなくても監視するプロパティが判別できるようなので mobx.autorun(...) も定義時に実行しなくてもよい気がするのですが、そこがよく分かりませんでした。。。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("autorun はプロパティの値を変更した時だけでなく、最初の定義時にも関数を実行する", () => {
        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: ""
                });
            }
        }

        let cnt = 0;
        const sample = new Sample();
        console.log("(1) mobx.autorun は定義時に1度実行される");
        mobx.autorun(() => {
            console.log("mobx.autorun の関数が実行されました");
            $("#value").text(sample.value);
            cnt++;
        });

        console.log("(2) mobx.autorun はプロパティを変更しても実行される");
        sample.value = "1";
    });

テストを実行すると、最初の定義時にも mobx.autorun(...) に渡した関数が実行されていることが確認できます。

f:id:ksby:20180105004353p:plain

また以下のように最初の実行時に sample.value を参照しないと、

    test("autorun の初回実行時にプロパティを参照しないと、プロパティを変更しても関数は実行されない", () => {
        class Sample {
            constructor() {
                this.autorunFirstFlg = true;
                mobx.extendObservable(this, {
                    value: ""
                });
            }
        }

        let cnt = 1;
        const sample = new Sample();
        console.log("(1) mobx.autorun の最初の定義時にプロパティを参照しないようにする");
        mobx.autorun(() => {
            console.log(`${cnt}回目: mobx.autorun の関数が実行されました`);
            if (sample.autorunFirstFlg) {
                sample.autorunFirstFlg = false;
                return;
            }

            // 最初の実行時にはここを通らない
            $("#value").text(sample.value);
            cnt++;
        });

        console.log("(2) プロパティを変更しても mobx.autorun は実行されない");
        sample.value = "1";
    });

プロパティを変更しても mobx.autorun(...) に渡した関数は実行されません。

f:id:ksby:20180105011452p:plain

when は条件を満たした時に1度だけ動作する

mobx.autorun(...) で定義した関数は参照しているプロパティが変更されれば何度でも実行されますが、mobx.when(...) は条件を満たした時に1度だけ実行されます。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("when は条件を満たした時に1度だけ動作する", () => {
        document.body.innerHTML = `
            <div id="value"></div>
        `;

        class Sample {
            constructor() {
                mobx.extendObservable(this, {
                    value: "",
                    whenExecuteFlg: false
                });
            }
        }

        let cnt = 1;
        const sample = new Sample();
        console.log("mobx.when は最初の定義時には実行されない");
        mobx.when(
            // 第1引数の関数の条件を満たした時だけ、第2引数の関数が実行される
            // また第1引数の条件で参照する変数は observable でなければいけない
            () => sample.whenExecuteFlg === true,
            () => {
                console.log(`${cnt}回目: mobx.when の関数が実行されました`);
                $("#value").text(sample.value);
                cnt++;
            }
        );

        // sample.value の値を変更するたけでは $("#value").text() には反映されない
        sample.value = "test";
        expect($("#value").text()).toBe("");
        // sample.whenExecuteFlg を true にすると反映される
        sample.whenExecuteFlg = true;
        expect($("#value").text()).toBe("test");
        // sample.whenExecuteFlg を false に戻して再度 true にしても、もう mobx.when は実行されない
        sample.whenExecuteFlg = false;
        sample.value = "sample";
        sample.whenExecuteFlg = true;
        expect($("#value").text()).toBe("test");
    });

テストを実行すると、1度だけ実行されることが確認できます。

f:id:ksby:20180105014530p:plain

MobX-utils の fromPromise メソッドを使用して、非同期にデータを取得して処理するサンプルを作成してみます。

まずは npm install --save mobx-utils コマンドを実行して MobX-utils をインストールします。

f:id:ksby:20180105015129p:plain

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加します。

"use strict";

global.$ = require("jquery");
const mobx = require("mobx");
const mobxUtils = require("mobx-utils");
const openWeatherMapHelper = require("lib/util/OpenWeatherMapHelper.js");
const xhrmock = require('xhr-mock');

describe("MobX の動作確認", () => {

    beforeEach(() => {
        xhrmock.setup();
    });

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

    ..........

    test("when + mobx-utils.fromPromise のサンプル", done => {
        // xhr-mock でモックを定義する
        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"
                    });
            });

        document.body.innerHTML = `
            <div id="weather"></div>
        `;

        // mobx-utils の fromPromise メソッドを使用して
        // openWeatherMapHelper.getCurrentWeatherDataByCityName を呼び出す
        const result = mobxUtils.fromPromise(
            openWeatherMapHelper.getCurrentWeatherDataByCityName("Tokyo"));
        // 非同期処理が完了したら ( result.state が "pending" でなくなったら )、
        // 取得した天気を $("#weather").text() にセットする
        mobx.when(
            () => result.state !== "pending",
            () => {
                const json = result.value.data;
                $("#weather").text(json.weather[0].main);
            }
        );

        // 1秒後に $("#weather").text() にモックで定義した天気("Rain")がセットされていることを確認する
        expect($("#wheather").text()).toBe("");
        setTimeout(() => {
            expect($("#weather").text()).toBe("Rain");
            done();
        }, 1000);
    });

});

テストを実行すると成功することが確認できます。

f:id:ksby:20180105021802p:plain

autorunAsync はプロパティが更新された時に動作する(定義時は動作しない)

mobx.autorunAsync(...) はプロパティが更新されると非同期で処理を実行します。mobx.autorun(...) は定義時にも実行されますが、 mobx.autorunAsync(...) は定義時には実行されません。

src/test/assets/tests/lib/util/mobx.test.js に以下のテストを追加して確認してみます。

    test("autorunAsync は observable なプロパティが変更された時に動作する(定義時は動作しない)", done => {
        // xhr-mock でモックを定義する
        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"
                    });
            });

        document.body.innerHTML = `
            <div id="area">
                <input type="text" name="name" id="name" value="">
                <div id="weather"></div>
            </div>
        `;

        class Area {
            constructor() {
                mobx.extendObservable(this, {
                    name: "",
                    weather: ""
                });
            }
        }

        const area = new Area();
        // area.name が更新されたら OpenWeatherMap の API で天気を取得し、area.weather にセットする
        mobx.autorunAsync(async () => {
            console.log("(3) area.name が更新されたら OpenWeatherMap の API で天気を取得し、area.weather にセットする");
            const res = await openWeatherMapHelper.getCurrentWeatherDataByCityName(area.name);
            const json = res.data;
            area.weather = json.weather[0].main;
        });
        // area.weather が更新されたら $("#weather").text() にセットする
        mobx.autorun(() => {
            console.log("(4) area.weather が更新されたら $(\"#weather\").text() にセットする");
            $("#weather").text(area.weather);
        });

        // $("#name").val() に入力された値を area.name にセットする
        $("#name").on("blur", event => {
            console.log("(2) $(\"#name\").val() に入力された値を area.name にセットする");
            area.name = $(event.target).val();
        });

        console.log("(1) $(\"#name\").val() に Tokyo と入力する");
        $("#name").val("Tokyo").blur();

        setTimeout(() => {
            console.log("(5) $(\"#weather\").text() に Rain がセットされていることを確認する");
            expect($("#weather").text()).toBe("Rain");
            done();
        }, 1000);
    });

テストを実行すると、定義時には実行されず、関数内で参照しているプロパティが更新された時だけ実行されることが確認できます。

f:id:ksby:20180106070750p:plain

もう少し試したいことがあるので続きます。

履歴

2018/01/06
初版発行。

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 に修正しました。

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

概要

記事一覧はこちらです。

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

  • 今回の手順で確認できるのは以下の内容です。
    • Jest で setTimeout の処理のテストを書きます。

参照したサイト・書籍

  1. Jest - Timer Mocks
    https://facebook.github.io/jest/docs/en/timer-mocks.html

目次

  1. Jest を 21.2.1 → 22.0.4 へバージョンアップする。。。は IntelliJ IDEA 2017.3.3 待ちでした
  2. setTimeout の処理のテストを書く
    1. テストで使用するモジュールを作成する
    2. テストを書いて実行する
  3. 次回は。。。

手順

Jest を 21.2.1 → 22.0.4 へバージョンアップする。。。は IntelliJ IDEA 2017.3.3 待ちでした

IntelliJ IDEA を 2017.3.2 へバージョンアップしたので、改めて npm install --save-dev jest@22.0.4 コマンドを実行して Jest を 22.0.4 へバージョンアップします。

f:id:ksby:20171229160345p:plain

Form.test.js のテストを実行してみます。。。が、失敗しました。前と同じ No tests found というメッセージが出ますね?

f:id:ksby:20171229175716p:plain

もしかしてまだ修正されていないのかと思い、IntelliJ IDEA の次の EAP のページを IDEA 2017.3 EAPIntelliJ IDEA 2017.3 173.4301.1 Release Notes と見てみると、Bug WEB-30377 wrong test pattern when running single Jest test, 'no tests found' が見つかりました。どうも修正されるのは次の 2017.3.3 のようです。

npm install --save-dev jest@21.2.1 コマンドを実行して元に戻すことにします。

また今回試している時に気づきましたが、Java のテストと同様に Ctrl+Shift+T を押すとテスト対象のモジュールのファイルとテストのファイルの間を行ったり来たりできました。

setTimeout の処理のテストを書く

テストで使用するモジュールを作成する

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

"use strict";

let id = undefined;

module.exports = {

    /**
     * 指定された時間経過後に実行する関数を登録する
     * @param {Function} fn - 遅延実行する関数
     * @param {number} milliSeconds - 関数 fn を遅延実行する時間(ミリ秒)
     */
    register: function (fn, milliSeconds) {
        if (id !== undefined) {
            this.cancel();
        }
        id = setTimeout(fn, milliSeconds);
    },

    /**
     * 登録されている遅延実行関数をキャンセルする
     */
    cancel: function () {
        if (id !== undefined) {
            clearTimeout(id);
            id = undefined;
        }
    }

};

テストを書いて実行する

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

"use strict";

global.$ = require("jquery");
const delayExecutor = require("lib/util/delayExecutor.js");

jest.useFakeTimers();

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

    function setSampleValue() {
        $("#sample").val("サンプル");
    }

    function setTestValue() {
        $("#sample").val("テスト");
    }

    beforeEach(() => {
        document.body.innerHTML = `
            <input type="text" name="sample" id="sample" value="">
        `;
    });

    test("delayExecutor.register で関数を登録すると指定した時間経過後に実行される", () => {
        delayExecutor.register(setSampleValue, 2000);
        expect($("#sample").val()).toBe("");
        jest.runAllTimers();
        expect($("#sample").val()).toBe("サンプル");
    });

    test("delayExecutor.cancel を呼び出せば指定した時間を経過しても実行されない", () => {
        delayExecutor.register(setSampleValue, 2000);

        // ここで jest.advanceTimersByTime(...) を呼び出して、
        // 少しだけ時間を経過させたかった。。。

        delayExecutor.cancel();
        expect($("#sample").val()).toBe("");
        jest.runAllTimers();
        expect($("#sample").val()).toBe("");
    });

    test("delayExecutor.register は最後に登録した関数だけが実行される", () => {
        delayExecutor.register(setSampleValue, 2000);
        delayExecutor.register(setTestValue, 1000);

        expect($("#sample").val()).toBe("");
        jest.runAllTimers();
        expect($("#sample").val()).toBe("テスト");
    });

});

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

f:id:ksby:20171230020511p:plain

やっぱり setTimeout のテストを書くなら jest.advanceTimersByTime が使いたいかな。。。

次回は。。。

番外編で以下の3つを書いてみる予定です。

  • axiosNock でテストを書いてみる
  • MobX を使ってみる(このモジュールを使えば jQuery でも状態管理ができるらしいのでちょっと興味が湧きました)
  • Flow を使ってみる(Javascript を書いていると型が欲しいと切実に思いますが、これか Typescript を使えば型がある書き方ができるらしいので試してみます)

その後に Gradle の build タスクで Javascript の build +テストを一緒に実行する方法を調べてから、入力画面3以降の作成に移る予定です。

履歴

2017/12/30
初版発行。

IntelliJ IDEA を 2017.3.1 → 2017.3.2 へ、Git for Windows を 2.15.0 → 2.15.1(2) へバージョンアップ

IntelliJ IDEA を 2017.3.1 → 2017.3.2 へバージョンアップする

IntelliJ IDEA の 2017.3.2 がリリースされたのでバージョンアップします。

※ksbysample-webapp-lending プロジェクトを開いた状態でバージョンアップしています。

  1. IntelliJ IDEA のメインメニューから「Help」-「Check for Updates...」を選択します。

  2. IDE and Plugin Updates」ダイアログが表示されます。左下に「Update and Restart」ボタンが表示されていますので、「Update and Restart」ボタンをクリックします。

    f:id:ksby:20171229095908p:plain

  3. Plugin の update も表示されました。このまま「Update and Restart」ボタンをクリックします。

    f:id:ksby:20171229100104p:plain

  4. Patch がダウンロードされて IntelliJ IDEA が再起動します。

  5. IntelliJ IDEA が起動すると画面下部に「Indexing…」のメッセージが表示されますので、終了するまで待機します。

    f:id:ksby:20171229100619p:plain

  6. IntelliJ IDEA のメインメニューから「Help」-「About」を選択し、2017.3.2 へバージョンアップされていることを確認します。

  7. Gradle Tool Window のツリーを見ると「Tasks」の下に「other」しかない状態になっているので、左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

    f:id:ksby:20171229100940p:plain

  8. clean タスク実行 → Rebuild Project 実行 → build タスクを実行して、"BUILD SUCCESSFUL" のメッセージが出力されることを確認します。

    f:id:ksby:20171229101658p:plain

  9. Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run 'All Tests' with Coverage」を選択し、テストが全て成功することを確認します。

f:id:ksby:20171229102120p:plain

Git for Windows を 2.15.0 → 2.15.1(2) へバージョンアップする

Git for Windows の 2.15.1(2) がリリースされていたのでバージョンアップします。

  1. https://git-for-windows.github.io/ の「Download」ボタンをクリックして Git-2.15.1.2-64-bit.exe をダウンロードします。

  2. Git-2.15.1.2-64-bit.exe を実行します。

  3. 「Git 2.15.1.2 Setup」ダイアログが表示されます。[Next >]ボタンをクリックします。

  4. 「Select Components」画面が表示されます。「Git LFS(Large File Support)」だけチェックした状態で [Next >]ボタンをクリックします。

  5. 「Choosing the default editor used by Git」画面が表示されます(新オプションですね)。中央の「Use Vim (the ubiquitous text editor) as Git's default editor」が選択されていることを確認後、[Next >]ボタンをクリックします。

    nano って何? と思って調べるとコンソールで動くテキストエディタで、Ubuntu だとデフォルトが nano らしい。nanoエディタユーザーじゃない人がうっかりnanoエディタを開いてしまった時の対処法 とかの記事がヒットするのですが、コマンドラインで Git を使うような人が Vim ではなく nano を使うなんてあるのかな。。。

    WindowsVim を使わないならば GitBashで起動するテキストエディタの変更 の記事のように sakura editor に変えてしまった方が便利な気がします。

  6. 「Adjusting your PATH environment」画面が表示されます。中央の「Use Git from the Windows Command Prompt」が選択されていることを確認後、[Next >]ボタンをクリックします。

  7. 「Choosing HTTPS transport backend」画面が表示されます。「Use the OpenSSL library」が選択されていることを確認後、[Next >]ボタンをクリックします。

  8. 「Configuring the line ending conversions」画面が表示されます。一番上の「Checkout Windows-style, commit Unix-style line endings」が選択されていることを確認した後、[Next >]ボタンをクリックします。

  9. 「Configuring the terminal emulator to use with Git Bash」画面が表示されます。「Use Windows'default console window」が選択されていることを確認した後、[Next >]ボタンをクリックします。

  10. 「Configuring extra options」画面が表示されます。「Enable file system caching」だけがチェックされていることを確認した後、[Install]ボタンをクリックします。

  11. インストールが完了すると「Completing the Git Setup Wizard」のメッセージが表示された画面が表示されます。中央の「View Release Notes」のチェックを外した後、「Finish」ボタンをクリックしてインストーラーを終了します。

  12. コマンドプロンプトを起動して git --version を実行し、git のバージョンが git version 2.15.1.windows.2 になっていることを確認します。

    f:id:ksby:20171229122725p:plain

  13. git-cmd.exe を起動して日本語の表示・入力が問題ないかを確認します。

    f:id:ksby:20171229122847p:plain

  14. 特に問題はないようですので、2.15.1(2) で作業を進めたいと思います。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その43 )( Jest で jQuery.ajax の処理のテストを書く )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その42 )( Form.js のテストを Jest で書く2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Jest で jQuery.ajax($.ajax)の処理のテストを書きます。
    • setTimeout の処理のテストは Jest の 22.0 以降から使用可能になる jest.advanceTimersByTime を使ってみたいので、IntelliJ IDEA を 2017.3.2 にバージョンアップしてから記述します。

参照したサイト・書籍

  1. Jest - An Async Example
    https://facebook.github.io/jest/docs/en/tutorial-async.html

  2. Windowsでnpm installしてnode-gypでつまずいた時対処方法
    https://qiita.com/AkihiroTakamura/items/25ba516f8ec624e66ee7

  3. nodejs/node-gyp
    https://github.com/nodejs/node-gyp

  4. JavaScript Promiseの本
    http://azu.github.io/promises-book/

  5. How to fake jquery.ajax() response?
    https://stackoverflow.com/questions/5272698/how-to-fake-jquery-ajax-response

  6. jakerella / jquery-mockjax
    https://github.com/jakerella/jquery-mockjax/

目次

  1. Jest を 21.2.1 → 22.0.4 へバージョンアップ。。。は IntelliJ IDEA 2017.3.2 待ちでした
  2. $.ajax の処理のテストを書く
    1. テストで使用する API 呼び出し用のモジュールを作成する
    2. テストを書いて実行する
    3. $.ajax のモックを作成してテストする
    4. jquery-mockjax でサーバ側をモックにしてテストする
  3. 次回は。。。

手順

Jest を 21.2.1 → 22.0.4 へバージョンアップ。。。は IntelliJ IDEA 2017.3.2 待ちでした

setTimeout のテストで jest.advanceTimersByTime を使用してみたいので、Jest を最新の 22.0.4 へバージョンアップします。

今回は Jest だけバージョンアップしますので、npm update ではなく npm install --save-dev jest@22.0.4 コマンドを実行します。

f:id:ksby:20171223211148p:plain

Error: Can't find Python executable "python", you can set the PYTHON env variable. というエラーメッセージが表示されました。。。 PC に Python はインストールしていないのですが、Jest の 22.0 以降から必要になったのでしょうか?

調べると Windowsでnpm installしてnode-gypでつまずいた時対処方法 という記事を見つけました。また nodejs/node-gyp を見ると「管理者として実行...」で起動した PowerShell か CMD.exe から npm install --global --production windows-build-tools コマンドを実行すればよいようです。

CMD.exe を「管理者として実行...」で起動した後、npm install --global --production windows-build-tools コマンドを実行してみます。

f:id:ksby:20171224213036p:plain

インストールは成功しました。再度 npm install --save-dev jest@22.0.4 を実行してみます。

f:id:ksby:20171224213350p:plain

こちらも成功しました。

Form.test.js のテストを実行してみます。。。が、失敗しました。No tests found というメッセージが出ています。なぜかテストが見つからないようです。

f:id:ksby:20171224224335p:plain

いろいろ調べてみましたが、おそらく WEB-30087 Jest: unable to run single symlinked test file のことでしょう。IntelliJ IDEA の Bug で次のバージョン 2017.3.2 で修正されるようです。

npm install --save-dev jest@21.2.1 コマンドを実行して元に戻すことにします。Form.test.js のテストも成功することを確認します。

f:id:ksby:20171224225134p:plain

$.ajax の処理のテストを書く

テストで使用する API 呼び出し用のモジュールを作成する

郵便番号検索API を呼び出すモジュールを作成してみます。src/main/assets/js/lib/util の下に ZipcloudApiHelper.js というファイルを新規作成し、以下の内容を記述します。

"use strict";

module.exports = {

    /**
     * 郵便番号検索API を呼び出して、郵便番号から都道府県・市区町村・町域を取得する
     * @param {string} zipcode - 郵便番号(7桁の数字、ハイフン付きでも可)
     */
    search: function (zipcode) {
        var defer = $.Deferred();
        $.ajax({
            type: "get",
            url: "http://zipcloud.ibsnet.co.jp/api/search",
            data: {zipcode: zipcode},
            cache: false,
            dataType: "jsonp",
            timeout: 15000,
            success: defer.resolve,
            error: defer.reject
        });
        return defer.promise();
    }

};

テストを書いて実行する

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

"use strict";

global.$ = require("jquery");
const zipcloudApiHeler = require("lib/util/ZipcloudApiHelper.js");

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

    test("100-0005で検索すると東京都千代田区丸の内が1件ヒットする", () => {
        zipcloudApiHeler.search("1000005")
            .then(json => {
                expect(json.results.length).toBe(1);
            })
            .catch(e => {
                console.log(e);
            });
    });

});

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

f:id:ksby:20171224231710p:plain

非同期処理もテストが出来るものなんだなあと思いましたが、breakpoint を設置して本当にコードを実行しているか確認してみます。

f:id:ksby:20171224232012p:plain

が、全く止まりませんでした。API を呼び出しているだけで、then と catch のどちらのコードも実行されていません。テストになっていませんね。。。

Jest の An Async Example のページを読んでみると async / await を使えば良さそうです。src/test/assets/tests/lib/util/ZipcloudApiHelper.test.js を以下のように変更して試してみます。

    test("100-0005で検索すると東京都千代田区丸の内が1件ヒットする", async () => {
        const json = await zipcloudApiHeler.search("1000005");
        expect(json.results.length).toBe(1);
        expect(json.results[0].address1).toBe("東京都");
        expect(json.results[0].address2).toBe("千代田区");
        expect(json.results[0].address3).toBe("丸の内");
    });

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

f:id:ksby:20171226005119p:plain

breakpoint を設置して Debug 実行してみると、expect のところで処理も止まり変数 json の中にデータが入っていることが確認できます。

f:id:ksby:20171226005551p:plain

ちなみに async / await を削除するとテストは失敗します。

f:id:ksby:20171226010305p:plain

$.ajax のモックを作成してテストする

stackoverflow の How to fake jquery.ajax() response? という QA を見つけました。ajax_response という関数を定義してモック化できるようです。

以下のテストを src/test/assets/tests/lib/util/ZipcloudApiHelper.test.js に追加します。

    describe("$.ajax のモックを作成してテストする", () => {

        /**
         * $.ajax モック用関数
         * https://stackoverflow.com/questions/5272698/how-to-fake-jquery-ajax-response 参照
         */
        function ajax_response(response, success) {
            return function (params) {
                if (success) {
                    params.success(response);
                } else {
                    params.error(response);
                }
            };
        }

        test("success のテスト", async () => {
            $.ajax = ajax_response({
                results: [
                    {address1: "東京都"},
                    {address1: "神奈川県"}
                ]
            }, true);
            const json = await zipcloudApiHeler.search("1000005");
            expect(json.results.length).toBe(2);
            expect(json.results[0].address1).toBe("東京都");
            expect(json.results[1].address1).toBe("神奈川県");
        });

        test("error のテスト", async () => {
            $.ajax = ajax_response({error: "エラー"}, false);
            await expect(zipcloudApiHeler.search("1000005")).rejects.toEqual({error: "エラー"});
        });

    });

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

f:id:ksby:20171226014601p:plain

jquery-mockjax でサーバ側をモックにしてテストする

サーバ側をモックにして正常にデータが返る場合や 500 等の HTTPステータスコードが返ってくる場合のテストをしたいと思い、調べてみたのですが、以外に難しい。。。

  • API を呼び出す場合、ブラウザ環境と Node.js 環境で異なるらしい。そんな違いがあるんだ、と少し驚きました。
  • ブラウザ環境は XMLHttpRequests を使用する。
  • Node.js 環境は http requests モジュールを使用する。
  • よく聞く axios だと環境に応じて XMLHttpRequests を使用するか http requests を使用するか判断してくれるらしい。
  • 今回は $.ajax のテストをしたいので、XMLHttpRequests をモックにしてくれるライブラリが欲しい。
  • Jest には XMLHttpRequests をモックにする機能はなさそう。
  • Sinon.JS Fake XHR and server を見ると sinon.useFakeXMLHttpRequest()sinon.fakeServer を使えば出来そうな気がする。ただし Sinon.JS は機能が結構豊富なので、Jest を使っているのに $.ajax のテストだけのために Sinon.JS を入れることに少し抵抗あり。もう少し機能を絞ったライブラリでも良さそう。
  • node-nock / nock というモジュールを発見したが、これは Node.js 環境で http requests をモックにするモジュールらしい。
  • 他に何かないか探してみたところ jakerella / jquery-mockjax というモジュールを発見。試してみたら Node.js 環境でも動作したので、これにしてみます!

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

f:id:ksby:20171228135423p:plain

以下のテストを src/test/assets/tests/lib/util/ZipcloudApiHelper.test.js に追加します。

"use strict";

global.$ = require("jquery");
const zipcloudApiHeler = require("lib/util/ZipcloudApiHelper.js");
const mockjax = require("jquery-mockjax")(global.$, window);

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

    ..........

    describe("jquery-mockjax でサーバ側をモックにして $.ajax をテストする", () => {

        afterEach(() => {
            mockjax.clear();
        });

        test("データを返す場合のテスト", async () => {
            mockjax({
                url: "http://zipcloud.ibsnet.co.jp/api/search",
                responseText: {
                    results: [
                        {address1: "東京都"},
                        {address1: "神奈川県"}
                    ]
                }
            });

            const json = await zipcloudApiHeler.search("1000005");
            expect(json.results.length).toBe(2);
            expect(json.results[0].address1).toBe("東京都");
            expect(json.results[1].address1).toBe("神奈川県");
        });

        // dataType: "jsonp" だとこのテストは成功しない
        // status: 500 にしても reject が呼び出されず、resolve が呼び出される
        test("HTTPステータスコード 500 が返ってくる場合のテスト", async () => {
            mockjax({
                url: "http://zipcloud.ibsnet.co.jp/api/search",
                status: 500
            });

            await expect(zipcloudApiHeler.search("1000005"))
                .rejects.toHaveProperty("status", 500);
        })

        // dataType: "jsonp" だとこのテストは成功しない
        // isTimeout: true にしても reject が呼び出されず、resolve が呼び出される
        test("Timeout する場合のテスト", async () => {
            mockjax({
                url: "http://zipcloud.ibsnet.co.jp/api/search",
                isTimeout: true
            });

            await expect(zipcloudApiHeler.search("1000005"))
                .rejects.toHaveProperty("statusText", "timeout");
        })

    });

});

コメントに書きましたが、$.ajax を呼び出す時に dataType: "jsonp" を記述しているとエラー系(HTTPステータスコード 500 を返す、Timeout させる)が全く成功しません。テストを実行すると最初のデータを返す場合しか成功しませんでした。

f:id:ksby:20171229082835p:plain

src/main/assets/js/lib/util/ZipcloudApiHelper.js で dataType: "jsonp"コメントアウトすると、

"use strict";

module.exports = {

    /**
     * 郵便番号検索API を呼び出して、郵便番号から都道府県・市区町村・町域を取得する
     * @param {string} zipcode - 郵便番号(7桁の数字、ハイフン付きでも可)
     */
    search: function (zipcode) {
        var defer = $.Deferred();
        $.ajax({
            type: "get",
            url: "http://zipcloud.ibsnet.co.jp/api/search",
            data: {zipcode: zipcode},
            cache: false,
            // dataType: "jsonp",
            timeout: 15000,
            success: defer.resolve,
            error: defer.reject
        });
        return defer.promise();
    }

};

テストは全て成功するようになります。

f:id:ksby:20171229083313p:plain

jakerella / jquery-mockjax$.ajax のテストをするにはかなり使い勝手いいですね。ただし dataType: "jsonp" を指定した場合には正常系しかテストは成功しないので要注意です。

次回は。。。

IntelliJ IDEA の 2017.3.2 が出たので、2017.3.2 にバージョンアップした後、Jest を 22.0 以降にバージョンアップしてから setTimeout の処理のテストを書きます。

履歴

2017/12/29
初版発行。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その42 )( Form.js のテストを Jest で書く2 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その41 )( IntelliJ IDEA で Javascript を debug する ) の続きです。

参照したサイト・書籍

  1. jQuery - Category: Event Object
    https://api.jquery.com/category/events/event-object/

目次

  1. class 属性を変更するメソッドのテストを書く
    1. resetValidation メソッド
    2. setSuccess メソッド
    3. setError メソッド
  2. Validation 用メソッドのテストを書く
    1. convertAndValidate メソッド
  3. IntelliJ IDEA の 2017.3 から coverage をエディタ上に表示できる
  4. 次回は。。。

手順

class 属性を変更するメソッドのテストを書く

resetValidation メソッド

以下のテストを書いて、

    describe("class 属性を変更するメソッドのテスト", () => {

        describe("resetValidation メソッドのテスト", () => {

            test("入力チェック成功時(has-success)は has-success が削除される", () => {
                document.body.innerHTML = `
                    <div class="form-group has-success" id="form-group-name">
                      <div class="control-label col-sm-2">
                        <label class="float-label">お名前</label>
                      </div>
                      <div class="col-sm-10">
                        <div class="row"><div class="col-sm-10">
                          <input type="text" name="lastname" id="lastname" class="form-control form-control-inline"
                                value="" placeholder="例)田中"/>
                        </div></div>
                        <div class="row hidden js-errmsg"><div class="col-sm-10">
                          <p class="form-control-static text-danger"><small>ここにエラーメッセージを表示します</small></p>
                        </div></div>
                      </div>
                    </div>
                `;

                let form = new Form([]);
                expect($("#form-group-name").hasClass("has-success")).toBe(true);
                expect($(".js-errmsg").hasClass("hidden")).toBe(true);
                form.resetValidation("#form-group-name");
                expect($("#form-group-name").hasClass("has-success")).toBe(false);
                expect($(".js-errmsg").hasClass("hidden")).toBe(true);
            });

            test("入力チェックエラー時(has-error)は has-error が削除されてエラーメッセージが非表示になる", () => {
                document.body.innerHTML = `
                    <div class="form-group has-error" id="form-group-name">
                      <div class="control-label col-sm-2">
                        <label class="float-label">お名前</label>
                      </div>
                      <div class="col-sm-10">
                        <div class="row"><div class="col-sm-10">
                          <input type="text" name="lastname" id="lastname" class="form-control form-control-inline"
                                value="" placeholder="例)田中"/>
                        </div></div>
                        <div class="row js-errmsg"><div class="col-sm-10">
                          <p class="form-control-static text-danger"><small>ここにエラーメッセージを表示します</small></p>
                        </div></div>
                      </div>
                    </div>
                `;

                let form = new Form([]);
                expect($("#form-group-name").hasClass("has-error")).toBe(true);
                expect($(".js-errmsg").hasClass("hidden")).toBe(false);
                form.resetValidation("#form-group-name");
                expect($("#form-group-name").hasClass("has-error")).toBe(false);
                expect($(".js-errmsg").hasClass("hidden")).toBe(true);
            });

        });

    });

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

f:id:ksby:20171220012914p:plain

setSuccess メソッド

以下のテストを書いて、

        test("setSuccess メソッドのテスト", () => {
            document.body.innerHTML = `
                <div class="form-group" id="form-group-name">
                </div>
            `;

            let form = new Form([]);
            expect($("#form-group-name").hasClass("has-success")).toBe(false);
            form.setSuccess("#form-group-name");
            expect($("#form-group-name").hasClass("has-success")).toBe(true);
        });

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

f:id:ksby:20171220014053p:plain

setError メソッド

以下のテストを書いて、

        test("setError メソッドのテスト", () => {
            document.body.innerHTML = `
                <div class="form-group" id="form-group-name">
                  <div class="control-label col-sm-2">
                    <label class="float-label">お名前</label>
                  </div>
                  <div class="col-sm-10">
                    <div class="row"><div class="col-sm-10">
                      <input type="text" name="lastname" id="lastname" class="form-control form-control-inline"
                            value="" placeholder="例)田中"/>
                    </div></div>
                    <div class="row hidden js-errmsg"><div class="col-sm-10">
                      <p class="form-control-static text-danger"><small>ここにエラーメッセージを表示します</small></p>
                    </div></div>
                  </div>
                </div>
            `;

            let form = new Form([]);
            expect($("#form-group-name").hasClass("has-error")).toBe(false);
            expect($(".js-errmsg").hasClass("hidden")).toBe(true);
            expect($(".js-errmsg small").text()).toBe("ここにエラーメッセージを表示します");
            form.setError("#form-group-name", "お名前を入力してください");
            expect($("#form-group-name").hasClass("has-error")).toBe(true);
            expect($(".js-errmsg").hasClass("hidden")).toBe(false);
            expect($(".js-errmsg small").text()).toBe("お名前を入力してください");
        });

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

f:id:ksby:20171220014840p:plain

Validation 用メソッドのテストを書く

convertAndValidate メソッド

以下のテストを書いて、

    describe("Validation 用メソッドのテスト", () => {

        describe("convertAndValidate メソッドのテスト", () => {

            const idFormGroup = "#form-group-name";
            const idList = ["#name", "#age"];

            test("全ての要素に focus していると converter, validator が呼ばれる", () => {
                let form = new Form(idList);
                let event = $.Event("test");
                const converter = jest.fn();
                const validator = jest.fn();

                form.forceAllFocused(form);
                form.convertAndValidate(form, event, idFormGroup, idList, converter, validator);
                expect(converter).toBeCalled();
                expect(validator).toBeCalled();
                expect(event.isPropagationStopped()).toBe(false);
            });

            test("全ての要素に focus していないと converter, validator は呼ばれない", () => {
                let form = new Form(idList);
                let event = $.Event("test");
                const converter = jest.fn();
                const validator = jest.fn();

                form.convertAndValidate(form, event, idFormGroup, idList, converter, validator);
                expect(converter).not.toBeCalled();
                expect(validator).not.toBeCalled();
                expect(event.isPropagationStopped()).toBe(false);
            });

            test("converter, validator に undefined を渡してもエラーにならない", () => {
                let form = new Form(idList);
                let event = $.Event("test");

                form.forceAllFocused(form);
                expect(() => {
                    form.convertAndValidate(form, event, idFormGroup, idList, undefined, undefined);
                }).not.toThrow();
                expect(event.isPropagationStopped()).toBe(false);
            });

            test("validator が Error オブジェクトを throw すると event.stopPropagation() が呼ばれる", () => {
                let form = new Form(idList);
                let event = $.Event("test");
                const converter = jest.fn();
                const validatorThrowError = jest.fn().mockImplementation(() => {
                    throw new Error();
                });

                form.forceAllFocused(form);
                form.convertAndValidate(form, event, idFormGroup, idList, converter, validatorThrowError);
                expect(event.isPropagationStopped()).toBe(true);
            });

        });

    });

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

f:id:ksby:20171221011404p:plain

IntelliJ IDEA の 2017.3 から coverage をエディタ上に表示できる

IntelliJ IDEA 2017.3 から Jest のテストを実行するコンテキストメニューに「Run ... with Coverage」が表示されるようになりました。また、このメニューからテストを実行すると Jest のレポートファイルを見なくてもエディタ上で実行された部分が分かります。

例えば「Run 'focus イベントに関するメソッドのテスト' with Coverage」を実行してみると、

f:id:ksby:20171222060252p:plain

テストが実行されて成功し、

f:id:ksby:20171222060938p:plain

Coverage の Window が表示されて、Form.js の 39% の行がテストされたことが分かります。

f:id:ksby:20171222061130p:plain f:id:ksby:20171222061300p:plain

Form.js をエディタで開いてみると、画面の左側にテストが実行された箇所が緑で、実行されていない箇所が赤で表示されます。

f:id:ksby:20171222061527p:plain

テストの実行された/されていない箇所を表示する色はデフォルトでは見にくい色だったので、単純な緑、赤に変更しています。色が表示されている部分にマウスカーソルを移動して左クリックした後、「Edit coverage colors」のアイコンをクリックします。

f:id:ksby:20171222061804p:plain

「Editor | Color Scheme | General」ダイアログが表示されるので、緑は 00FF00 へ、

f:id:ksby:20171222062055p:plain f:id:ksby:20171222062137p:plain

赤は FF0000 へ変更しています。

f:id:ksby:20171222062319p:plain f:id:ksby:20171222062410p:plain

次回は。。。

もう少し Jest でテストを書いてみます。jQuery.ajax 呼び出し時の非同期処理や、setTimeout で遅延して処理を実行する場合のテストを書く予定です。

それにしても IntelliJ IDEA 2017.3 + Jest の環境だと、Javascript のテストを書いたり実行したり、coverage を確認したり、うまく動かない時に debug 実行したりも、Java の時とたいして変わりがないですね。開発しやすい印象です。テスト実行時の Node.js のパラメータ指定(--inspect 等)も IntelliJ IDEA が自動でセットしてくれるのは便利です。

履歴

2017/12/22
初版発行。