echo("備忘録");

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

【Node.js】ES2020仕様の便利な機能

はじめに

つい先日(2/3)、AWS LambdaがNode.js 14のサポートを開始しました。
AWS Lambda が Node.js 14 のサポートを開始

Node.js 10が4月末でEOL(新規Lambda作成は3月末くらいまで)になるので、このタイミングでNode.js 14(または12)への移行を検討している人もいるのでは?と思います。

そこで今回は、Node.js 14...というよりは、ES2020仕様(node.js 10ではほとんどが未サポート)で追加された、便利だなと思う新要素をピックアップしてみました。

ちなみに、各種要素のバージョン別サポート状況は、下記サイトで確認できます。
https://node.green/

紹介する新要素

  • Promise.allSettled
  • nullish coalescing operator (??)
  • optional chaining operator (?.)
  • (おまけ)CloudFormationでのデプロイ

Promise.allSettled(node.js 12.9.0以降)

これは前々回の【JavaScript】非同期処理(async/await)に関するちょっとしたTips - echo("備忘録");でも紹介した新要素です。

使い方としては下記ソースのようになり、引数の配列に指定した複数のPromiseについて、

  • 全Promiseの結果が返る(resolve/reject)されるまで待つ
  • 各Promiseについて、個別にresolve/rejectを判別できる

というものです。

Promise.all()と似ていますが「rejectされたPromiseがあっても、Promise.allSettled()自体はrejectされない(=エラーにならない)」点が異なります。

Promise.allSettled([...promise]).then(results => {});

具体的には、下記ケースで役に立ちます。

  • 複数の非同期処理を並列に実施する
  • 上記非同期処理が1つ以上rejectされるケースがおこりうる
  • 上記非同期処理がrejectされても、処理フロー自体はエラー扱いにしたくない

サンプルソース

/**
* Promise.allSettlednの使用例
*/  
const main = async () => {
  
    const promises = [];
    
    for(let i = 0; i <3; i++) {  
        // someFuncAsync()は何か処理を行う非同期処理。  
        // ただし、処理フロー上rejectされるケースが  
        // 普通に起こりえるとする。
        promises.push(someFuncAsync(i));
    }
  
    // Promise.allSettledで全Promiseを待つ
    const results = await Promise.allSettled(promises);
    
    // ここからが異なる
    for (let j = 0; j < 3; j++) {
  
       // Promise.allSettled()を使用した場合、各promiseの  
       //「status」キーでresolve/rejectを判別可能  
       // もちろんrejectされても、Promise.allSettled()自体は
       // rejectされない。(=エラーにならない) 
        if (results[j].status === 'fulfilled') {  
            // statusが'fullfilled'の場合、そのpromiseはresolveされた。 
            // その場合、resolveされた値はキー「value」に格納される
            console.info(`someFuncAsync(${j}) はresolveされました。`);
            console.info(`戻り値: ${results[j].value}`);
        } else if (results[j].status === 'rejected'){  
            // statusが'rejected'の場合、そのpromiseはrejectされた。  
            // その場合、キー「reason」にその理由が格納される
            console.warn(`someFuncAsync(${j}) はrejectされました。`);  
            console.warn(`理由: ${results[j].reason}`);
        }
    }
}  

nullish coalescing operator (??) (node.js 14.0.0以降)

日本語に訳すと「null合体演算子」(?)

使い方としては、下記ソースのような感じです。

const hoge = fuga ?? piyo;

上記ソースですが、挙動として

  • fugaがnullまたはundefinedの場合のみ、??の右の値(piyo)を返す。
    • hoge=piyoになる
  • fugaがnullでもundefinedでもない場合、??の左の値(fuga)をそのまま返す。
    • hoge=fugaになる

となります。

今までこういう場合、下記ソースのように「||」演算子で比較していましたが、「??」は「||」と違い、fugaが空文字、0、falseでもhoge=fugaになるという違いがあります。

というか、この点が非常に大きくて、結構これがバグの原因になったり、制御が厄介だったりしたので、個人的にこの機能の追加追加は本当に嬉しいです。

// 「||」での判定では、fugaが空文字, 0 ,falseだとhoge = piyoになる。 
const hoge = fuga || piyo;  
  
// 「??」での判定では、fugaが空文字, 0 ,falseでもhoge = fugaになる。 
const hoge = fuga || piyo;  

optional chaining operator (?.) (node.js 14.0.0以降)

日本語に訳すと「選択的連結演算子」(かな?)

使い方としては、下記ソースのような感じで「nullまたはundefinedになるかもしれない要素、およびその子孫要素にアクセスする」場合に使用します。

const hoge = fuga?.piyo

上記ソースは、下記の挙動となります。

  • fugaがnullまたはundefinedの場合、hoge=undefinedになる。
  • fugaがnullでもundefinedでもない場合、hoge = fuga.piyoになる。
ネストされたオブジェクトへのアクセスに便利

これが便利なのが、上記に書いた通り「ネストされたオブジェクトへのアクセス」で、例えば下記オブジェクト(何かしらのユーザーの指名情報)があったとします。

そして「fullName以降のキーは指定されないケースもある」とします。

