[Node.js] AWS lambdaでaws-sdkをスタブ化してテストする

# 背景
node.jsでlambdaをがっつり運用していくことを考えた場合、**避けて通れないのがテスト**ですね。

lamdaからAWSのリソースにアクセスするためには、aws-sdkを利用するのが一般的だと思いますが、必ずしもローカル環境からAWSのリソースにアクセスできるとは限りません。その為、**ローカルでテストをする際にAWSのリソースに対して何かをするようなテストが書きづらい**と感じるようになりました。

## MockとかStubとかないの？
RubyのSDKだと[Aws::ClientsStubs](http://docs.aws.amazon.com/sdkforruby/api/Aws/ClientStubs.html) みたいなModuleが用意されているのですが、JavascriptのSDKにはそれらしきものが見当たりません。npmモジュールでいくつかあるようですが、なんとなく自分には取っ付き辛いものでした。

## これいいかも？
そこで考えたのが、[**proxyquire**](https://github.com/thlorenz/proxyquire)という名前のnpmモジュールを使い、aws-sdkをスタブ化するというものです。

proxyquireとは、require関数をプロキシしてくれるモジュールです。
このモジュールを利用するために実装コードを修正する必要もないし、オーバーライドされていないメソッドに関してはそのまま機能します。`use strict`にも対応します。

# やってみた
とりあえず、[rotate-snapshot](https://github.com/szkkentaro/rotate-snapshot) というlambdaを書きました。

内容は、外から与えられたパラメータを元にフィルタリングしたSnapshotを取得し、最新N件を保持する(残りは削除する)というシンプルなものです。下記コードは、上記lamdba functionのリポジトリから今回の記事に関係ない部分を取り除いたバージョンになります。

## 実行コード
まず、実行コードを見ていきます。処理の流れはざっくりこんな感じです。

- フィルタリングされたSnapshotのデータを取得する
- 取得したSnapshotのデータから、削除対象のSnapshotのIDリストを抽出する
- リストを元に該当するSnapshotを削除する

とりあえず、handlerの中身だけさらっと見て、テストコードに移ります。

```js:index.js
var aws   = require("aws-sdk")
  , _     = require("underscore")
  , async = require("async")
  , ec2   = new aws.EC2({apiVersion: "2015-10-01"})
  ;

var delList = function(snapshots, rotate) {
  return _.chain(snapshots)
          .sortBy(function(o) { return -o.StartTime.getTime(); })
          .rest(rotate)
          .value();
};

var delParams = function(snapshots, dryRun) {
  return _.chain(snapshots)
          .map(function(o) { return _.pick(o, "SnapshotId"); })
          .map(function(o) { return _.extend(o, { DryRun: dryRun }); })
          .value();
};

// async map iterator
var iterator = function(params, callback) {
  console.log("Deleted snapshotId : ", params);
  ec2.deleteSnapshot(params, function(err, data) {
    if (err) {
      if (err.hasOwnProperty("code") && err.code == "DryRunOperation") {
        data = err;
        err = null;
      }
    }
    callback(err, data);
  });
};

exports.handler = function(event, context) {
  // params that will be passed to ec2.describeSnapshots function
  var params = { DryRun: false, Filters: event.filters };

  // describe snapshots and delete them.
  ec2.describeSnapshots(params, function(err, data) {
    if (err) {
      return context.done(err, err.stack);
    }

    // find snapshot ids to delete
    var snapshotIds = delList(data.Snapshots, event.rotate);
    if (+snapshotIds.length == 0) {
      console.log("Do nothing. There is no snapshot to delete.");
      return context.succeed();
    }

    // delete snapshots
    var params = delParams(snapshotIds, event.dryRun);
    async.map(params, iterator, function(err, results) {
      return context.done(err, results);
    });

  });
};
```

## テスト
次にテストコードです。**proxyquire**の登場です。

lambdaはイベント駆動で発火するサービスなので、ローカルでテストをする場合は自前でhandler関数をコールする必要があります。その際は、contextオブジェクトやeventオブジェクトも作って引数として一緒に渡します。

テストコードを見てみます。

```js:test/index_test.js
var proxyquire = require('proxyquire')
  , assert     = require('assert')
  , sinon      = require('sinon')
  ;

describe('rotate-snapshot', function() {
  describe('#handler(event, context)', function() {

    context('when ec2.describeSnapshots() calls back with err', function() {
      it('should call context.done', function () {
        var context = { done: function(err, data) {
                          assert.deepEqual({stack: 'foo'}, err);
                      }}
          // create ec2 stub function
          , ec2 = function() {
                    this.describeSnapshots = function(params, callback) {
                      var err = {stack: 'foo'}
                      callback(err, null);
                    };
                  }
          // require lambda func with aws-sdk stub via proxyquire
          , lambda = proxyquire('../index', { 'aws-sdk': {EC2: ec2} })
          ;
        // execute handler func
        lambda.handler({ filters: [], dryRun: true, rotate: 7 }, context);
      });
    });

    context('when ec2.describeSnapshots() calls back with data', function() {
      var context = { done: function(err, results) {
                              assert.deepEqual([{}], results);
                            },
                      succeed: function(){}
                    }
        // create ec2 stub function
        , ec2 = function() {
                  this.describeSnapshots = function(params, callback) {
                    callback(null, {Snapshots: [
                      {SnapshotId: 's-1', StartTime: new Date(2015,1,1)},
                      {SnapshotId: 's-2', StartTime: new Date(2015,1,2)}
                    ]})
                  };
                  this.deleteSnapshot = function(params, callback) {
                    assert.deepEqual({ SnapshotId: 's-1', DryRun: true }, params);
                    callback(null, {});
                  };
                }
        // require lambda func with aws-sdk stub via proxyquire
        , lambda = proxyquire('../index', { 'aws-sdk': { EC2: ec2 } })
        // spy console.log func
        , spy    = sinon.spy(console, 'log')
        ;

      // execute handler with event and context
      lambda.handler({ filters: [], dryRun: true, rotate: 2 }, context);
      it('should call context.succeed', function () {
        var msg = 'Do nothing. There is no snapshot to delete.'
        assert.ok(spy.calledWith(msg));
      });

      // execute handler with event and context
      lambda.handler({ filters: [], dryRun: true, rotate: 1 }, context);
      it('should call context.done', function () {
        var params = {DryRun: true, SnapshotId: 's-1'};
        assert.ok(spy.calledWith('Deleted snapshotId : ', params));
      });

    });
  });
});
```

このテストでは、proxyquire経由で実行コード(index.js)を読み込む際にスタブ化したaws-sdk(正確にはec2)を渡しています。

これにより、`ec2.describeSnapshots`でテスト用の処理を実行できるようにしています。

あとは、handler関数を呼び出すだけですね。

`describeSnapshots`関数内で、テスト用の値を引数にコールバック関数を呼び出すことで`context.done`でアサーションしています。

また、proxyquireとは直接関係ありませんが、sinonのspyオブジェクトを利用してconsole.logに渡ってくるエラーメッセージをアサーションしています。

# まとめ
proxyquireを使って`aws-sdk`をスタブ化する話でしたが、require経由で呼び出すモジュールであればaws-sdkに限らずスタブ化することが可能だと思います。

lambdaにおけるローカルテストについては、利用者の増加と共にどんどん洗練されていくと思うので楽しみですね。

今回作った lambda function は[こちら](https://github.com/szkkentaro/rotate-snapshot)になります。利用する際にはeventのバリデートが適当なので、必要に応じて追加していただければと思います。また、lambdaのRoleにSnapshotの操作権限が必要なのをお忘れなく。