実験:Nightmare で XHR を待ってみる


Nightmare で click() 等の結果を待つには、以下のメソッドが用意されています。

  • wait(): ページロードを待つ。
  • wait(ms): ms ミリ秒待つ。
  • wait(selector): ページに selector が出現するまで待つ。
  • wait(fn, value, [delay]): fn の呼び出し結果が value になるまで待つ。delay が指定された場合には都度ページリフレッシュも行う。

SPA をテストする場合には基本的に wait(selector) で待てばいいのでしょうが、例えば、XHR の結果を待ちたくなったときにどうしたらいいのだろうか、ということで実験してみました。

基本的な方針

Nightmare(Phantomjs)では、SPA が勝手に発行したリクエスト/レスポンスも監視してくれるので、それを利用します。

  • resourceReceived で XHR 等のレスポンスを拾い、内部キューに溜める。
  • click() 等の後で、setTimeout を使って内部キューからレスポンスを取り出し必要な判定を行う。
  • コンストラクタでいろいろやりたいので、Nightmare を継承する

実装

testcase.coffee
Nightmare = require 'nightmare'

module.exports = class TestCase extends Nightmare
  constructor: (@caseName, @cnt = 0) ->
    @__responses = []
    super()
    @viewport 1024, 768
    @on 'resourceReceived', (res) =>
      @__responses.push res

  waitXHR: (url, status) ->
    waitInner = ->
      args = arguments
      done = arguments[args.length - 1]

      getResponse = =>
        while @__responses.length > 0
          res = @__responses.shift()

          if res.url.indexOf(url) != -1 && res.status == status
            return true

      waitLoop = =>
        done() if getResponse()
        setTimeout waitLoop, @options.interval

      waitLoop()

    @queue.push [waitInner, [url, status]]
    this

  createCaptureFileName: (name) ->
    baseDir = process.env.EVIDENCE_DIR
    fileName = "00#{++ @cnt}".substr(-3) + "-#{name}.png"
    "#{__dirname}/#{baseDir}/#{@caseName}/#{fileName}"

  capture: (testName) ->
    @screenshot @createCaptureFileName testName

使用例

mocha を使ってみました。テスト開始前に RESTful API 用の proxy を備えたテスト用サーバーを起動しておきます。

startup.coffee
{startServer} = require '../local-server'
moment = require 'moment'

before ->
  startServer 9876, 'public'
  process.env.NODE_ENV = 'test'
  process.env.DEBUG='*'
  now = moment().format("YYYY-MM-DD_HHmmss")
  process.env.EVIDENCE_DIR = "evidence@#{now}"

各テストでは、click() の後で適宜 waitXHR() で待ちます。wait() でタイムアウトを待つ必要もなく、wait(ms) で空振りすることもなく、なかなか良い感じです。

001.login.coffee
TestCase = require './testcase'

describe "ログイン", ->
  caseNumber = 0
  newCase = ->
    caseString = "00#{++ caseNumber}".substr(-3)
    new TestCase(caseString)
      .goto('http://localhost:9876')

  it "ログイン画面を表示すること", (done) ->
    newCase()
      .capture('login')
      .run(done)

  it "ユーザー名とパスワードの未入力でエラーとなること", (done) ->
    newCase()
      .capture('login')
      .click('#login')
      .waitXHR('/api/auth/login', 500)
      .capture('login-fail')
      .run(done)

ポイント

Nightmare はメソッドチェーンを一旦内部キュー(this.queue)に格納します。run() が呼ばれると、キューからメソッドが一つずつ取り出され呼び出されていきます。このときの引数は、格納時の引数と、キューの次のメソッドです。

nightmare/lib/index.js
/**
 * Run all the queued methods.
 *
 * @param {Function} callback
 */

Nightmare.prototype.run = function(callback) {
  var self = this;
  debug('run');
  this.setup(function () {
    setTimeout(next, 0);
    function next(err) {
      var item = self.queue.shift();
      if (!item) {
        self.teardownInstance();
        return (callback || noop)(err, self);
      }
      var method = item[0];
      var args = item[1];
      args.push(once(next));
      method.apply(self, args);
    }
  });
};

最後の callback まで、数珠繋ぎにメソッドが呼ばれていくわけですね。イベント駆動なのにシーケンシャル実行を実現できているのは、こういう仕掛けだったわけです。なるほど。

はまりどころ

当初、XHR のレスポンスを @queue に格納していて、Nightmare のメソッド呼び出し用キューとかぶっていることにしばらく気づきませんでした。ありがちな名前は衝突事故が発生しやすいわりに発見が遅くなるという嫌なパターンですね。

自分が使う変数は特殊なプレフィクスつけるとかで自衛するとして、それでも衝突しちゃった場合にはどうすればいいんですかねぇ。。。

参考