const userData = {
    user: {  
        country: 'USA',
        fullName: {
            firstname: 'John',
            middleName: 'Fitzgerald', 
            familyName : 'Kennedy' 
    }
}

この場合に「firstName」にアクセスする場合、今までだと下記のように、親以上の要素のキーについてチェックする必要がありました。

const firstName = (userData.user.fullName && userData.user.fullName.firstName) ? userData.user.fullName.firstName : "No firstName";  
  
// 下記だとfullNameがない場合にエラーになる  
const firstName = userData.user.fullName.firstName;  

しかし今回のoptional chaining operatorを使えば、下記1行でOKになります。

// 下記だとfullNameがなくてもエラーにならない。  
// fullNameがない場合、firstNameはundefinedになる。  
const firstName = userData.user.fullName?.firstName;  
  
// nullish coalescing operatorと組み合わせて、下記書き方もできる。  
const firstName = userData.user.fullName?.firstName ?? "No firstName";  

(おまけ)CloudFormationでのデプロイについて

AWS Lambdaのランタイムをnode.js 14にしてCloudFormationでデプロイする場合ですが、AWS公式のドキュメントの「Runtime」項目には「node.js 14.x」の記載がありません。

docs.aws.amazon.com

しかし、これは単にドキュメントの修正がまだされてないだけで、Runtimeに「node.js14.x」を指定すれば、問題なくnode.js 14でのデプロイが可能です。

また、Serverless Frameworkでもprovider.runtimeに「node.js14.x」を指定すれば、問題なくnode.js 14でのデプロイが可能です。(下記画像の警告は出ますが、デプロイ自体は問題なし)
f:id:Makky12:20210211191551p:plain

まとめ

以上、Node.js 14(てか、ES2020仕様)で便利な機能の紹介でした。

中でもPromise.allSettled()やnullish coalescing operatorなんかは、本当に便利だと思いますので、こういう便利な新機能をどんどん活用していきたいですね。

というか既存機能でも結構知らない機能があったので、node.green なり MDN Web Docs なりでしっかり見直しておかないといけないなあ、と思いました。

告知

3/20(土)に開催される「JAWS DAYS 2021」にて、「AWS Lambdaのテストで役立つ各種ツール」という内容で登壇させていただくことになりました。(諸事情により、タイトル変更しました)
jawsdays2021.jaws-ug.jp

内容としては、1/6(水)の「Serverless Meetup Japan Virtual #14」での登壇内容である「Severless Frameworkで行うLambdaテスト」をベースに、時間の関係でお話しできなかった内容を追加する予定です。

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

【Jest】SpyOnで関数のMock化ができない場合の対処

概要

JavaScriptのソースを単体テストする際、Jestを使っている人も多いと思います。

jestjs.io

で、ある関数のテストをする際に、その関数が呼ぶ別の関数を一時的にmock関数にしたい場合があります。

Jestでは、それをspyOn()というメソッドを使うことで実現できます。

  
// 例:例えばこのvideo.play()関数自身はtrueを返すけど...
const video = {
  play() {
    return true;
  },
};
  
module.exports = video;
const video = require('./video');
  
test('plays video', () => {
  // こんな感じでspyOn()してあげることで、mock関数化して、  
  // そのふるまいを自由に定義できる。   
  // ここではfalseを返すように変更している
  const spy = jest.spyOn(video, 'play').mockReturnValue(false);
  const isPlaying = video.play();
  
  // 上でfalseを返すように変更しているので、このテストはNGになる。
  expect(isPlaying).toBe(true);
  
  spy.mockRestore();
});

が、spyOn()を実行してもmock関数化されないという現象がありましたので、その対処法についてが今回の内容です。

具体的な現象

下記のソースがあるとします。

// ただnumが偶数化奇数かを判定するだけのもの。  
// 仮にファイル名をevenOrOdd.jsとする。  
// このmain関数をテスト対象とする。
const main = num => {
  const modNum = getEvenOrOdd(num);
  const resultString = modNum === 0 ? "even" : "odd";  
  
  return resultString
};
  
const getEvenOrOdd = num => {
    return num % 2;
}
  
// module.exports宣言  
module.exports = {
    main: main,  
    getEvenOrOdd: getEvenOrOdd
}

で、main関数の単体テストとして、下記のテストを書いたとします。
コメントにある通り、このテストは本来NGになるはずです。

const target = require('./evenOrOdd');
  
test('numが偶数の時、文字列evenを返すこと', () => {   
  // ここではspyOn()を使用し、getEvenOrOdd()は必ず1を返すようにする。  
  const spy = jest.spyOn(target, 'getEvenOrOdd').mockReturnValue(1);
  const result = target.main(2);
  
  // 上でgetEvenOrOdd()は必ず1を返すように変更しているので、  
  // このテストはNGになるはず。  
  // (本来なら必ず文字列oddを返すはず)
  expect(result).toBe("even");
  console.log("result is " + result);  
  
  spy.mockRestore();
});

ところが、上のテストはOKになってしまいます。(実際ログで確認しても、resultの値はevenになっています。) f:id:Makky12:20210205202844p:plain

こんな感じで、spyOn()が作用しない(=実際の関数を実際に呼んでしまう)状況があります。

原因と対処法

【参考サイト】:Jestで関数から呼ばれてる関数をspyOnする - Qiita

原因ですが、参考ページの「Jestで関数から呼ばれてる関数をspyOnする - Qiita」にある通り「スコープが違うから」です。

上記のソースだと「module.exportsしたgetEvenOrOddと、main関数が呼ぶgetEvenOrOddは別物」という扱いなので、spyOnしてもダメみたいです。

対処法ですが、「同じスコープで扱えるように、getEvenOrOddをオブジェクトの要素として扱う」になります。

const main = num => {  
  // getEvenOrOddを呼ぶ側は、オブジェクトを介して呼ぶ。  
  // オブジェクトを介することで、Jestでmainをテストする際も  
  // オブジェクト経由でgetEvenOrOddを認識できるようになる。  
  const modNum = self.getEvenOrOdd(num);
  const resultString = modNum === 0 ? "even" : "odd";  
  
  return resultString
};
  
const getEvenOrOdd = num => {
    return num % 2;
}
  
// こんな感じで、getEvenOrOddをオブジェクト(self)の要素にする  
const self = {
    getEvenOrOdd : getEvenOrOdd
};
  
// module.exports宣言  
module.exports = {
    main: main,    
  
    // getEvenOrOdd関数自体ではなく、それを持つオブジェクトをexportする。  
    self: self
}
const target = require('./evenOrOdd');
  
test('numが偶数の時、文字列evenを返すこと', () => {   
  // テスト側もオブジェクト(targer.self)経由でgetEvenOrOdd()をspyOnする。  
  const spy = jest.spyOn(target.self, 'getEvenOrOdd').mockReturnValue(1);
  const result = target.main(2);
  
  // 上でgetEvenOrOdd()は必ず1を返すように変更しているので、  
  // このテストはNGになるはず。  
  // (本来なら必ず文字列oddを返すはず)
  expect(result).toBe("even");
  console.log("result is " + result);  
  
  spy.mockRestore();
});

上記テストソースを実行すると、確かに今度はspyOn()が作用して、テストはNGとなりました。(ちゃんとoddが返ってます)
f:id:Makky12:20210206085930p:plain

thisを使った方法

ちなみに「this」を使用すれば、わざわざオブジェクトを作成&exportしなくても、spyOnは正常に動作します。

ただし「thisが自分自身(=evenOrOdd.js)を指すかどうか」については、要注意。(特にアロー関数を使っている場合など)

function main(num) {  
  // thisを介してgetEvenOrOddを呼んでもOK。  
  const modNum = this.getEvenOrOdd(num);
  const resultString = modNum === 0 ? "even" : "odd";  
  
  return resultString
};
    
function getEvenOrOdd(num) {
    return num % 2;
}
  
// module.exports宣言  
module.exports = {
    main: main,    
    getEvenOrOdd: getEvenOrOdd,
}
const target = require('./evenOrOdd');
  
test('numが偶数の時、文字列evenを返すこと', () => {   
  // thisがあることでmain関数はgetEvenOrOdd()を認識可能なので、  
  // spyOnできる。  
  const spy = jest.spyOn(target, 'getEvenOrOdd').mockReturnValue(1);
  const result = target.main(2);
  
  // 「原因と対処法」同様、このテストは正しくNGになります。  
  expect(result).toBe("even");
  console.log("result is " + result);  
  
  spy.mockRestore();
});

exportsを使う場合

なお「module.exports」ではなく、各関数を「exports」する場合でも、thisを使うことで別関数を呼び出せるし、テスト時にspyOnも実行できます。

exports.main = function(num) {  
  // 各関数を個別にexportsする場合でも、thisを使って  
  // 別関数を呼び出せる。  
  // もちろんテスト関数でspyOn()もできる。(結果は同じなので省略)
  const modNum = this.getEvenOrOdd(num);
  const resultString = modNum === 0 ? "even" : "odd";

  return resultString
};
  
exports.getEvenOrOdd = function(num) {
    return num % 2;
}

で、どっちがいいの? ※以下、個人的な感想です

【参考サイト】:Node.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiita

ここまでspyOnするための関数のexport方法として

  • 関数を内包したオブジェクトのexport
  • thisを使って、関数を個別にexport(module.exportsにしろ個別exportにしろ)

の2つを紹介しましたが、個人的には前者が良いと思っています。

理由としては、以下の通りです。

thisが指す内容

先述の通り、thisが関数の書き方(アロー関数を使うか否か)によって、意図しないものを指す場合があります。
それをちゃんと分かっていればいいんですが、知らずにやってしまうと、思わぬバグのもとになるからです。

ちなみに、Vue.jsでは上記の挙動のため、一部の関数(computedやライフサイクルメソッド)で「アロー関数を使用すべきではない」と書かれています。

不要な関数を公開しない

今回の「getEvenOrOdd」関数はprivateな関数なので、テスト用途以外で公開する必要はありません。
こういう「公開不要なもの」は、極力公開すべきではないと考えています。(特に本番環境などでは)

関数をオブジェクトで内包するやり方だと、公開する/しない制御が簡単にできるからです。

このあたりの考え方については、参考サイトのNode.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaにも記載されていますし、僕も過去に【AWS】単体テストを考慮したLambdaの構成を考えた - echo("備忘録"); でブログにしています。

const main = function(num) {  
  const modNum = self.getEvenOrOdd(num);
  const resultString = modNum === 0 ? "even" : "odd";
  
  return resultString
};
  
const getEvenOrOdd = function(num) {
    return num % 2;
}  
  
const self = {  
    // getEvenOrOddをオブジェクトで内包する 
    getEvenOrOdd : getEvenOrOdd
};  
  
// module.export用オブジェクト。  
// mainはpublicな関数なので、無条件で公開する。
const exportsFunc = {
    main: main
};  
  
// self(evenOdOdd)はprivateな関数なので、開発環境のみ  
// テスト用途で公開する。
if (process.env.ENVIRONMENT === 'dev') exportsFunc.self = self;  
  
module.exports = exportsFunc;
spyOnしなくても関数をmock化できる

関数をオブジェクトで内包する場合、その関数はあくまで「オブジェクトのキーの値」です。
なので、テスト内でオブジェクトのキーの内容を書き換えることで、強制的に関数の内容を変更することができます。

つまり、spyOnを使用しなくても、テスト時に関数のmock化を行うことができますので、どうしてもspyOnがうまく動かない場合も、関数のmock化が可能になります。

参考サイトのNode.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaでも紹介されていますね。(てか、僕もそれを参考にさせてもらったわけですが)

const target = require('./evenOrOdd');
  
let mockFuncCache = null;
  
beforeAll(() => {  
    // テスト開始前に、self.getEvenOddの内容  
    // (=evenOrOdd.jsのgetEvenOdd関数の内容)をコピーしておく
    mockFuncCache = Object.assign({}, target.self).getEvenOrOdd;
});
  
afterEach(() => {  
    // 各テスト終了後に、self.getEvenOrOddの内容をevenOrOdd.jsの内容に戻す。  
    target.self.getEvenOrOdd = mockFuncCache;
})  
  
test('numが偶数の時、文字列evenを返すこと', () => {
    // 最初のテストでは、self.getEvenOrOddを書き換えて、必ず1を返すようにする。
    target.self.getEvenOrOdd = function() { return 1; };
    const result = target.main(2);
  
    // self.getEvenOrOdd()は必ず1を返すので、
    // このテストはNGになるはず。
    expect(result).toBe("even");
    console.log("result is " + result);
});
  
test('numが偶数の時、文字列evenを返すこと', () => {
    // 2回目のテストでは、self.getEvenOrOddの内容は書き換えない。  
    // 1回目テスト終了~2回目テスト開始の間にafterEach()を通るので、  
    // self.getEvenOrOddの内容はevenOrOdd.jsのものと全く同じ。
    const result = target.main(2);
  
    // 今回はself.getEvenOrOdd()はnum=2なら0を返すので、
    // このテストはOKになるはず。
    expect(result).toBe("even");
    console.log("result is " + result);
});

上記テストの結果がこちら。
期待通り1回目はNG、2回目はOKになっています。

このことからも、自分で書き換えた内容が正しく反映されているのがわかります。
f:id:Makky12:20210206194104p:plain

まとめ

以上、SpyOnで関数のMock化ができない場合の対処、およびさらに踏み込んでexportする際の考え方について書きました。

確かにSpyOnで関数のMock化ができない場合の対処だけならともかく、さらに一歩踏み込んだところまで考えると、Node.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaで紹介されているhook化は、感心してしまいます。

自分もこういう有効なやり方はどんどん取り入れていきたいし、自分でもそういうのをもっと思いついていかないとなあ、と思いました。

告知

3/20(土)に開催される「JAWS DAYS 2021」にて、「AWS Lambdaのいろいろなテスト方法」という内容で登壇させていただくことになりました。
jawsdays2021.jaws-ug.jp

内容としては、1/6(水)の「Serverless Meetup Japan Virtual #14」での登壇内容である「Severless Frameworkで行うLambdaテスト」をベースに、時間の関係でお話しできなかった内容を追加する予定です。

あと、上記「Serverless Meetup Japan Virtual #14」での登壇スライドですが、Youtubeの「新時代のサーバーレス チャンネル」チャンネルにて、AWSの西谷さんに紹介していただきました。(27:20くらいから)


Monthly AWS Serverless update 2021/01

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

【JavaScript】非同期処理(async/await)に関するちょっとしたTips

はじめに

新年あけましておめでとうございます。(もう1/25だけど)

去年もいろいろありましたが、今年も正月休みも終わり、そろそろ平常運転にも慣れてきたので、ブログの方も開始しようと思いました。

で、新年一発目のネタですが、最初は安定のServerless Framework..と思ったんですが、業務でJavaScript(Node.js)の非同期処理についてちょっと調べる機会があったので、その内容をブログにしました。

ちなみに、僕も過去に非同期処理に関するブログをいくつか書いてますので、良ければそちらも読んでみてください。

内容

  • Promise.allSettled()でPromiseの結果を個別に判定する
  • ループの中でawaitを使う場合の挙動
  • JavaScriptでsleep関数を実現する方法

Promise.allSettled()でPromiseの結果を個別に判定する

2020年6月リリースのES2020(ECMAScript2020)で、「Promise.allSettled()」というメソッドが追加されました。(Node.jsならver12.9.0以上で使用可能)

これはどんなメソッドかというと、複数の非同期処理に関して、下記の挙動を実施するメソッドです。

  • 引数の配列で指定した非同期処理がすべてresolve/rejectされるまで待つ
    • これはPromise.all()と同じ
  • 引数の配列で指定した非同期処理について、個別にresolve/rejectを判別可能
    • これがPromise.all()と違う点
    • 1つでもrejectされたらメソッド自体がrejectされる...ということがない

これの何が便利かというと、

・ 個別にresolve/rejectを判別可能
rejectされた処理があってもメソッド自体はrejectされない

という点です。

これまで、「非同期処理の並列化」といえばPromise.all()を使っていたわけですが、Promise.all()は

引数の非同期処理のうち1つでもrejectされた場合、その時点でPromise.all()自体がrejectされる

という挙動なので、複数の非同期処理を並列に実行する際に、

  • 複数の非同期処理で、rejectされる状況が仕様上おこりえる。
  • rejectされた非同期処理があっても、並列実行の結果自体はrejectしたくない(≒エラーにしたくない)

という場合に、非同期処理側で下記のような処理を行う必要があり、一手間がかかりました。

/**
* 例:AWSのS3バケットからキー(ファイル)の内容を読み込む処理
* ただし、ファイルの内容が存在しないことは普通に起こりえるとする
*/  
const main = async () => {
  
    const keys = ['hoge.json', 'fuga.json', 'piyp.json'];  
    const promises = [];
    
    for(const key of keys) {
        promises.push(getS3ObjectAsync(key));
    }
  
    const promises = await Promise.all(promises);
  
    for (let i = 0; i < 3; i++) {
  
        if (promises[i].size !== -1) {
            // getS3ObjectAsync()で何もなかった場合の処理  
            conssole.info(`${keys[i]} のファイルサイズは${promises[i].size}, 内容は${promises[i].content} です。`);
        } else {
            // getS3ObjectAsync()でエラー発生した場合の処理  
            conssole.warn(`${keys[i]} はsample-bucketバケットに存在しません。`);
        }
    }
}  
  
/**
* s3バケットからgetObject()でキーの内容を読み込む。
*/ 
const getS3ObjectAsync= async (key) => {
  
    const param = {
        Bucket: 'sample-bucket',
        Key: key
    };
    
    let contentsObj = null;   
    
    try{  
        const data= await s3.getObject(param).promise();  
        contentsObj = {  
            size: data.ContentLength,  
            content: data.Body.toString()
        };  
    catch(e) {  
        // エラー発生時は、専用の値を返す。  
        // 指定したkeyのファイルがsample-bucket内にない場合、  
        // エラー発生する。 
        console.warn(`key ${key} is not found`);
        contentsObj = {  
            size: -1,  
            content: ""
        };
    }  
  
    return contentsObj;
}  
  
(async ()=> {  
    await allSettledAsync();  
}).call();  

しかしPromise.allSettled()を使えば、メインロジック側で下記のような方法でresolve/rejectを判別できるので、非同期処理側で個別に上記のような処理を行わなくてよくなります。

/**
* 例:AWSのS3バケットからキー(ファイル)の内容を読み込む処理
* ただし、ファイルの内容が存在しないことは普通に起こりえるとする
*/  
const main = async () => {
  
    const keys = ['hoge.json', 'fuga.json', 'piyp.json'];  
    const promises = [];
    
    for(const key of keys) {
        promises.push(getS3ObjectAsync(key));
    }
  
    // ここまではさっきと同じ  
    const promises = await Promise.allSettled(promises);
    
    // ここからが異なる
    for (let i = 0; i < 3; i++) {
  
       // Promise.allSettled()を使用した場合、各promiseの  
       //「status」キーでresolve/rejectを判別可能  
       // もちろんrejectされても、Promise.allSettled()自体は
       // rejectされない。(=エラーにならない) 
        if (promises[i].status === 'fulfilled') {
  
            // statusが'fullfilled'の場合、そのpromiseはresolveされた 
            // その場合、resolveされた値はキー「value」に格納される
            conssole.info(`${keys[i]} のファイルサイズは${promises[i].value.size}, 内容は${promises[i].value.content} です。`);
        } else if (promises[i].status === 'rejected'){
  
            // statusが'rejected'の場合、そのpromiseはrejectされた
            conssole.warn(`${keys[i]} はsample-bucketバケットに存在しません。`);
        }
    }
}  
  
/**
* s3バケットからgetObject()でキーの内容を読み込む。
*/ 
const getS3ObjectAsync= async (key) => {
  
    const param = {
        Bucket: 'sample-bucket',
        Key: key
    };
     
    // ここでcatchする必要がない(エラー発生時はそのままrejectしてOK)
    const data= await s3.getObject(param).promise();  
    const contentsObj = {  
        size: data.ContentLength,  
        content: data.Body.toString()
    };  
  
    return contentsObj;
}  
  
(async ()=> {  
    await allSettledAsync();  
}).call();  

ループの中でawaitを使う場合の挙動

JavaScriptの非同期処理をググってみると、たまに

  • ループの中で非同期処理は使えない
  • ループの中で非同期処理を使う場合は注意が必要

という記載がある記事を見かけます。

ただ、記事によって書いてある内容が違っててごちゃごちゃしてたので、実際に検証しました。

結論:ループ内でも普通にawaitは使える ※ただしforEach()を除く

まず結論から言うと、ほとんどのループ処理(下記)では、普通にループ内でawaitが使用できます。

  • for
  • for ~ of
  • for ~ in
  • while

ただし例外として、forEach()内ではawaitは機能しません。
なのでforEach()内でawaitを使用したところで、resolve/rejectを待つことなく次のループ処理を実施してしまいます。

※ちなみにMDNには、以下のように記載されています。

forEach はプロミスを待ちません。forEach のコールバックとしてプロミス (または非同期関数) を使用する場合は、その意味合いを理解しておくようにしてください。

動作確認

サンプルとして、下記ソースを用意しました。
(ちょっと長いですが、概要としては「先述の各ループ処理について、個別にawaitしながら5回非同期処理を実施する」という内容です)

// for~of、forEach()で使用するカウントアップ用の配列  
const counts = [1, 2, 3, 4, 5];  
  
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));  
  
