Block Rockin’ Codes

back with another one of those block rockin' codes

ServiceWorker を使った XHR のモックテスト

Intro

似た話は既出なのでご提案にはならないけど、PoC として一応ライブラリっぽくしてみた。

タイトルの通り XHR のリクエストに対して、任意のレスポンスを返すことによって、 再現の面倒なエラー処理などのテストを、 Local Proxy 無しで実現する方法のメモ。

というか、これ自体がブラウザ上の JS だけで完結する Local Proxy と言えるかもしれません。

ざざっと書いただけなので、API というか使い方もまだ適当というかどうしようか考えてるところです。 今これが使えるブラウザ自体がそんなにないので、 SW の実装が普及するまでにもう少しまともにしておきます。

Jxck/response-injection · GitHub

色々踏んだので、 Service Worker 使う際の参考になればと。

モチベーション

Service Worker(SW) には色々な機能があり、それ故に色々な使い方が考えられます。

代表的なのはオフラインキャッシュですが、それは一部であり、本質的には表示されているページとは別のスレッドに常駐する JS を登録し、発生するイベントをフックしてメタ的な処理を挟み込みための API だととらえています。

SW がフックできるイベントに 'fetch' というものがあり、これはブラウザ上でリクエストが発生したことを通知し、そのリクエストを盗むことができます。 オフラインアプリの場合は、このイベントのフックで、 Cache API に保存してあるレスポンスを返すなどの使い方をしますが、 今回はテストに都合のよい任意のレスポンスを生成して、変わりに返すことで、実際のネットワークアクセスをせずに、 XHR (もしくは fetch API) を用いたライブラリなどのテストをするものです。

任意のレスポンスが返せるということは、エラー処理のテストなどに便利で、 LocalProxy を使わずブラウザで完結できます。

仕組み

例えばこんな雑な API のコードを書いたとする。

function get(url) {
  return new Promise(function(done, fail) {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.responseType = 'json';
    xhr.addEventListener('load', function() {
      if(xhr.status >= 400) {
        return fail(xhr.response);
      }
      done(xhr.response);
    });
    xhr.addEventListener('error', fail);
    xhr.send();
  });
}

このテストをこんな感じで書きたい。

Injector(

// URL とレスポンスの対応を書く
{
  '/success': {
    head: {
      status: 200,
      statusText: 'OK',
      headers: {
        'Content-Type': 'application/json'
      }
    },
    body: {
      id: 200,
      name: 'jxck',
      mail: 'jxck@example.com'
    }
  },
  '/fail': {
    head: {
      status: 404,
      statusText: 'Not Found',
      headers: {
        'Content-Type': 'application/json'
      }
    },
    body: {
      message: 'user not found'
    }
  }
},

// option
// 除外したい URL と、 SW のスコープを書く
{
  ignore: ['/', '/test.js', '/main.js'], // ignore injection (default [])
  scope: '.' // scope for register worker (default '/')
}

).then(

// ここまでにレスポンスの登録は終わってる

// 普通にテストを書く
function test() {
  console.log('start test');

  get('/success').then(function(res) {
    console.assert(res.id, 200); // 差し込んだ結果が返ってくる
  }).catch(function(err) {
    console.assert(false);
  });

  get('/fail').then(function(res) {
    console.assert(false);
  }).catch(function(err) {
    console.assert(err.message, 'user not found');
  });

  // etc
}

).catch(console.error.bind(console));

SW が有効になったブラウザで動かすと、最初のリクエストで SW が登録されるので、 一度リロードすればレスポンスが差し込まれます。

また、 SW の登録は最初の一回だけでよく、既に登録されていれば、上部のレスポンスのモックデータを書き換えてリロードするだけで、動的にモックデータを更新できます。 毎回、登録しないのでテストの更新がすぐ反映されてストリレスが少なく、テスト自体は邪魔してないので、 Injector の Promise が解決されたあとにいつも通りのテストが走らせられれば、 連携するフレームワークなどは選ばないので、取り入れやすいはず。

デモはこんな感じ。

$ git clone ttps://github.com/Jxck/response-injection
$ cd response-injection/sample
$ # python とかでサーバを立てる

http://localhost:3000/ とかを開いてリロードするとテストが走る。

本当はもっとキーを柔軟にしないと実用に耐えないと思うので、 差し込むデータの形式は最終的に Request/Response オブジェクトの対応まで拡張してしまえばいいかと思ってる。 全く同種のリクエストの分岐は、カスタムヘッダの付与とかでいいかと。

何をしているか

まだ書き始めたばかりなので、コードは短いです。かいつまんで解説します。

全体

1 ファイルで登録する側、される側を両方兼ねています。 分岐は以下のような感じになります。

if ('ServiceWorkerGlobalScope' in self && self instanceof ServiceWorkerGlobalScope) {
  // service worker のコード
} else {
  // Injector のコード(window 側)
}

二つにわけて説明します。

Injector 側

データを渡すと SW に登録してくれて、登録が終わったら Resolve する Promise を返します。

