概要
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モジュールのインストールなどもすることなく、ローカル関数のスタブ化ができます。
てか、頭のいい人はいるんですねえ...
対処方法
対処方法は上記サイトの通りなんですが、実際にソースにしてみました。
かいつまんで説明すると、
- ローカル変数を直接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イベントが公開されています。
まだまだ参加者を募集中ですので、みなさまぜひご参加ください!
参考:サンプルソース
'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); }); }