// while内でawaitする  
const whileAsync = async () => {  
  
    console.log("関数[whileAsync]を実行します");  
  
    let cnt = 0;  
  
    while(true) {
        if(5 <= cnt) break;
  
        console.log(`引数は${cnt + 1}です。`);
  
        try {
            await sampleFuncAsync(cnt + 1);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt + 1}のawaitが終了しました。`);
        cnt++;
    }
}
  
// forループ内でawaitする  
const forAsync = async () => {
  
    console.log("関数[forAsync]を実行します");
    for(let cnt = 0; cnt < 5; cnt++) {
  
        console.log(`引数は${cnt + 1}です。`);
  
        try {
            await sampleFuncAsync(cnt + 1);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt + 1}のawaitが終了しました。`);
    }
}
   
// for~ofループ内でawaitする
const forOfAsync = async () => {
  
    console.log("関数[forOfAsync]を実行します");
    for (const cnt of counts) {
  
        console.log(`引数は${cnt}です。`);
  
        try {
            await sampleFuncAsync(cnt);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt}のawaitが終了しました。`);
    }
}
  
// for~inループでawaitする
const forInAsync = async () => {
  
    console.log("関数[forInAsync]を実行します");
    const fruits = { apple: 1, orange: 2, banana: 3, melon: 4, grape: 5 };
  
    for (const fruit in fruits) {
  
        console.log(`果物は${fruit}、引数は${fruits[fruit]}です。`);
  
        try {
            await sampleFuncAsync(fruits[fruit]);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`果物${fruit}、引数${fruits[fruit]}のawaitが終了しました。`);
    }
}
  
// forEach()ループでawaitする
const forEachAsync = async () => {
  
    console.log("関数[forEachAsync]を実行します");
  
    counts.forEach(async (cnt) => {
  
        console.log(`引数は${cnt}です。`);
  
        try {
            await sampleFuncAsync(cnt);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt}のawaitが終了しました。`);
    });
}
  
