はじめに
この記事は、AWS Advent Calendar 2020 最終日の記事です。
qiita.com
また、Serverless Advent Calendar 2020の12/18(金)にも記事を書いてますので、よろしければそちらもお願いします。
qiita.com
ところで
みなさん、テスト書いてますか?
...と言っといてあれですが、正直僕も今年の前半までは、ほとんどテストを書いてませんでした。(なかなか本腰を入れられなかったのもありますが)
でも、秋くらいから本格的にJestを勉強してテストを書いたら、考えがまるっと変わりました。
いやあ、テストは良いわ。てか絶対テストを書くべき。
テストを書くことにより受けられるメリットは、本当に大きいです。
「そんな時間あるならプロダクトコードを...」と思うかもしれませんが、受けられるメリットの大きさを考えたら、テストコードを書く時間なんて必要経費です。(マジで)
ただLambdaのテストを書くにあたり、どうしても問題になるのが「実際にAWSリソースにアクセスする部分」だと思います。(詳細は後述)
そこで、今回はその部分のサポートをしてくれる「aws-sdk-mock」というnpmモジュールを紹介しようと思います。
aws-sdk-mockとは
aws-sdk-mockとは名前の通り、aws-sdkのモック化を行うためのnpmモジュールです。
github.com
テストの場合、実際にAWSリソースにアクセスする部分について、下記のような問題があります。
aws-sdk-mockを使うことで、上記の問題を解決できます。
導入手順
インストール
まずインストールですが、下記コマンドで行います。
> npm install aws-sdk-mock --save-dev
モック対象のaws-sdkの指定
公式ページに記載の通り、下記ケースではモック対象のaws-sdkを正しく認識できないので、自分でモック対象のaws-sdkを指定する必要があります。
下記いずれかの方法で、モック対象のaws-sdkを指定できます。
(個人的には、状況にかかわらずモック対象のaws-sdkを指定した方が良いと思います。理由は後述)
// aws-sdk-mockの読み込み(これは必須) const awsMock = require('aws-sdk-mock'); // 方法1: パスで指定する方法 awsMock.setSDK('../node_modules/aws-sdk'); // 方法2: インスタンスで指定する方法 const aws = require('aws-sdk'); awsMock.setSDKInstance(aws);
なおこの記事では、以後「方法2」のやり方で指定します。
モック関数の定義・再定義・解除
モック関数の定義・再定義・解除は、下記関数で行います。
// ※引数については、下表を参照 // モック関数の定義(mock()メソッド) // awsMock.mock(service, method, replace); // 例: awsMock.mock('DynamoDB.DocumentClient', 'query', function (params, callback) { callback(null, { Items: [ {id:1, value: 'hoge'} ] }); }); // モック関数の再定義(remock()メソッド) // ※一度定義したモック関数の内容を変更する場合に使用する。 // awsMock.mock(service, method, replace); // 例: awsMock.mock('DynamoDB.DocumentClient', 'query', function (params, callback) { callback(null, { Items: [ {id:1, value: 'hoge'}, {id:2, value: 'fuga'} ] }); }); // モックの解除(restoreメソッド) // awsMock.resyore(service, method); // 例: awsMock.restore('DynamoDB.DocumentClient', 'query');
引数 | 説明 | 備考 |
---|---|---|
service | 対象のAWSサービス | S3, SQS など |
method | 対象のaws-sdkのメソッド | getObject(S3), sendMessage(SQS) など |
replace | モック関数の内容 | 関数または文字列が指定可能。 文字列の場合、その文字列を戻り値として返す |
replaceの指定について
mock()メソッドやremock()メソッドのreplace引数で関数を指定する場合、その関数の引数は以下のように指定します。
引数 | 説明 | 備考 |
---|---|---|
params | 対象のaws-sdkのメソッドに渡すパラメータ | S3.getObjectなら {Bucket: 'hoge', Key:'fuga.txt'} など |
callback | コールバック関数 |
またコールバック関数では、引数を以下のように指定します。
引数 | 説明 | 備考 |
---|---|---|
err | エラーオブジェクト | エラーを発生させないならnull |
data | モック関数の戻り値として返す値 |
つまり、モック元のaws-sdk関数の引数params, 及びcallbackの内容と全く同じでOKです。
また先程のソースコードでは引数replaceに直接関数を埋め込んでいますが、もちろん関数の内容を別途定義することもできます。
// mock関数の定義 const replacedFunc = async(params) => { const items = [ {id:1, value: 'hoge'}, {id:2, value: 'fuga'} ]; const filtered = items.filter(x => x.id === params.id); return { Items: filtered } }; // replaceに変数を指定する。 awsMock.mock('DynamoDB.DocumentClient', 'query', replacedFunc);
なお文字列で指定した場合は、その文字列がそのままモック関数の戻り値になるだけなので、特に問題はないかと思います。
注意点
モック対象のaws-sdkの指定について
公式ドキュメントには、(先述の通り)下記の場合に、モック対象のaws-sdkを正しく認識できない、とあります。
しかしそれ以外の場合でも、モック対象のaws-sdkを正しく認識できないケースがあります。(実際、僕もありました)
なので個人的には、状況に関係なくモック対象のaws-sdkを明示的に指定したが良いと思います。(先程「状況にかかわらずモック対象のaws-sdkを指定した方が良い」と書いた理由はそれ)
【参考】aws-sdk-mockを使ってもS3がモックに差し替わらずに困りました - Qiita
子要素のAWSサービスの利用について
aws-sdk-mockで「DynamoDB.DocumentClient」のような、あるサービス(DynamoDB)の子要素(DocumentClient)をモック化する場合で、親要素もモック化したい場合、モック化及びモック解除の順番に注意が必要です。
具体的には、下記の順序でモック化、及びモック解除をする必要があります。
- モック化:子要素→親要素の順にモック化する
- モック解除:親要素→子要素の順にモック解除する
// モック化は、子→親の順に行わないといけない。 AWS.mock('DynamoDB.DocumentClient', 'get', 'message'); AWS.mock('DynamoDB', 'describeTable', 'message'); // モック解除は、親→子の順に行わないといけない。 AWS.restore('DynamoDB'); AWS.restore('DynamoDB.DocumentClient');
おそらく、
- モック化:先に親をモック化すると、その影響で子の定義が参照できなくなる
- モック解除:先に親をモック解除しないと、子の定義が参照できないままになる
からなんでしょうね。
なお、親のみ(または子のみ)をモック化する場合、特に何も気にしないで大丈夫です。(先程のソースを参照)
テスト対象のソースを読み込みするタイミング
テスト対象のソース(今回ならLambdaソース)を読み込む(=requireする)タイミングですが、aws-sdkのモック化を行う前に読み込むと、モック化がうまく行われない場合があります。(問題ない場合もある)
この辺は僕も詳細は不明ですが、とりあえず「テスト対象のソースは、aws-sdkのモック化を行った後に読み込む」ようにした方が良いと思います。
まとめ
以上、駆け足ではありますが、aws-sdk-mockの紹介でした。
aws-sdk-mockのようなテストをサポートするモジュール・ツールを使うことで、テストがとても行いやすくなりますので、そういうモジュール・ツールをどんどん取り入れ、プロジェクトにテストを導入していきましょう。
繰り返しになりますが、テストは「絶対やるべき」だし、本当に「受けられるメリットが大きい」ので、どんどん導入すべきだと考えています。
そのためにも、こういったモジュール・ツールをどんどん取り入れて、快適なテストを実現しましょう。
※なお、最後に参考資料として、簡単なテストソースのサンプルを置いておきますので、参考にしてもらえばと思います。
それでは、よいクリスマスを&よいお年を!
【参考資料】サンプルソース
サンプルソースは、下記2ファイルで構成されています。
- druaga.js(Lambda本体)
- druaga.test.js(druaga.jsのテストソース)
druaga.jsについて
druaga.jsは、株式会社ナムコ(現・株式会社バンダイナムコエンターテインメント)が1983年に発売した超名作ゲーム「ドルアーガの塔」の宝物情報を取得し、返却するLambdaになります。(「ドルアーガの塔」がわからない人は、ググってみてください)
下記の動作を行います。
- クエリパラメータ「floor」で階数を指定された場合、その階の宝物情報のみ返す
- 階数が指定されなかった場合、全60階すべての宝物情報を返す
- どちらの場合も、宝物情報はキー「treasures」に格納する
- 宝物情報はDynamoDBの「tower-of-druaga」テーブルに格納されている
- 今回は細かい処理(階数でソート、「floor」のバリデーション処理など)は行わない
なお「tower-of-druaga」テーブルは、以下の構成となっています。
キー名 | 説明 | 備考 |
---|---|---|
Type | 格納している情報の種類。(今回は「treasure」のみ格納) | パーティションキー |
Floor | 階数(1~60) | ソートキー |
Name | 宝物の名前 | |
Effect | 宝物の効果 | |
Condition | 宝物を出現させる条件 | |
Memo | 宝物に関するメモ |
なお、詳細が知りたい人は「ドルアーガの塔 宝物」などでググってみてください。(インターネットなんてない時代に、よく全部発見しましたよね。当時のゲーマーさん達は)
druaga.test.jsについて
- druaga.jsのテストを行うソースです。
- duraga.jsの4つの関数(index, main, get, scan)の単体テストを定義しています。
- テストフレームワークは、Jestを使用します。
- テストでは、1~3階のみの宝物情報を使用します。
// druaga.jsの内容 'use strict'; const aws = require("aws-sdk"); const Dc = new aws.DynamoDB.DocumentClient({ region: 'ap-northeast-1' }); const TABLE_NAME = 'tower-of-druaga'; const TYPE_TREASURE = 'treasure'; const NO_FLOOR_INDICATED = -1 exports.index = async event => { console.info('[event] ' + JSON.stringify(event)); const floor = event.queryStringParameters && event.queryStringParameters.floor ? event.queryStringParameters.floor : NO_FLOOR_INDICATED; const list = await this.main(floor); const body = { treasures: list } return { statusCode: 200, body: JSON.stringify(body), }; }; exports.main = async(floor) => { let list = null; if(floor === NO_FLOOR_INDICATED) { list = await this.scan(); } else { list = await this.get(floor); } return list; }; exports.get = async(floor) => { const params = { TableName: TABLE_NAME, Key: { Type: TYPE_TREASURE, Floor: floor } } const data = await Dc.get(params).promise(); return data.Item; } exports.scan = async() => { const data = await Dc.scan({TableName: TABLE_NAME}).promise(); return data.Items; }
// druaga.test.jsの内容 const aws = require("aws-sdk"); const awsMock = require("aws-sdk-mock"); const treasureList = { Items:[ { Floor: 1, Name: 'カッパーマトック', Effect: '壁を宝箱を取る前後1回ずつ壊せる', Condition: 'グリーンスライムを3匹倒す', Memo: '' }, { Floor: 2, Name: 'ジェットブーツ', Effect: '足が速くなる', Condition: 'ブラックスライムを2匹倒す', Memo: '' }, { Floor: 3, Name: 'ポーション・オブ・ヒーリング', Effect: 'ミスしても残機が減らない(1回だけ)', Condition: 'ブルーナイトのどちらかを倒す', Memo: '正確な条件は「2体のブルーナイトのうち、先にフロアに出現した方を倒す」' } ] }; let getCalledCount = 0; let scanCalledCount = 0; const replaceGet = async (params) => { getCalledCount++ const data = treasureList.Items.filter(x => x.Floor === params.Key.Floor); return { Item: data[0] } }; const replaceScan = async (params) => { scanCalledCount++; // console.log(JSON.stringify(params)); return treasureList; }; const resetCounter = () => { getCalledCount = 0; scanCalledCount = 0; } awsMock.setSDKInstance(aws); awsMock.mock('DynamoDB.DocumentClient', 'get', replaceGet); awsMock.mock('DynamoDB.DocumentClient', 'scan', replaceScan); const {index, main, get, scan} = require('./druaga'); describe("tower-of-druaga test", () => { describe('index test', () => { it('returns treasure data of indicated floor when floor is indicated', async () => { const event = { "queryStringParameters": { "floor": 1 } } const data = await index(event); const treasures = JSON.parse(data.body).treasures; expect(treasures).toEqual( { Floor: 1, Name: 'カッパーマトック', Effect: '壁を宝箱を取る前後1回ずつ壊せる', Condition: 'グリーンスライムを3匹倒す', Memo: '' } ); }); it('returns all data when floor is not indicated', async () => { const event = { "queryStringParameters": {} } const data = await index(event); const treasures = JSON.parse(data.body).treasures; expect(treasures).toEqual([ { Floor: 1, Name: 'カッパーマトック', Effect: '壁を宝箱を取る前後1回ずつ壊せる', Condition: 'グリーンスライムを3匹倒す', Memo: '' }, { Floor: 2, Name: 'ジェットブーツ', Effect: '足が速くなる', Condition: 'ブラックスライムを2匹倒す', Memo: '' }, { Floor: 3, Name: 'ポーション・オブ・ヒーリング', Effect: 'ミスしても残機が減らない(1回だけ)', Condition: 'ブルーナイトのどちらかを倒す', Memo: '正確な条件は「2体のブルーナイトのうち、先にフロアに出現した方を倒す」' } ]); }) }) describe('main test', () => { describe('when floor is indicated', () => { beforeAll(() => { resetCounter(); }); it('returns treasure data of indicated floor when floor is indicated', async () => { const data = await main(2); expect(data).toEqual( { Floor: 2, Name: 'ジェットブーツ', Effect: '足が速くなる', Condition: 'ブラックスライムを2匹倒す', Memo: '' } ); }); it('get() is called when floor is indicated', async() => { expect(getCalledCount).toBe(1); }); it('scan() is not called when floor is indicated', async() => { expect(scanCalledCount).toBe(0); }); }) describe('when floor is not indicated', () => { beforeAll(() => { resetCounter(); }); it('returns all data when floor is not indicated', async () => { const list = await main(-1); expect(list).toEqual([ { Floor: 1, Name: 'カッパーマトック', Effect: '壁を宝箱を取る前後1回ずつ壊せる', Condition: 'グリーンスライムを3匹倒す', Memo: '' }, { Floor: 2, Name: 'ジェットブーツ', Effect: '足が速くなる', Condition: 'ブラックスライムを2匹倒す', Memo: '' }, { Floor: 3, Name: 'ポーション・オブ・ヒーリング', Effect: 'ミスしても残機が減らない(1回だけ)', Condition: 'ブルーナイトのどちらかを倒す', Memo: '正確な条件は「2体のブルーナイトのうち、先にフロアに出現した方を倒す」' } ]); }) it('get() is not called when floor is indicated', async() => { expect(getCalledCount).toBe(0); }); it('scan() is called when floor is indicated', async() => { expect(scanCalledCount).toBe(1); }); }) }) describe('get test', () => { it('returns treasure data of indicated floor when floor is indicated', async () => { const data = await get(3); expect(data).toEqual( { Floor: 3, Name: 'ポーション・オブ・ヒーリング', Effect: 'ミスしても残機が減らない(1回だけ)', Condition: 'ブルーナイトのどちらかを倒す', Memo: '正確な条件は「2体のブルーナイトのうち、先にフロアに出現した方を倒す」' } ); }) }); describe('scan test', () => { it('returns all data when floor is not indicated', async () => { const list = await scan(); expect(list).toEqual([ { Floor: 1, Name: 'カッパーマトック', Effect: '壁を宝箱を取る前後1回ずつ壊せる', Condition: 'グリーンスライムを3匹倒す', Memo: '' }, { Floor: 2, Name: 'ジェットブーツ', Effect: '足が速くなる', Condition: 'ブラックスライムを2匹倒す', Memo: '' }, { Floor: 3, Name: 'ポーション・オブ・ヒーリング', Effect: 'ミスしても残機が減らない(1回だけ)', Condition: 'ブルーナイトのどちらかを倒す', Memo: '正確な条件は「2体のブルーナイトのうち、先にフロアに出現した方を倒す」' } ]); awsMock.restore('DynamoDB.DocumentClient'); }) }) })