AWS Lambdaのユニットテストのベストプラクティス(Node.js)

概要

みなさん、テスト書いてますかーー!
AWS Lambdaはサーバーレスアーキテクチャを構成する上で、重要なサービスです。そして、その特性上、ユニットテストを書いて変更に強いコードにすることが、継続的なメンテナンスや改善を行っていく上で非常に重要なものとなります。

この記事は、まずローカルで行うLambdaのユニットテストについてその具体的な手法を書いています。なお、ベストプラクティスってタイトルにしてますが、これはベストプラクティスのひとつであり、その要件ややりたいことや個人的な趣味趣向によって変わってくるということは理解してるので、ひとつのやり方としてみなさんの参考になればと思っています。

テスト対象のLambdaとその要件

では、まず初めにテスト対象となるLambdaファンクションの要件を以下のように定義してみます。これを元にしてテストを書きつつその流れを解説したいと思います。

要件

まずは今回の要件です。DynamoDBに以下のBlogテーブルが存在していたとしましょう。

key 用途
post_id 記事ID(ハッシュキー)
post_title タイトル
post_content 本文

以下のような実装を行いたいと考えてみます。
- post_idがLambdaへの外部からの入力値となる
- post_idを元にBlogテーブルを検索して、該当のpost_titleとpost_contentを取得する
- post_titleとpost_contentを返す

処理の流れ

この処理の流れをフローチャートにすると以下のようになります。
Flowchart (2).png

テストのパターン

まずは、どういったテストを網羅する必要があるのかを考えてみます。上記のフローチャートを見ながら、正常系と異常系に分けて考えてみましょう。

以下のようなパターンが考えられると思います。

正常系

  • post_idが渡され、Blogテーブルから検索したデータを返した

異常系

  • Lambdaにpost_idが渡されなかった
  • post_idはLambdaに渡されたが、Blogテーブルに該当のデータが無かった
  • DynamoDB自体が落ちていて処理されなかった

では、これからこの4パターンでテストコードを作っていきましょう。

使用するユニットテスト用のライブラリ

まずはテストコードを書く前にユニットテストに使用したライブラリを紹介します。
Node.jsでテストを書く場合は良く出てくるライブラリですので覚えていて損は無いでしょう。

chai

http://chaijs.com/ はJavaScript用のアサーションライブラリです。これを使うことで、期待通りの入力値と出力値になっているか比較して、テストの成否を判定します。

Chai as Promised

https://github.com/domenic/chai-as-promised はchaiのPromise用の拡張です。
Promiseの状態がどうなっているかを判定します。

Mocha

https://mochajs.org/ はテスティングフレームワークライブラリです。テストを書くための枠組みを提供してくれたり、コマンドラインからの実行をサポートしてくれたり、beforeEach()after()といったテスト前後の定形処理を行う関数を提供してくれたりします。テストを実行するための環境を整えてくれるライブラリです。

proxyquire

https://github.com/thlorenz/proxyquire はrequireモジュールをスタブ化して動作を変更します。要はローカルでテストするにあたって、aws-sdkが毎回AWSに接続されてしまってはテストが出来ません。そこでproxyquireを使うことでaws-sdkの処理を書き換え、ローカルでテストが行えるようにします。

sinon

http://sinonjs.org/ はテスト用にスタブやモック、スパイを作ってくれるライブラリです。今回はスパイの用途で使用して、proxyquireでスタブ化されたaws-sdkが仕様にそって呼び出されているかをチェックします。

Istanbul

https://istanbul.js.org/ はコードのカバレッジ計測をしてくれるライブラリです。例えばif文がひとつ追加されると、単純にテストケースとしては2つのケースに分岐されます。そういった形でソースコードの静的解析を行うことでどの程度全体のテストが網羅されているのかを計測します

メインのソースコード

まずは先にLambdaファンクションのソースコードを載せておきます。上記のフローチャートをそのままLambdaに落とし込むと以下のようになります。

handler.js
'use strict'
const aws = require('aws-sdk')
const dynamodb = new aws.DynamoDB.DocumentClient()

module.exports.blog = (event, context, callback) => {
  return Promise.resolve().then(() => {
    if (!event.post_id) {
      return Promise.reject(responseBilder(400, {message: 'invalid param'}))
    }

    const params = {
      TableName: 'Blog',
      Key: {
        post_id: event.post_id
      }
    }
    return dynamodb.get(params).promise()
  }).then(data => {
    if (!Object.keys(data).length) {
      return Promise.reject(responseBilder(404, {message: 'can not find specified post'}))
    } else {
      return Promise.resolve(responseBilder(200, {
        post_title: data.Item.post_title,
        post_content: data.Item.post_content
      }))
    }
  })
  .then(result => callback(null, result))
  .catch(error => callback(error))
}

const responseBilder = (statusCode, data) => {
  return JSON.stringify({
    statusCode: statusCode,
    body: data
  })
}

ローカルで動作させるためのスタブの構築

Lambda内ではaws-sdkを使ってAWSリソースにアクセスすることが多くなるかと思います。ローカルでテストを行う場合に毎回AWSリソースにアクセスさせる必要はないため(aws-sdk自体の動作は、このアプリケーションのテストの範疇外)、スタブ化してダミーの値を返しつつ、どういう値で呼ばれたか、テスト内で監視を行うのが良いでしょう。

sinonとproxyquireでaws-sdkをスタブ化する