// サンプルの非同期処理。  
// index秒待って、indexが偶数ならresolve, 奇数ならrejectする。
const sampleFuncAsync = async (index) => {
  
    await _sleep(index * 1000);
  
    if (index % 2 !== 0) {
        throw new Error(`${index} is Odd!`);
    }
  
    return true;
}
  
// 非同期関数の呼び出し
(async ()=> {
    await forAsync();
    await forOfAsync();
    await forInAsync();
    await forEachAsync();
    await whileAsync();
}).call();

で、上記ソースの実行結果が下記枠内です。

結果としては、forEach()以外はすべて想定される挙動である

  • 「引数はxです」とログ記載
  • x is Odd!とエラーログ記載(xが奇数の場合のみ)
  • 「引数xのawaitが終了しました」とログ記載
  • xをカウントアップ

という結果ですが(=awaitが正しく機能している)、forEach()だけはいきなり1~5について「引数はxです」のログが書かれていることから、awaitしてもresolve/rejectを待っていないことがわかります。

関数[whileAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forOfAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forInAsync]を実行します
果物はapple、引数は1です。
1 is Odd!
果物apple、引数1のawaitが終了しました。
果物はorange、引数は2です。
果物orange、引数2のawaitが終了しました。
果物はbanana、引数は3です。
3 is Odd!
果物banana、引数3のawaitが終了しました。
果物はmelon、引数は4です。
果物melon、引数4のawaitが終了しました。
果物はgrape、引数は5です。
5 is Odd!
果物grape、引数5のawaitが終了しました。
関数[forEachAsync]を実行します
引数は1です。
引数は2です。
引数は3です。
引数は4です。
引数は5です。
1 is Odd!
引数1のawaitが終了しました。
引数2のawaitが終了しました。
3 is Odd!
引数3のawaitが終了しました。
引数4のawaitが終了しました。
5 is Odd!
引数5のawaitが終了しました。

