概要
みなさん、テスト書いてますかーー!
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を返す
処理の流れ
この処理の流れをフローチャートにすると以下のようになります。
テストのパターン
まずは、どういったテストを網羅する必要があるのかを考えてみます。上記のフローチャートを見ながら、正常系と異常系に分けて考えてみましょう。
以下のようなパターンが考えられると思います。
正常系
- 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に落とし込むと以下のようになります。
'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
})
}
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と言ったメソッドで、スタブの呼び出しを監視しながらテストするのがベターです。
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%問題なくテストできていることがわかります。