スタブ化するにはproxyquireにてrequireで呼び出されるaws-sdkモジュールをスタブ化した上で、sinonのstubやspyと言ったメソッドで、スタブの呼び出しを監視しながらテストするのがベターです。

handler.test.js
const sinon = require('sinon')
const proxyquire = require('proxyquire')

const proxyDynamoDB = class {
  get (params) {
    return {
      promise: () => {}
    }
  }
}
const lambda = proxyquire('./handler', {
  'aws-sdk': {
    DynamoDB: {
      DocumentClient: proxyDynamoDB
    }
  }
})

const dynamoDbGetStub = sinon.stub(proxyDynamoDB.prototype, 'get')
.returns({promise: () => {
  return Promise.resolve({
    Item: {
      post_title: 'aa',
      post_content: 'bb'
    }
  })
}})

だいたい予想はつくかと思いますが、上記の用に書くと、dynamodb.getはスタブの返り値として指定したJSONを返却してくれます。

{
  "Item": {
    "post_title": "aa",
    "post_content": "bb"
  }
}

疑似Lambdaを実行させるための設定

テストを実行するために擬似的にローカルでLambdaを実行させる仕組みを用意します。
以下がLambdaファンクションのメイン部分なので、これを動かせるようにダミーの引数を与えれば良いということになります。

module.exports.blog = (event, context, callback) => {
}

以下の用に引数を定義しましょう。contextは今回使用してないので、{}を指定しています。使用している場合は以下のドキュメントを参考にダミーの値を設定しましょう。
http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-prog-model-context.html

また、callbackにはPromiseを返すようにしていますが、これはテストの結果をPromiseで返えるようにして、値の検査をやりやすくしているためです。

const event = {
  post_id: 'your-post-id'
}
const callback = (error, result) => {
  return new Promise((resolve, reject) => {
    error ? reject(error) : resolve(result)
  })
}
const context = {}

テストの実装

では、それぞれのテストを実装していきましょう。
テストのパターンは上記の4つです。それぞれのパターンに分けて解説していきます。

post_idが渡され、Blogテーブルから検索したデータを返した

Promiseの状態がfulfilledになっていること、DynamoDBのgetが1回実行されたこと、正常系の返り値が返ってきたことをテストしています。

it('Should return resolve when running successfully', () => {
  return expect(lambda.blog(event, context, callback)).to.be.fulfilled.then(result => {
    expect(dynamoDbGetStub.calledOnce).to.be.equal(true)
    expect(result).to.deep.equal(JSON.stringify({
      statusCode: 200,
      body: {post_title: 'aa', post_content: 'bb'}
    }))
  })
})

Lambdaにpost_idが渡されなかった

先ほど、post_idをセットしていたeventを空オブジェクトで上書きします。
そしてPromiseの結果がrejectedになっていること、入力チェックで落ちたためにDynamoDBが呼ばれなかったこと、異常系の返り値が返ってきたことをテストしています。

it('Should return reject when post_id is not given', () => {
  event = {}
  return expect(lambda.blog(event, context, callback)).to.be.rejected.then(result => {
    expect(dynamoDbGetStub.calledOnce).to.be.equal(false)
    expect(result).to.deep.equal(JSON.stringify({
      statusCode: 400,
      body: {message: 'invalid param'}
    }))
  })
})

post_idはLambdaに渡されたが、Blogテーブルに該当のデータが無かった

dynamoDbGetStubの返り値にデータが取得できなかった時の{}が変えるようにスタブを設定します。そして、異常系の返り値が返ってきたことをテストしています。

it('Should return reject when you can not your item in DB', () => {
  dynamoDbGetStub = sinon.stub(proxyDynamoDB.prototype, 'get')
  .returns({promise: () => {
    return Promise.resolve({})
  }})
  return expect(lambda.blog(event, context, callback)).to.be.rejected.then(result => {
    expect(dynamoDbGetStub.calledOnce).to.be.equal(true)
    expect(result).to.deep.equal(JSON.stringify({
      statusCode: 404,
      body: {message: 'can not find specified post'}
    }))
  })
})

DynamoDB自体が落ちていて処理されなかった

dynamoDbGetStubがrejectedされるようにスタブを設定します。

it('Should return reject when error occurs in DB', () => {
  dynamoDbGetStub = sinon.stub(proxyDynamoDB.prototype, 'get')
  .returns({promise: () => {
    return Promise.reject('error')
  }})
  return expect(lambda.blog(event, context, callback)).to.be.rejected.then(result => {
    expect(dynamoDbGetStub.calledOnce).to.be.equal(true)
    expect(result).to.be.equal('error')
  })
})

テストの実行

package.jsonにて以下のような設定を行いカバレッジの計測とテストの実行をnpm run testで行えるようにします。

"scripts": {
  "test": "istanbul cover -x 'handler.test.js' node_modules/mocha/bin/_mocha 'handler.test.js' -- -R spec --recursive"
}

実行結果は以下のとおりです。
4つともテストは成功して、カバレッジも100%問題なくテストできていることがわかります。
スクリーンショット 2017-06-30 12.38.18.png

まとめ

今回解説したものは以下のGitHubにあげておりますので、興味のある方は確認してみてください
https://github.com/horike37/lambda-unittest-sample

如何でしたでしょうか。ぜひこれを参考にTest Driven Deveropment for Lambdaを実践していってもらえればと思います!