for await of はあるけれど...

もう一つ、ループの非同期関数に関する処理として、「for await of」というのがあります。
これは、非同期のループ処理(=非同期イテレーター&ジェネレーター)について、awaitしながらループを順に処理する、というものです。

具体的な使用方法は、下記ソースを見てください。(非同期イテレータ&ジェネレーターについては、ここでは詳しく説明しません)

const counts = [1, 2, 3, 4, 5];  
  
// lists配列の要素の数値について、その2乗の数をresolveする
// 非同期イテレーターのジェネレーター
const sampleFuncAsyncGenerator = async function* (lists) {
    for(const cnt of lists) {  
        try {  
            console.log(`cntは${cnt}です。`);
            yield cnt * cnt;  
        catch(e)  {  
            // rejectするとその時点でfor await ofループが終了するので、  
            // for await ofループは継続したい場合、エラー発生時専用の値を   
            // resolveする必要がある
            yield Number.MIN_VALUE;  
        }
    }
};  
  
const forAwaitAsync = async () => {
  
    console.log("関数[forAwaitAsync]を実行します");
  
    try {  
        // for await ofを使用して、非同期イテレータで非同期処理を  
        // awaitしながら実施
        for await (const cnt of sampleFuncAsyncGenerator(counts)) {  
            if(cnt !== Number.MIN_VALUE) {  
                console.log(`cntの2乗は${cnt}です。`);
                console.log(`awaitが終了しました。`);  
            } else {  
                // エラー発生時の値がresolveされた場合
                console.log(`エラーが発生しました。`);  
            }
        };
    }catch (e) {  
        // この書き方でわかる通り、rejectされるとその時点で  
        // for await of ループが終了してしまう...
        console.warn('error: ' + e.message);
    }
};  
  
(async ()=> {  
    await forAwaitAsync();
}).call();

ただ、これを使ってた感想として、

  • ループ内でawaitした場合と、そこまで違いが見受けられない
    • あえてfor await of(=非同期イテレーター)を使う理由って何だろう?(知ってたら教えてほしいです)
  • reject時の処理が面倒
    • 上のソースでなんとなくわかる通り、rejectされるとその時点でfor await ofループがが終了してしまう
    • 先述の「メイン処理自体はrejectしたくない(後続のfor await of処理を継続したい)」場合、Promise.all()同様、固有の処理が必要

というのがあり、個人的には「for await of 使うなら、for~ofループ内でawaitした方が良いのでは?」というのが正直な感想です。

というか、そもそもループ内でawait自体が

と、ここまでループ内でのawaitについて書きましたが、個人的にはそもそもループ内でawaitすること自体がNGだと思います。

なぜなら、ループでawaitするということは、非同期処理の大きなメリットである「並列処理による実行時間短縮」を捨ててしまっているからです。
なので、(何か特別な理由がない限り)そのようなソースを書くべきではないます。

代わりに「ループでは非同期処理の呼び出しだけ行って、awaitは全呼び出し終了後にまとめて実施」しましょう。

  
// NG例  
// 全同期処理を個別にawaitすると、処理時間は各処理時間の合計になる  
// 例えば、200msかかる非同期処理を5回実行した場合、  
// 全処理終了までに1000msかかる
for (const cnt of counts) {
    await sampleFuncAsync(cnt);
}  
  
// -----------------------------------------------------------  
  
// OK例  
// 全同期処理をまとめてawaitすると、処理時間は「各処理時間のうち、
// 一番長かったものの処理時間」になる  
// 例えば200msかかる非同期処理を5回実行しても、200msで全処理終了する。
const promises = [];  
for (const cnt of counts) {  
  
    // ループ内では、非同期関数の呼び出しだけ実施
    promises.push(sampleFuncAsync(cnt));
}
  
// Promise.all()なりPromise.allSettled()なりで、
// 呼び出し非同期処理を一括でawaitする。
await Promise.all(promises);

ちなみに、ESLintでも「ループ内のawait」はデフォルトで不許可になっています。
eslint.org

JavaScriptでSleep関数を実現する方法

これは本当に小ネタレベルですが、PromiseとsetTimeout()を組み合わせた下記の関数(_sleep関数)を呼び出すことで、JavaC#などにあるSleep処理をJavaScriptでも実行できます。(「ループの中でawaitを使う場合の挙動」でも使用しています)

// Sleepしたいときに呼び出す関数(この1行だけでOK)  
// 引数msは、スリープさせたい時間(ミリ秒)
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));  
  
// 具体的な使用例  
const waitThreeMin = async () => {  
    console.log('3分間待ってやる');  
    await _sleep(1000 * 60 * 3); 
    console.log('時間だ'); 

    return;
}

まとめ

という感じで、非同期処理のちょっとした情報をまとめました。

特にPromise.allSettled()は個人的には割と「待ってました」な感じの機能なので、Node.jsのバージョンが12.9.0以上の環境での開発なら、有効活用していきたいなあ、と考えています。

また、ES2020(ECMAScript2020)ではPromise.allSettled()以外にも便利な機能がいろいろ追加されているので、興味があれば下記サイトなどで一度調べてみるのもいいかもしれません。

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

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

Serverless Framework はじめの一歩

はじめに

この記事は、QiitaのServerless Advent Calendar 2020 18日目の記事です。

qiita.com

なお来週12/25(金)にも、AWS Advent Calendar 2020最終日の記事として「aws-sdk-mockを使ったAWSのテスト」の記事を投稿しますので。そちらもよろしくお願いします。
qiita.com

今回の記事について

今年もAdvent Calendarが始まりました。
昨年と同様、Serverlessにもたくさんの記事が投稿されています。

そして、Serverless Frameworkに関する記事もたくさん投稿されています。

「Serverless」のアドベントカレンダーに参加するにあたり、最初はServerless FramewrokのTips系にしようと考えていました。(個人的にもServerless Framewrok大好きなので)

しかし、今回それ系の記事(=Serverless Frameworkをある程度知っている前提)を書く人が結構多いので、いっそのこと、Serverless Frameworkをあまり知らない人向けに記事を書くことにしました。

今回のメインターゲット

  • Serverless Frameworkを全く知らない
  • Serverless Frameworkは知っているが、やり方がよくわからない

Serverless Frameworkとは?

サーバーレスアプリを構成管理&デプロイするためのオープンソースツールです。
AWS CloudFormationやAzure Resource Managerなど、IaC(Infrastructure as Code)の機能を使い、コードベースでのサーバーレスアプリの構成管理を行ったり、各クラウドへのデプロイを行えたりします。

その他、AWS・Azure・GCPを始め、数多くのクラウドをサポートしています。(一番機能が豊富なのはAWS)

また、他にも下記のようなことができるのが特徴です。(あくまで一例)

  • 豊富なテンプレート(=プログラム言語対応)
  • Serverless FrameworkのダッシュボードからCI/CDを実現できる
  • 公式ドキュメントが結構充実している&英語も読みやすい(個人的には)
  • プラグイン(=拡張機能)をインストールして、機能を拡張できる

