echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

【AWS】aws-sdk-mockでLambdaテストを行う

はじめに

この記事は、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リソースにアクセスしている部分」のみモック化したい
    • AWSリソースへのアクセスをある関数内で行っているが、その関数自体はモック化したくない
      • AWSリソースへのアクセス後の処理はテストで確認したい
  • テスト時は、実際にAWSリソースへのアクセスは不要
    • 仮データでテストすればよい。てかむしろアクセスしたくない
      • 環境面、コスト面の理由

aws-sdk-mockを使うことで、上記の問題を解決できます。

導入手順

では、さっそくaws-sdk-mockを導入してみます。

インストール

まずインストールですが、下記コマンドで行います。

> npm install aws-sdk-mock --save-dev
モック対象のaws-sdkの指定

公式ページに記載の通り、下記ケースではモック対象のaws-sdkを正しく認識できないので、自分でモック対象のaws-sdkを指定する必要があります。

  • 対象のaws-sdkがプロジェクトルートフォルダ直下のnode_modulesフォルダにない場合
  • テスト対象のコードがTypeScriptやES6で書かれている場合

下記いずれかの方法で、モック対象の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がプロジェクトルートフォルダ直下のnode_modulesフォルダにない
  • テスト対象のコードがTypeScriptやES6で書かれている

しかしそれ以外の場合でも、モック対象の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階のみの宝物情報を使用します。

jestjs.io

// 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');
        })
    })
})