$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

navigator.userAgentをモックしてJSのユニットテストをする

 JavaScriptユニットテストでnavigator.userAgentをモックしてテストしたいことがあり、そのようなユーティリティを作ってテストをしてみたのでメモ。

 ちなみにいろいろ試した例は https://github.com/shibayu36/javascript-playground/blob/master/es2015-project/test/stubUserAgent.js にあるので、参照してください。

やりたいこと

 最近はkarmaやkarma-html2js-preprocessorを利用することで、DOMに依存するテストでもHTMLの断片を用意してユニットテストを行うことができるようになった。TypeScript環境でそのようなテストをする方法については 以前の記事 で紹介した。またES2015版でのテスト環境を作った例は https://github.com/shibayu36/javascript-playground/tree/master/es2015-project あたりにおいている。

 DOMに依存するユニットテストを行っていると、navigator.userAgentに応じて処理を変えているような実装をテストしたいことがある。navigator.userAgentに応じて処理を変えている例としては、特定のブラウザで動かない機能のUIをそのブラウザでは隠すなどといったものがある。

 そのようなテストをしたい場合、テスト時のみ一時的にnavigator.userAgentを書き換え、そのテストが終わったら元に戻すということをして、挙動をユニットテストしたい。つまりnavigator.userAgentを一時的にモックしたい。

 利用のイメージとしては次のコードのとおり。stubUserAgent関数を呼ぶと一時的にnavigator.userAgentを書き換え、この関数から返ってきたオブジェクトのrestoreメソッドを呼ぶことで元に戻すということをしている。

it('ユーザーエージェントをモックしてテストする', () => {
    let defaultUserAgent = navigator.userAgent;

    // ここでnavigator.userAgentを「StubbedAgent/1.0」に書き換える
    let stub = stubUserAgent('StubbedAgent/1.0');

    // ここでuserAgentに応じて処理を変えるような実装をテストする
    // 現在は簡単のためnavigator.userAgentが変わっているかどうかだけ見ている
    assert.equal(navigator.userAgent, 'StubbedAgent/1.0');

    // テストが終わったらrestoreメソッドを呼ぶと元のuserAgentに戻る
    stub.restore();
    assert.equal(navigator.userAgent, defaultUserAgent);
});

stubUserAgentを実装する

 上で紹介したようなnavigator.userAgentを一時的にモックすることを実現するために、stubUserAgent関数を実装してみた。Chrome, Firefox, jsdomの環境下ではおそらく動くと思う。

function stubUserAgent(userAgent) {
    // もともとのPropertyDescriptorを保存しておく
    let origDescriptor = Object.getOwnPropertyDescriptor(
        navigator, 'userAgent'
    );

    // navigatorのuserAgentプロパティを
    // 渡されたuserAgentが返るように書き換える
    Object.defineProperty(navigator, 'userAgent', {
        get: function () { return userAgent },
        enumerable: true,
        configurable: true,
    });

    // restoreを呼べるようなオブジェクトを返す
    return {
        restore() {
            if (origDescriptor) {
                // origDescriptorがあるなら、definePropertyで戻す
                Object.defineProperty(navigator, 'userAgent', origDescriptor);
            }
            else {
                // origDescriptorがないなら、navigatorのprototypeで
                // userAgentが定義されているはず。それならば、モックで
                // 定義したuserAgentプロパティをdeleteすれば戻せる。
                delete navigator.userAgent;
            }
        },
    };
}

 手順としては、もともとの状態を取っておき、deinePropertyを使ってnavigator.userAgentを再定義し、restoreを呼んだ時に元に戻すというだけである。ただ、navigatorのオブジェクト自体にuserAgentプロパティがある場合と、prototypeの方でuserAgentが定義されている場合の二通りがあるため、少し複雑化している。

なぜdefinePropertyで定義しなければいけないか

 ちなみに単純に考えると以下のようにすればうまくいくのでは?と思えてしまう。しかし、この方法だとnavigator.userAgentへの代入が出来ないためにうまくいかない。

function stubUserAgent(userAgent) {
    let origUserAgent = navigator.userAgent;
    navigator.userAgent = userAgent;

    return {
        restore() {
            navigator.userAgent = origUserAgent;
        }
    };
}

 JSではプロパティにもいろんなメタ情報があって、そのメタ情報を設定することで以下のような項目などを制御できる。

  • getterやsetterとして定義する(get, set)
  • プロパティへの代入の可否(writable)
  • プロパティの再定義やdeleteの可否(configurable)
  • 列挙の可否(enumerable)

 この辺りについてはdefinePropertyのドキュメント が詳しい。

 そして、navigator.userAgentはgetterとして定義されていて、またwritableはfalseとして定義されているため、プロパティへの値の代入は出来ない状態になっている。しかし、configurableという属性はtrueとなっているため、definePropertyという関数を使えば再定義することは可能となっている。これらの理由から、一時的に書き換えければdefinePropertyを使うしか方法がなかった。

 もしconfigurableさえfalseになっていると、そもそも値の書き換えを行うことはできなくなってしまう。このため、navigator.userAgentプロパティのconfigurableをfalseとしているSafariにおいては、上のような実装すらも動かない。こうなってくると、userAgentを判定するようなクラスを作り、そのクラスのメソッドをモックするという方法を使うしかないだろう。

まとめ

 今回はnavigator.userAgentの値を一時的に書き換えてテストする方法について書いてみた。正直こういう方法で良いのか不安なので、よりクールな方法があれば教えてほしいです。