echo("備忘録");

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

【AWS】単体テストを考慮したLambdaの構成を考えた

概要

Lambdaのソースコードを書いた際に、ローカル環境で単体テストを実施する場合があると思います。

実は今まで業務では、そこまで単体テストを書いていなかったのですが*1、一度書いてみたら「こりゃ便利だ」と感じ、本腰を入れて単体テストを勉強し始めました。

が「単体テストを考慮してLambdaのソースコードを書く」となる際に、いくつかの問題点があったので、自分なりに考えてみました。

今回はそんな内容です。

※今回使用するソースは、末尾の「サンプルソース」がベースになります。(前回の【JavaScript】awaitの使い方を復習するのソースを少し改変したものです)

よくあるLambdaソースでの問題点

僕が今まで関わったLambdaソースを思い出した際に「単体テスト」という観点でぱっと思いつく問題点といえば、ざっとこんな感じです。(本当は、多分もっとあるでしょうが)

  • メイン処理の詳細(仕様通りの処理が行われたかどうか)が分からない
  • ローカル関数の単体テストができない

上記2点について、書きたいと思います。

メイン処理の詳細が分からない

これのケースでよくあるのが、「ハンドラ関数にメイン処理を全部記載している」というもの。
例えば、まさに末尾の「サンプルソース」に書いたようなコードです。(てか、前回の記事もそうなってますけどね)

テストフレームワーク(MochaやJestなど)の多くは「関数の戻り値」でテスト結果を判別しますが、ハンドラ関数に全部処理を書くと、戻り値からは「成功/失敗しか分からない」ケースがあります。

まだ「クライアントにレスポンスで詳細なデータを返す」ような場合はレスポンスから判定ができますが、そうではない場合、詳細な処理結果を確認するのは困難です。*2

対処方法

対処方法ですが、AWS公式のAWS Lambda 関数を使用する際のベストプラクティスにもある通り、「ハンドラ関数をメインロジックから分離する」のが良いと思います。

サンプルソースで言えば、こんな感じでしょうか。

'use strict';

module.exports.hello = async event => {  
  
  console.info(`[event] ${JSON.stringify(event)}`);  
  const users = event.users;  
  const result = await main(users);  
  
   return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'OK',
      }
    ),
  };  
}  
    
async function main(users) {
  const promises = [];  
  
  for(const user of users) {
    const promise = asyncFuncA(user);
    promises.push(promise);
  }
  
  const result = await Promise.all(promises);
  return result;
};
  
// (以下省略)

ローカル関数の単体テストができない

これですが、Lambda関数は大抵exportするのはイベントハンドラだけです。(てか、それ以外exportする必要ない)
つまり、標準ではテストソースから単体テストを実施できるのはイベントハンドラだけです。

もちろん本来はそれでOKですが、単体テストを行う場合、やはりローカル変数もテストしておきたいところです。

対処方法

対処方法ですが、ちょっと考えたんですが、最終的には単純に「全関数exportするのが一番早いのかも」と思いました。

ソースにすると、こんな感じですかね。

'use strict';

// ここではmodule.exportsを付けない
async function hello(event) {  
  // (途中省略)
}  
    
async function main(users) {
  // (途中省略)
};
  
// (途中省略)  
  
module.exports = {
    hello:hello,  
    asyncFuncA: asyncFuncA,
    asyncFuncB: asyncFuncB
}

ただ「公開する必要がないのにexportするのが気になる」という懸念もあるので、下記のように条件付けexportする感じでしょうか?

// (途中省略)  
  
  const exportFunc = {  
    hello:hello
  }  
  
  // 環境変数ENVが'local'の時のみ、ローカル変数をexportする。  
  // テストを実施するローカルPCで、環境変数ENVの値を'local'にしておく。
  if(process.env.ENV === "local") {  
    exportFunc["main"] = main;
    exportFunc["asyncFuncA"] = asyncFuncA;  
    exportFunc["asyncFuncB"] = asyncFuncB;
  }
  
  module.exports = exportFunc;
}

ただし正直これについては、私もベストアンサーがまだ分からないので、もしも良いやり方があったら、ぜひ教えて頂ければ...と思っています。*3

ローカル変数のスタブ化

これで「よくあるLambdaソースでの問題点」の問題は一通り洗い出しましたが、追加で一点。

ローカル関数が時間がかかるような処理を行う場合、ハンドラ関数の単体テストを行う際に面倒なことがあります。
例えばサンプルソースでも、asyncFuncAが値「0」をresolveするのに2秒かかってしまいます。