※Serverless Framework公式ページ: www.serverless.com

導入のメリットは?

※これ以降、すべてAWS前提で書きます。(先述の通り、一番機能が豊富なので)

もちろん先程挙げた点もメリットなのですが、1つだけ挙げるとするならば、個人的には

Lambda定義が非常にシンプル

な点だと思います。

実際、AWSのサーバーレスアプリで一番多く作成するリソースがLambdaだと思うので、Lambdaの定義が非常にシンプルなのは、非常に大きなメリットです。

具体例

例えばCloudFormation構文で、よくあるRest API的なLambdaを作成する場合、

  • API Gateway本体(Type: AWS::ApiGateway::RestApi)を作って...
  • API Gatewayのリソースパス(Type: AWS::ApiGateway::Resource)を作って...
  • API Gatewayのメソッド(AWS::ApiGateway::Method)を作って...
  • Lambdaに割り当てるIAM Role(AWS::IAM::Role)作って(Cloudwatchへの書き込み権限など)...
  • Lambda本体(AWS::Lambda::Function)を作って...
  • CloudWatchのLogGroup(AWS::Logs::LogGroup)作って...

みたいな感じで、すごく膨大な量の定義を作ることになり、めちゃくちゃ大変だと思います。(めちゃくちゃ長くなるので、ソースは省略)

これがServerless Frameworkだと、下記7行の定義だけで、上記のことがすべてできてしまいます。

# Serverless FrameworkのLambda定義例
Makky12Lambda:
  handler: makky12.handler
  name: Makky12Lambda
  events:
    - http:
        path: /hoge
        method: get

また、Lambdaはたいてい何かしらのトリガーで起動しますが、トリガ周りの定義も簡単です。 以下に一例を挙げます。(他にもたくさんあります。下記URLの各子項目を参照)

www.serverless.com

Makky12Lambda:
  handler: makky12.handler
  name: Makky12Lambda
  events:
    - http:  # API Gatewayトリガ
        path: /hoge
        method: get
    - s3:  #S3バケットトリガ(ファイル保存時に発生)
        bucket: makky12-bucket #バケット名
        event: s3:ObjectCreated:*
    - stream:  #DynamoDBトリガ(データ追加・変更・削除)
        type: dynamodb
        arn: (DynamoDBテーブルのARN)
    - schedule: cron(0 12 * * ? *)  #CloudWatchイベントトリガ(スケジューラ)

こんな感じで、Lambdaが非常に簡単に定義できてしまうのが、Serverless Frameworkの魅力の一つです。

インストール&プロジェクト作成

と、ここまででなんとなく概要は話しましたので、さっそくインストール&プロジェクト作成を行いましょう。

インストール

インストールは、下記コマンドで実施します。

# グローバルじゃなくてもいいけど、たぶんグローバルの方が便利だと思う。
> npm i serverless -g
プロジェクト作成

プロジェクト作成は、下記コマンドで行えます。

# 代表的なオプションは、下表を参照。
> serverless create --template aws-nodejs --name hoge  
  
# なお'serverless'コマンドには'sls'というエイリアスがあるので、  
# これでも実行可能。  
# ただしPowerShellだとエラーになる。(Select-Stringのエイリアスと
# 勘違いされる)
# 昔は'slss'というエイリアスがあったが、ver1.9あたりで削除された...
> sls create --template aws-nodejs --name hoge
オプション 意味 デフォルト値
--template 作成プロジェクト(言語など)のテンプレート 省略不可
--name プロジェクト名 Serverless Frameworkが命名
--path プロジェクト作成先フォルダ カレントフォルダ

※「--template」に指定するテンプレートの種類は、こちらを参照。 https://www.serverless.com/framework/docs/providers/aws/cli-reference/create/

プロジェクトを作成すると、プロジェクトを作成したフォルダに「handler.js」及び「serverless.yml」の2ファイルができると思います。(--templateで指定したテンプレートにより異なります。ここでは「aws-nodejs」を指定した前提で進めます)

※下画像では他のファイル・フォルダが出来ていますが、「handler.js」及び「serverless.yml」の2ファイルさえあればOKです。(「.serverless」フォルダはデプロイ時(正確にはパッケージ時)に出来ます) image.png

  • --templateで「aws-nodejs-typescript」を指定した場合、「serverless.yml」が「serverless.ts」になっていますが、定義する項目は同じです。
  • プロジェクトを作成するフォルダに既にpackage.jsonや(aws-nodejs-typescriptの場合)tsconfig.jsonがあるとエラーになるので、その場合は一度リネームしたり、別フォルダに退避してください。

serverless.ymlで確認する項目

といっても、ただ動かすだけならほとんど手を加える必要はないですが、「provider」セクションの下記項目だけ、確認&変更(コメント解除)しておきましょう。

項目 意味 デフォルト値 備考
region デプロイ先のリージョン。 us-east-1(バージニア北部) 多分、これ読んでる人は大抵がap-northeast-1(東京)かと
profile デプロイに使用するAWSプロファイル名 なし 設定すると、credential情報内の指定したprofile名の情報をデプロイ時などで使用する(いちいちコマンド実行時に指定しなくてよい)。
※コマンド実行時に指定することも可能。
  • credential情報は、下記コマンドやAWS CLIで作成できます。
    • AWS CLIについては各自調べてください
  • keyやsecretの値は、事前にIAMで作成しておいてください。
    • これも、わからなければ各自調べてください。
# keyはアクセスキー、secretはシークレットアクセスキー。  
# providerはクラウドごとに一意の値。(awsは「aws」固定)
# profileはプロファイル名(オプション)。省略時は「default」になる。
> serverless config credentials --provider aws --key key --secret secret --profile custom-profile

デプロイ&動作確認

では、ここまで終わったら、さっそくデプロイします。

「え?Lambdaは?」と思うかもしれませんが、serverless.ymlにはデフォルトでhandler.jsというjsファイル(=Lambdaのソースファイル)、およびこれをAPI Gateway経由で(=Rest APIとして)起動する定義がされていますので、Lambda周りは一切触らないでOKです。

デプロイは、下記コマンドで実施できます。

# -vオプションを付けると、デプロイ状況を確認できるようになる。
# (なくてもデプロイ自体はできる)
> serverless deploy -v

デプロイに問題なければ、下記のようなメッセージ作成され、実際にAWSを見ると、stackやAPI-Gateway、Lambdaが作成されているはずです。
image.png

動作確認ですが、Serverless Frameworkには「invoke」という、デプロイしたLambdaを実行できるコマンドがあるので、それを実施します。(もちろんcURLやRest Clientなどから実際に該当URLにリクエストを送って確認してもOKです。)

# ローカルで実行も可能だが(invoke local)、今回はデプロイしたLambdaの動作確認なので、実際にAWSのLambdaを実行する
> serverless invoke --function hello

レスポンスで200が返れば、OKです。 image.png

これで「Serverless Framework はじめの一歩」はクリアです。お疲れさまでした。

まとめ

いかがでしたか? ザックリとではありますが、Serverless Frameworkの概要をつかんでもらえたかなと思います。

Serverless Frameworkはそれだけでも非常に便利なツールですし、(今回は紹介しなかったけど)プラグインを使って、驚くほど便利な機能を色々追加することもできます。

ぜひ有効活用して、効率的なサーバーレスアプリ開発を行っていただければと思いますし、この記事がその一助になればと思います。

以上です。

【AWS】cfn-diagramでCloudFormationテンプレートをプレビューする

