expressやsocket.ioのテストはこんな感じで書いてます、というお話

最近仕事ではNode.jsしか書いてないtoritoriです。


お仕事でもexpressやsocket.ioを使っているのですが
WebアプリケーションのテストについてWeb上にあまり書かれていないような気がします。
特にソケット接続した後のメッセージ送信/受信の部分です。
今までのようなHTTPの単純なリクエスト/レスポンスとは手法が異なりますしどうしたものかと。


そこで「自分はこんな感じでテストしてますよー」という記事を書いてみます。
裏を返せば「もっといい方法あるよ!」というのを聞きたいのです><

サンプルについて

socket.ioのexample を使います。
ただし、サンプルではログイン時に遷移しない方式なので
もう少し実用的な動作に近づかせるため
あえてログインURLから遷移するようにし、
セッションもRedisを使うようにしてexpressとsocket.ioでセッションを使いまわすように変更しました。

サンプルアプリケーションの概要

  1. 「/login」でニックネームを入力してログインします
  2. ログインしたセッションはRedisに保持します
  3. 「/」 に遷移してsocket.ioサーバに接続します
  4. socket.ioサーバのauthorizationでRedis上のセッションを用いて認証します
  5. 認証がOKであればソケット接続が確立されます

チャット部屋では複数クライアントでメッセージのやり取りが可能です

app.js

テストするためのポイントを幾つか。

アプリケーションに外部からアクセスできるようにする

最近のexpressコマンドでは自動でmodule.exportsが追加されていますが、
exampleでは抜けていたので一応書いておきます。

var app = module.exports = express.createServer();
テスト時にはlistenしないようにする

後述しますが、テスト時にはテスト用にポートを変えて起動するため
テスト実行時にはlistenしないように条件を追加しています。

if(!module.parent){
  app.listen(3000,function(){
    var addr = app.address();
    console.log(' app listening on http://'+addr.address+':'+addr.port);
  }); 
}
テスト時にはheaderのcookie名を変更する

こちらも後述しますが、テスト時はsocket.io-clientをカスタマイズして
セッションクッキーを別のヘッダークッキー名から取得するため
ヘッダークッキー名の設定を行なっています。

var header_cookiename = 'cookie';
app.configure('test',function(){
  header_cookiename = 'x-set-cookie';
});

利用しているテストモジュール

テストモジュールは主に以下のものを利用しています。

テスト内容

今回書いたのは以下の様なテストです。

  1. ユーザAがログイン認証を行いソケット接続
  2. ユーザBがログイン認証を行いソケット接続
  3. ユーザBがユーザAに対しメッセージを送信
  4. ユーザAがユーザBからメッセージを受け取り、内容が一致することをテスト
  5. ユーザBがメッセージを受け取っていないことをテスト

test/basic.test.js

heplerも重要なのですがそれより前にテストの流れを追ってみます。

初期化処理
describe('basicテスト', function() {

  // 初期化処理
  before(function (done) {
    // DB初期化
    helper.initDataStore(function() {
      // サーバ起動
      helper.startServer(function() {
        done();
      });
    });
  });

mochaではbeforeファンクションで
init処理を実行することができます。
ここではデータストアの初期化とサーバ起動を行なっています。

ユーザログイン/ソケット接続/テスト
    async.waterfall(
      [
        function userA(callback) {
          var nickname = 'tori';
          var parameter = { nick: nickname }
          helper.login_and_clsocket(parameter, function(socket) {
            usersocket[nickname] = socket;
            socket.on('connect', function() {
              // 'user message' spy用
              socket.on('user message', spy_A);
              // 'user message' 受信テスト
              socket.on('user message', function (nickname, message) {
                  assert.equal(nickname, 'fuga')
                  assert.equal(message, 'wanwan')
              });
            });
            callback();
          });
        },
        function userB(callback) {
          var nickname = 'fuga';
          var parameter = { nick: nickname }
          helper.login_and_clsocket(parameter, function(socket) {
            usersocket[nickname] = socket;
            socket.on('connect', function() {
              // 'user message' spy用
              socket.on('user message', spy_B);
              // メッセージ送信
              socket.emit('user message', 'wanwan');
            });
            callback();
          });
        },
        function finish(callback) {
          setTimeout(function () {
            // userA の 'user message'が呼び出されることをテスト
            assert.equal(true,  spy_A.calledOnce)
            // userB の 'user message'が呼び出されないことをテスト
            assert.equal(false, spy_B.called)

            done();
          }, 50);
        },
      ]
    );
});

次にクライアント処理+spyテストです。
helper.login_and_clsocketを用い、ログイン処理とソケット接続を行なっています。
ユーザAでは受信ハンドラをspyしているのと受信メッセージの検証を行います。
ユーザBでは受信ハンドラのspyとメッセージのemitを行なっています。
asyncモジュールを使うことにより綺麗に書くことが可能です。
最後のspy検証では
「ユーザAの受信ハンドラが1回のみ呼ばれたこと」「ユーザBの受信ハンドラが呼ばれなかったこと」
を検証します。
ちなみにsetTimeoutを用いているのは受信される前にテストが走ってしまう可能性があるためです。
テストの内容によってはもう少し長めにとったりします。