このような場合、ハンドラ関数の単体テストでは、さっさと値「0」をresolveしてほしい...となります。(つまり、ローカル関数をスタブ化したい)

で、単体テストについて調べていた時に、それについて下記サイトで説明されており、「なるほど!すごい!」と思いました。
これなら別にnpmモジュールのインストールなどもすることなく、ローカル関数のスタブ化ができます。

てか、頭のいい人はいるんですねえ...

qiita.com

対処方法

対処方法は上記サイトの通りなんですが、実際にソースにしてみました。

かいつまんで説明すると、

  • ローカル変数を直接exportするのではなく、hookポイント(≒hook用の変数)を設ける。
  • ローカル変数は、上記hookポイントの配下に配置する。
  • exportするのは、(ハンドラ関数と)hookポイントのみ

となります。

サンプルソース」で説明すると、以下の通りになります。

  // hookポイント用の変数  
  const hook = {};  
  
  // hookポイントにローカル変数を格納する。  
  hook["main"] = main;
  hook["asyncFuncA"] = asyncFuncA;  
  hook["asyncFuncB"] = asyncFuncB;
  
  // (ハンドラ関数は省略)  
  
  async function main(users) {  
  
    // ローカル関数の呼び出しを、hookポイントからの呼び出し(=hook変数の参照)にする。  
    const promise = asyncFuncA(user);  
    const promise = hook.asyncFuncA(user);  
  }  
  
  // (asyncFuncA, asyncFuncB関数は省略)   
  
  // exportするのは、イベントハンドラとhookポイントのみ
  const exportFunc = {  
    hello:hello,  
    hook: hook
  }  
  
  module.exports = exportFunc;
}

で、テストソースはこんな感じにします。

const { hello, hook } = require("./handler.js");
const event = {  
  users:["userA", "userB", "userC"]
};  
  

describe('handler.js tests', () => {
  describe('hello test', () => {
    it('sample', async () => {
        
      // asyncFuncAの書き換え
      // hook['asyncFuncA'] = async function() { return 1; };
      const response = await hello(event);
      const message = JSON.parse(response.body).message;
      expect(message).toBe("OK");
    });
  })
});

上記では11行目をコメントしていますが、この状態だとサンプルソースの通り、hook['asyncFuncA']は2秒待って値「0」をresolveします。
しかし11行目のコメントを外し、hook['asyncFuncA']の内容を書き換えると、実際にテストを実施した際、asyncFuncAは即座に値「1」をresolveします。

つまり、テスト関数内でローカル関数をスタブ化することも可能になるわけです。

また上記サイトにある通り「明示的に準備したstub関数以外が誤って呼び出されたら undefined 等でエラーにする」なんてテストも可能になったりします。

これを使えば、単体テストが便利になるかもしれないですね。

まとめ

と、ざっとちょっとLambdaの単体テストを触ってみて分かった問題点&対処をまとめてみました。

まだまだ単体テストについては分からないことだらけですが、いろいろ便利なことがたくさんありそうなので、これからも勉強していきたいと思います。

てか、まだまだ分からない点があることもあり、もしかしたら違っていることやアンチパターン的なことを書いてしまっているかもしれませんので、その際は教えて頂ければ非常にありがたいです。

それでは、今回はこの辺で。

告知

私が共同管理者をしている、下記LTイベントが公開されています。

まだまだ参加者を募集中ですので、みなさまぜひご参加ください!

connpass.com

参考:サンプルソース

'use strict';

module.exports.hello = async event => {  
  
  console.info(`[event] ${JSON.stringify(event)}`);  
  const promises = [];  
  
  for(const user of event.users) {
  
    const promise = asyncFuncA(user);
    promises.push(promise);
  }
  
  await Promise.all(promises);
  
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'OK',
      }
    ),
  };
};
  
async function asyncFuncA(user) {
  await asyncFuncB(user);
  return 0;
}
  
function asyncFuncB(user) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, 2000);
  });
}

*1:Serverless Frameworkのinvokeコマンド&eventオブジェクトを定義したJSONファイルの指定でやりくりしていた

*2:CloudWatch/DynamoDB/S3などのトリガで起動するLambda関数の場合、詳細なデータを返さないケースが多いと思います。

*3:「exportしてないローカル変数もテストソースから参照できるようにする」npmモジュールがあったような気がしたのですが...詳細分かったら、後日追記します。