はじめに

以前、以下の記事で「VS Code Conference Japan」で登壇したということを書きました。

makky12.hatenablog.com

その際の登壇資料の「Azure Template Viewer」のスライド(14ページ)で、「CloudFormationにも、こういう拡張機能が欲しい...」と発表したのですが...

※Azure Template Viewerは、Azure Resource Manager(AWSのCloudFormationに該当する機能)のテンプレートの内容をダイアグラムで視覚的に確認できる拡張機能 f:id:Makky12:20201212152830p:plain

なんとCloudFormationにも、そういうツールがありました。(VS Code拡張機能ではないけど)

というわけで、今回はそのツールの紹介です。

cfn-diagram

そのツールは「cfn-diagram」という名前で、npmモジュールとして公開されています。

github.com

これを使うことで、Azure Template Viewerのように、CloudFormationテンプレートの内容をダイアグラムで視覚的に確認することができます。

f:id:Makky12:20201211195548p:plain

また、下記形式で出力できます。

  • Draw.io
  • HTML
  • 画像(まだexperimentalな段階。今回は詳しく取り上げません)

Draw.io形式で出力

Draw.io形式で出力する場合、VS Code拡張機能である「Draw.io Integration」が必要なので、事前にインストールしておきます。(VS Code Conference Japanでも紹介したやつです)

marketplace.visualstudio.com

インストールしたら、下記コマンドを実行します。

> cfn-dia draw.io -t (CloudFormationテンプレートファイルのパス)  
# 例:  
> cfn-dia draw.io -t ./template.yaml

すると、カレントフォルダに「template.drawio」ファイルが作成され、ダイアグラムがDraw.io Integration内に表示されます。
f:id:Makky12:20201212083006p:plain

また、下記条件でフィルタをかけることもできます。

項目 説明 備考
Filter Resources by Type リソース種別でフィルタリングを設定する AWS::IAM::Role, AWS::Lambda::Function など
Filter Resources by Name リソース名でフィルタリングを設定する
Edge labels 矢印についているラベル(移動するリソース)の表示/非表示を設定する

HTML形式で出力

HTML形式で出力する場合、下記コマンドを入力するだけOKです。

> cfn-dia html -t (CloudFormationテンプレートファイルのパス)  
# 例:  
> cfn-dia html -t ./template.yaml

すると、ダイアグラムがブラウザに表示されます。
f:id:Makky12:20201212083829p:plain

また、 下記操作も行えます。

  • 拡大・縮小
  • ダイアグラム全体の移動
  • 個別アイテムの移動

画像

画像形式は先述の通りまだexperimentalな状態なので、詳しくは説明しませんが、

とのことです。

分かっている問題

公式ページの「Known issues」にもある通り、下記の問題があるそうです。

  • 全部のアイコンがそろっているわけじゃない(一部抜けがある)
  • WSLの環境でHTML出力をしようとすると「Error: spawn wslvar ENOENT」というエラーが出る
    • wsluというモジュールをインストールすれば解決する模様。(自分は未確認)

ここは理解した上で使用してください。

Serverless Frameworkで使用する

ここまでcfn-diagramの使い方を説明してきましたが、肝心の「Serverless Frameworkではどうなのか」というのを最後に紹介したいと思います。

www.serverless.com

結論

で、いきなり結論ですが、serverless.yml(serverless.ts含む)自体は、cfn-diagramではダイアグラム表示できません。(エラーになる)

まあ、当然と言えば当然です。(CloudFormationテンプレート形式じゃないし)

しかし裏を返せば「CloudFormationテンプレート形式なら可能」なので、パッケージング後の「cloudformation-template-update-stack.json」ファイルなら、cfn-diagramでダイアグラム表示できます。

  • 実は「Draw.io形式で出力」「HTML形式で出力」で表示したダイアグラムも、「cloudformation-template-update-stack.json」ファイルをダイアグラム表示したものです。
  • 「cloudformation-template-update-stack.json」ファイルは、デフォルトではルート直下の「.serverless」フォルダに保存されます。
  • 「cloudformation-template-create-stack.json」というファイルもありますが、これはパッケージング後の*.zipファイルを保存するS3バケットの定義になります。(serverless.ymlで定義したリソース定義とは別)

まとめ

いかがでしたでしょうか?

cfn-diagram、非常に導入も使い方も簡単なので、興味がある方は一度使ってみてはいかがでしょうか?

実際僕も便利だなあと思いますし、業務で開発しているサーバーレスアプリの開発でもいい感じに使っていますので、こういう便利なツールをもっと活用していきたいと考えております。

お知らせ

Qiitaで開催中の「Advent Calendar 2020」において、2記事を担当することになりました。
担当するテーマは、サーバーレスAWS です。

ちなみに、下記のタイトルで記事きます。(確定)

  • サーバーレス(12/18(金)):Serverless Frameworkを始めよう または Serverless Frameworkでクロススタック参照を行う
  • AWS(12/25(金)):aws-sdk-mockでAWSのテストを行う

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

【Serverless Framework】Serverless Framework + TypeScript + JestでAWS Lambdaの開発&テスト環境を構築する その1

はじめに

皆さん、先日はVS Code Conferenceの視聴、ありがとうございました。
※見てない人は下のURLから視聴できます。

VS Code Conference Japan - YouTube

今年はVS Codeで登壇を結構させて頂きましたが、肝心の&僕が愛してやまないServerless Frameworkでの登壇が少ないなあ...なんて思ってます。

そういう理由...でもないですが、今回は久々にServerless Frameworkのブログです。

Serverless Framework公式ページ:
www.serverless.com

やること

タイトルの通り、今回は下記の環境でAWS Lambdaの開発環境を構築し、Jestでのテストも行えるようにします。

  • Serverless Framework
  • TypeScript
  • Jest

特に、今までずっとNode.jsでコードを書いてたので、TypeScriptもしっかりやらないと...という思いが強いです。(むしろ、本来は静的型付け言語の方が好きなので)

前提

とりあえず、上記3つのインストールを済ませておきます。(下記コマンドは、公式サイトのものをそのまま記載)

※global(-g) installではない場合、この後の「プロジェクト作成」の後で実行した方が良いです。(理由は後述。ただServerless Frameworkはどうしようもないけど...)

# Serverless Framework  
> npm install serverless -g  
  
# TypeScript  
> npm install typescript -g  
  
# Jest(もちろんglobal(-g) installでもOK)  
> npm install --save-dev jest  

で、Jestの設定をjest.config.jsやpackage.jsonに書きます。(下記はpackage.jsonに記載した場合)

{  
  "jest": {
    "roots": [
      "<rootDir>/"
    ],
    "testMatch": [
      "**/__tests__/**/*.+(ts|tsx|js)",
      "**/?(*.)+(spec|test).+(ts|tsx|js)"
    ],
  },
}

Serverless Frameworkプロジェクト作成

まずはServerless Frameworkのプロジェクトを作成します。
「--template」に「aws-nodejs-typescript」を指定すれば、TypeScriptのオプションを作成できます。

# 下記にはないけど、「--path」オプションでプロジェクトを作成する
# フォルダを指定できる。 (未指定時はカレントフォルダに作成)    
> serverless create --template aws-nodejs-typescript --name serverless-typescript-test

注意点としては、下記の点です。

  • 対象フォルダにすでに「package.json」や「tsconfig.json」があると、エラーになりますので、一時的にリネームor別フォルダに退避しておいてください。
    • 先程「グローバルではないnpm installはプロジェクト作成の後で」と書いたのはそのため
  • TypeScriptpプロジェクトの場合、テンプレートファイルが「serverless.yml」ではなく「serverless.ts」ファイルになります。
    • もちろん定義内容はまったく同じ。むしろ型定義ファイルによるインテリセンスが効くので、なかなか便利。