最終処理
  after(function(done) {
    // connection close
    for (var key in usersocket) {
        usersocket[key].disconnect();
    }
    done();
  });

});

最後にsocket.ioコネクションのお掃除を行なっています。

test/support/helper.js

ヘルパーを見てみましょう。

socket.io-client.Socket.prototype.handshakeのオーバーライド
//////////////////////////////////////////////////////////////////////////////////////
// socket.ioクライアントでクッキー送信するようにhandshakeをハイジャック
var xhr_cookie;
function empty () { };
io.Socket.prototype.handshake = function (fn) {
    var self = this
      , options = this.options;

    function complete (data) {
      if (data instanceof Error) {
        self.connecting = false;
        self.onError(data.message);
      } else {
        fn.apply(null, data.split(':'));
      }
    };

    var url = [
          'http' + (options.secure ? 's' : '') + ':/'
        , options.host + ':' + options.port
        , options.resource
        , io.protocol
        , io.util.query(this.options.query, 't=' + +new Date)
      ].join('/');

    if (this.isXDomain() && !io.util.ua.hasCORS) {
      var insertAt = document.getElementsByTagName('script')[0]
        , script = document.createElement('script');

      script.src = url + '&jsonp=' + io.j.length;
      insertAt.parentNode.insertBefore(script, insertAt);

      io.j.push(function (data) {
        complete(data);
        script.parentNode.removeChild(script);
      });
    } else {
      var xhr = io.util.request();

      xhr.open('GET', url, true);
      xhr.setRequestHeader("X-Set-Cookie", xhr_cookie);
      if (this.isXDomain()) {
        xhr.withCredentials = true;
      }
      xhr.onreadystatechange = function () {
        if (xhr.readyState == 4) {
          xhr.onreadystatechange = empty;

          if (xhr.status == 200) {
            complete(xhr.responseText);
          } else if (xhr.status == 403) {
            self.onError(xhr.responseText);
          } else {
            self.connecting = false;
            !self.reconnecting && self.onError(xhr.responseText);
          }
        }
      };
      xhr.send(null);
    }
};
////////////////////////////////////////////////////////////////////////////////////

これはsocket.io-clientでクッキーを使いたい場合のみ必要です。
デフォルトではhandshake時にクッキーを送信しないため認証に失敗してしまいます。
そのためhandshakeをハイジャックしてセッションクッキーを送信するように変更しています。

データストア初期化/アプリケーションサーバ起動
// サーバ起動
function startServer (callback) {
  app.listen(port, function () {
    callback();
  });
};
process.on('exit', function () {
  app.close();
});

// データストア初期化
function initDataStore(callback) {
  redisc.flushdb(function() {
    callback();
  })
}

データストアの初期化やサーバの起動を行なうファンクションです。
もちろん用途に応じてMySQLの初期化や初期データ投入など
いろいろなロジックを書けばよいでしょう。

ログイン/ソケット接続
// ログイン処理 かつ socket.ioのコネクションを返却
function login_and_clsocket (parameter, callback) {
  browser.get('/login', function(res, $){
    $('#set-nickname')
      .fill(parameter)
      .submit(function(res, $){
        var cookies = parse_cookie(res.headers['set-cookie']);
        xhr_cookie = 'connect.sid=' + cookies['connect.sid']['value'];
        var socket = io.connect('http://localhost:' + port, {'force new connection': true });
        callback(socket);
      });
  });
};

// cookie parse
function parse_cookie (cookie_str) {
    if(!cookie_str) return {};

    var cookies = {};
    for (var i=0;i<cookie_str.length;i++) {
        var cookie = new Cookie(cookie_str[i]);
        cookies[cookie.name] = cookie;
    }
    return cookies;
}

ここではtobiを利用して
「ログイン」「パラメータ(ユーザ名)入力」「submit」
を行なっています。
最後にはセッションcookieをゴニョゴニョしてsocket.ioサーバに接続し、
socket.ioコネクションをコールバック関数で返却しています。

テスト実行

NODE_ENVを設定してmochaコマンド実行しましょう。

NODE_ENV=test mocha --reporter spec

ソースコード

アプリケーションとテストは全てこちらにおいてあります。
https://github.com/toritori0318/node-sio-example-chat-test


最後に

非同期テストですが、あまり慣れてないこともあり
ここにたどり着くまでにだいぶ時間をかけてしまいました。
「"この条件の時"には"このメソッドが呼び出される"(または呼び出されない)」
というテストをイメージできるとだいぶ世界が変わるのではないでしょうか。
非同期テスト難しいですね\(^o^)/