function Injector(mockdata, option) {
  console.log(mockdata, option);
  option = option || {};
  option.scope = option.scope || '.';
  option.ignore = option.ignore || [];

  // 登録されていれば再利用、無ければ自身を登録。
  return Promise.race([
      navigator.serviceWorker.register('injector.js', { scope: option.scope }),
      navigator.serviceWorker.getRegistration()
  ]).then(function() {
    // これでメッセージをやりとりする。
    var msgchan = new MessageChannel();

    // メッセージは文字列のみ
    var message = JSON.stringify({
      mockdata: mockdata,
      option: option
    });
    // activate 前は controller が null になるけど reject すべきか。。
    navigator.serviceWorker.controller.postMessage(message, [msgchan.port2]);

    return new Promise(function(done, fail) {
      // 登録が終わった通知を受け取る。
      // addEventListener だと動かない
      msgchan.port1.onmessage = function(event) {
        if(event.data === 'done') {
          // 終わったら解決
          done();
        } else {
          fail(new Error('event.data'));
        }
      };
    });
  })
}

SW のコードは更新しなくはないので、一度だけ登録し、登録されていたら再利用します。 SW へデターを渡すには DOM を経由できないので、 MessageChannel API を使います。 SW は登録が終わったら応答させるようにして、そこで Promise を resolve させます。

なので、これが終わったらテストを実行すれば、レスポンスが挟めます。

navigator.serviceWorker.controller は activate されるまで null なんですが、どうせ二回リロードしないと動かない前提と放置してるけど、実際は resolve なり reject すべきか。

ちなみに、 port の message へのリスナが addEventListener で登録できなかったぽいのは報告中。

Worker 側

// window 側からのメッセージを受け取る
self.addEventListener('message', function(message) {
  var data = JSON.parse(message.data);
  var mockdata = data.mockdata;
  var option = data.option;

  // レスポンスを生成
  function createResponse(path) {
    var head = mockdata[path].head;
    var body = JSON.stringify(mockdata[path].body);
    console.log(head, body);
    return new Response(body, head);
  }

  // fetch イベントをフック
  // リスナは参照もってなくて良いように毎回上書き
  self.onfetch = function(event) {

    // とりあえずパスだけでマッチさせてる
    var path = (new URL(event.request.url)).pathname;

    // test.js やらへのリクエストを省くため
    if(option.ignore.indexOf(path) >= 0) {
      // ignore
      return;
    }

    // 作ったレスポンスを返す
    var response = createResponse(path);
    event.respondWith(response);
  };

  // fetch リスナの登録が終わった事を伝える。
  message.ports[0].postMessage('done');

});

登録した Worker 内では message イベントにリスナを貼っておきます。 これで window 側でリロードしたとき毎回イベントで必要なデータを受け取れます。

レスポンスは、 Response コンストラクタで生成できます。 実際の定義はこんな感じ。

typedef (Blob or BufferSource or FormData or URLSearchParams or USVString) BodyInit;
typedef (Headers or sequence<sequence<ByteString>> or OpenEndedDictionary<ByteString>) HeadersInit;

dictionary ResponseInit {
  unsigned short status = 200;
  ByteString statusText = "OK";
  HeadersInit headers;
};

Response(optional BodyInit body, optional ResponseInit init),

今回は JSON にしてますが、 FormData や URLSearchParams を渡せるようにしたり、 そもそも Response の配列を渡せるようにしたい。

statusText は定義通り、省略すると 'OK' が返ってきますが、 Reason Phrase に依存してなければ無視して良いです。

HeadersInit は、 key-value で渡しておけば大丈夫です。

onfetch のリスナは、リロードするたびに上書きしてしまいたいので、こちらはあえて addEventListener を使わないで書いてます。

登録が終わったら、テストを走らせて良い事を window 側にまたメッセージを投げて伝えます。

memo

  • 登録
    • worker のコードを 1 byte でも変更すると再登録される
    • 最初のリロードで取得され、この段階では古いコードが動く
    • 二回目のリロードで、新しいコードが動く
    • worker が登録できなかった時のエラーは慣れないと分かりづらい。
    • 自分自身を register して、単一ファイル内に window 側と SW 側の処理を分岐で突っ込む事もできる。
    • スクリプト内では self instanceof ServiceWorkerGlobalScope で分岐できる。
  • inspector
    • chrome://serviceworker-internals/ を見る
    • 登録して動いている状態で inspect ボタンを押すと inspector が開く
    • Open the DevTools にチェックしておくと勝手に inspector が開く
    • SW 内の console.log はこっちのログに出て、 window 側には出ない
    • SW 内での console.log は毎回 messaging で window に送って表示した方が見やすいかもしれない。
    • inspector が残ってる状態で登録を繰り返すと、なんか inspector の画面が大量に発生してる場合がある?
  • ライフサイクル
    • SW のライフサイクルと、その時点での内部のデータやリスナの状態に注意しないと、更新した後??になる。
    • 生きてるリスナは何か、クロージャに登録されてるのは何か
    • 分からなくなったら inspector を全部閉じて、 unregister すれば消える。
    • さらにブラウザ再起動すれば worker の履歴も消える。
    • worker の履歴、 UI で消せるようにして欲しい。
  • XHR 以外にも使える筈
    • a タグとかもできるはずだけど試してない。
    • 将来 fetch が window に来ても同様にできるはず。
  • プラクティス
    • とにかく Promise 返しておけば間違いない
    • scope とディレクトリ構造のベストプラクティスが欲しい

とにかくコードの更新がうまく反映されてない時に一番ハマるので、慣れるまでは正確にコードが更新された事を確認するのが、当たり前だけど大事でした。

thanks

@hiroki_ さん、@kinu さん、@hirano_y_aa さんがた中の方々に色々ご助言頂きました、ありがとうございました!