テスト対象のLambda関数の作成

まずはテスト対象のLambda関数の作成ということで、下記ソースを書きました。
※今回、コードの詳細には触れません。概要はソースコメントを参照。

// Day.jsのimport
import dayjs, { Dayjs } from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
  
dayjs.extend(utc)
dayjs.extend(timezone)
  
// Lambdaだったり、DynamoDBのimport
import { APIGatewayProxyEvent, APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import 'source-map-support/register';
import { Context } from 'vm';
import * as AWS from 'aws-sdk';
import { DocumentClient } from 'aws-sdk/clients/dynamodb';
  
// ハンドラ関数
export async function hello(event:APIGatewayProxyEvent, _context:Context):Promise<APIGatewayProxyResult>{
  console.log(`[event] ${JSON.stringify(event)}`);
  let response: APIGatewayProxyResult = null;
  
  const items:DocumentClient.ItemList = await getDynamoData();
  const dateTime:string = getDate();
  const nodeVer: string = getNodeJsVersion();
  
  response = {
    statusCode: 200,
    body: JSON.stringify({
      items: items,
      dateTime: dateTime
    })
  };
  
  return response;
}
  
// DynamoDBからquery()関数で特定データを抽出する
export async function getDynamoData():Promise<DocumentClient.ItemList> {
  
  const documentClient: DocumentClient = new AWS.DynamoDB.DocumentClient();
  const param: DocumentClient.QueryInput = {
    TableName: 'nature-remo-events-history',
    KeyConditionExpression: 'app_name = :app_name and date_time_num = :date_time_num',
    ExpressionAttributeValues: {
      ':app_name': 'NatureRemoTest',
      ':date_time_num': 20201122073504
    }
  };
  
  const data: DocumentClient.QueryOutput = await documentClient.query(param).promise();
  console.info(`[data] ${JSON.stringify(data)}`);
  
  return data.Items;
}
  
// 現在の日付or指定した日時の日付をISO形式で返す
export function getDate(dateTime?:string | number, format?:string): string {
  const moment:Dayjs = (function():Dayjs {
    const momentInterim:Dayjs = dateTime ? dayjs(dateTime) : dayjs();
    return momentInterim.tz('Asia/Tokyo');
  })();
  
  const momentString:string = format ? moment.format(format) : moment.format();
  return momentString;
}

今回、「Day.js」という日付操作ライブラリを使ってます。
機能開発停止&新プロジェクトへの採用を非推奨としたmoment.jsが、その代替として推奨しているモジュールの一つです。
実際、関数なども大部分がmoment.jsと同じで、扱いやすいです。

day.js.org

上記ソースをデプロイして、無事にデプロイ&正常動作を確認できればOKです。

※デプロイ実行前に「npm install」コマンドでnpmモジュールをインストールしないとエラーになりますので、そこは注意です。(プロジェクト作成時にpackage.jsonに必要なモジュールを記載してくれるが、インストールはしてくれない)

テストを書く

では、上記Lambdaのテストを書きましょう。
とりあえずは、getDate()関数のテストを作成します。(ソースは下記)

※なお、jestの型定義ファイルをインストールしておくと、テストコードの作成時に便利です。

# jestの型定義ファイルのインストール
> npm install -D @types/node
// getDate()関数のテストソース  
import {getDate} from './handler';
  
describe('hello.tsのテスト', () => {
  
    describe('getDate()のテスト', () => {
        test('ISO形式の文字列で時間指定した場合、正しくその時間が返ること', () => {
            expect(getDate('2020-11-28T09:00:00+09:00')).toBe('2020-11-28T09:00:00+09:00');
        });
  
        test('UNIX数値(ミリ秒)で時間指定した場合も、正しくその時間が返ること', () => {
            expect(getDate(1606521600000)).toBe('2020-11-28T09:00:00+09:00');
        });
    })
});

で、上記ソースを「jest」コマンドで実行して、問題なければOKです。

が、下記エラーが発生するケースがあります。(私もそうだった)

Jest encountered an unexpected token.
This usually means that you are trying to import a file which Jest cannot parse, e.g. it's not plain JavaScript.
By default, if Jest sees a Babel config, it will use that to transform your files, ignoring "node_modules".

原因ですが、上のエラーにもある通り、「プレーンなJavaScriptに変換できないコードがある」のが原因です。
(テストソース的には「import {getDate} from './handler'」が該当)

というか、Jestの公式ドキュメントにも

Jest supports TypeScript, via Babel. First, make sure you followed the instructions on using Babel above. Next, install the @babel/preset-typescript via yarn

とある通り、JestでTypeScriptをテストする場合、Babelと「@babel/preset-typescript」モジュールを入れる必要があります。

なので、これらをインストールします。

Babel&@babel/preset-typescriptのインストール

といっても、ここからはJestの公式ドキュメントの記載とほとんど同じです。

まずはBabelを下記コマンドでインストールして、

# babel-jestはjestインストール時にインストールされるので、  
# ない場合のみインストールする。
> npm i -D babel-jest @babel/core @babel/preset-env

で、babel.confog.jsなりpackage.jsonに下記設定を追記します。(下記はpackage.jsonの場合)

"babel": {
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ],
  ]
}

で、次に「@babel/preset-typescript」モジュールをインストールします。

> npm i -D @babel/preset-typescript

で、同じようにbabel.confog.jsなりpackage.jsonに設定を追記します。(下記はpackage.jsonの場合)
※なお、Jest公式ページでは「@babel/preset-typescript」を配列にしていませんが、配列にしないと正しく動かないので注意。

"babel": {
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ],
    [
      "@babel/preset-typescript" 
    ]
  ]
}

また「babel-jestはbabelの設定がある場合、自動でファイル変換を行う」とのことですが、自分でJestの「transform」に設定を定義することで、任意のtransform設定を定義出来ます。

{  
  "jest": {
    "roots": [
      "<rootDir>/"
    ],
    "testMatch": [
      "**/__tests__/**/*.+(ts|tsx|js)",
      "**/?(*.)+(spec|test).+(ts|tsx|js)"
    ],
    "transform": {
      "^.+\\.(ts|tsx)$": "babel-jest"
    },
  },
}

動作確認

では、改めて動作確認です。

上記設定をした後で「jest」コマンドを動かすと...

f:id:Makky12:20201203075406p:plain

正しくテストが実行されました。これでOKです。(skipしたテストは、その2で取り上げる予定です)

まとめ

これで、Serverless Framework + TypeScript + JestでのAWS Lambda開発環境が整いました。

TypeScriptは個人的に使いやすいと感じますし、型厳格な言語(C#とかJavaとか)が好きな人(自分も含め)にお薦めなので、これからもどんどん使っていきたいと思っています。

なお「てか、Serverless Frameworkほとんど関係ねえじゃん!」と思った人もいるかもしれませんが、その2でServerless Frameworkのプラグインを使ったテストを紹介する予定です。

告知

現在Qiitaで開催されている、「Advent Calendar2020」に「Serverless」と「AWS」で記事を公開します。
Serverlessは12/18(金)、AWSは12/25(金)に公開予定ですので、よろしければそちらもどうぞ。

なお、Serverlessは「Serverless Framework はじめの一歩」、AWSは「aws-sdk-mockを使ったAWSのテスト」について書く予定です。

qiita.com

qiita.com

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