echo("備忘録");

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

【AWS】Amazon S3 Object Lambdaを試してみた(その1)

はじめに

3/19(金)に「Amazon S3 Object Lambda」というS3の新機能がGAになりました。

aws.amazon.com

これは上記公式サイトにも

「S3 から取得したデータをアプリケーションに返す前に独自のコードを追加して処理できる新機能」

とあるように、例えばS3バケットのキーをgetObjectする際に、あらかじめ何かの処理(フィルタリングやマスキングなど)を実施した値になっているため、getObject()を呼び出すLambdaでの変換処理が不要になります。

というわけで、今回はこのS3OLについて試してみた話です。

TL;DR

  • S3OLの概要
  • TypeScriptで実装してみた
  • ハマった点など

S3OLの仕組み

S3OLの仕組みとしては、公式サイトの下の画像の通りです。
https://d2908q01vomqb2.cloudfront.net/da4b9237bacccdf19c0760cab7aec4a8359010b0/2021/03/16/s3-object-lambda-architecture-1.png

概要としては、こんな感じです。

  • S3OLアクセスポイント経由でS3バケットにアクセスすると、S3OLにて変換処理をした値を取得できる
  • S3OLアクセスポイントを経由しなければ、元の値(生値)を取得できる
  • 1つのS3バケットについて、S3OLアクセスポイント(≒変換処理)を複数設定できる

ユースケース

ユースケースとしては、下記のようなケースです。

  • 元の情報を編集して返す必要がある。
    • (例) 特定の情報をフィルタ/マスクする、画像のサイズを変える...など
  • 複数の処理(≒Lambda)で、上記のような処理を行う(=共通化)
    • モジュール化/レイヤー化より、管理は簡単かも

参考サイト

実装してみる

というわけで、さっそく実装します。(今回はTypeScriptで実装)

前提条件として、aws-sdkを最新バージョンに更新しておきます。(最新バージョンじゃないとS3OLをサポートしていないので。Ver2.874.0でサポートを確認)

まずは「参考サイト」のAWS公式ブログに載っているコードを元に、TypeScriptで下記コードを書きました。(AWS公式ブログはpythonですが、全く問題なく理解できる内容だと思います。)

※元となる情報ですが、今回も「ドルアーガの塔 宝物リスト」のjsonを使用しています。(内容はこのブログの最後に載せています)

今回は「上記リスト(tower_of_druaga.json)から、階数(Floor)が素数の階の情報のみ取得する」という処理を実装しています。

// s3_object_lambda.ts
import axios from 'axios'
  
import 'source-map-support/register';
import * as AWS from 'aws-sdk';
import S3 from 'aws-sdk/clients/s3';
  
// S3OLのレスポンスの定義
interface IS3ObjectLambdaResponse {
  statusCode: number
}
  
// ObjectContextの定義
interface IObjectContext {
  inputS3Url: string,
  outputRoute: string,
  outputToken: string
}
  
// フロアの詳細(Detail)の定義
interface IDetail {
  Condition: string,
  Effect: string,
  Memo: string,
  Name: string
};
  
// 各フロアの情報の定義
interface IFloor {
  Type: string,
  Floor: number,
  Detail: IDetail[]
}
  
// ハンドラ関数
export async function handler(event:any): Promise<IS3ObjectLambdaResponse>{
  console.log(`[event] ${JSON.stringify(event)}`);
  
  const context:IObjectContext = event.getObjectContext;
  const route:string = context.outputRoute;
  const token:string = context.outputToken;
  const url:string = context.inputS3Url
  
  const res:any = await axios.get(url);
  console.log(`[resData] ${JSON.stringify(res.data)}`);
  
  const originalContent:IFloor[] = res.data;  
  
  // 変換処理を実施する。
  const filteredContent:string = filter(originalContent);
  
  const s3:S3 = new AWS.S3();
  
  const param:S3.WriteGetObjectResponseRequest = {
    RequestRoute: route,
    RequestToken: token,
    Body: filteredContent
  };
  
  const result = await s3.writeGetObjectResponse(param).promise();
  
  const response:IS3ObjectLambdaResponse = {
    statusCode: 200
  }
  
  return response;
}
  
// 階数(Floor)が素数の階の情報のみ抜き出す処理。  
// 素数の判定は、今回は手抜きしてます。
function filter(_content:IFloor[]):string {
  // const items:IFloor[] = _content

  const filteredItem:IFloor[] = _content.filter(x => [2,3,5,7].findIndex(y => y === x.Floor) !== -1);
  console.info(`[filteredItem] ${JSON.stringify(filteredItem)}`)
  return JSON.stringify(filteredItem);
}

キーになるのは、「event.getObjectContext」で取得できる以下3つの値です。

キー名 説明 備考
outputRoute S3OLからLambdaに渡されるルーティングトーク writeGetObjectResponseで使用
outputToken S3OLからLambdaに渡される認証(マッピング)トーク 同上
inputS3Url S3から元のキー内容を取得するためのURL

これらをevent引数から取得したら、あとはソースにある通り、

  • inputS3Urlにgetリクエストを投げて、そのレスポンスから元のキー内容を取得
    • 「元のキー情報(=ファイル名)」はgetObjectの引数「key」で設定される
  • 変換処理を実施し、元のキー情報から変換後の値を取得
    • 上のソースなら「filter」関数
  • writeGetObjectResponse関数で、getObjectの戻り値を変換後の値に更新
  • このLambda自体のレスポンスを返却する。(statusCode:200)

という処理を実施する感じです。

あとは上記Lambdaを、AWSに新規Lambda関数として作成しておいてください。

  • コンソール直でも、CloudFormation(以下Cfn)経由でもどちらでもOKです。
  • Cfnの場合、定義は通常のLambda関数と全く同じでOKです。
    • もちろんServerless Frameworkも

また、上記S3OLのLambda関数を呼び出すLambda(s3_object_lambda_base.ts)も実装しました。
(詳細は省略しますが、レスポンスのBodyとして、「raw」にtower_of_druaga.jsonのそのままの値を、「filter」に先述の「階数(Floor)が素数の階のみの情報」を格納します。)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import 'source-map-support/register';
import { Context } from 'vm';
import * as AWS from 'aws-sdk';
import S3, { GetObjectRequest, GetObjectOutput } from 'aws-sdk/clients/s3';
  
interface IDetail {
  Condition: string,
  Effect: string,
  Memo: string,
  Name: string
};
  
interface IFloor {
  Type: string,
  Floor: number,
  Detail: IDetail[]
}
  
export async function handler(event:APIGatewayProxyEvent, _context:Context):Promise<APIGatewayProxyResult> {
  console.log(`[event] ${JSON.stringify(event)}`);
  
  const s3:S3 = new AWS.S3();
  
  const param_raw:GetObjectRequest = {
    Bucket: 'suzukima-s3-object-lambda-test',
    Key: 'tower_of_druaga.json'
  };
  
  const param_filtered:GetObjectRequest = {
    Bucket: '<S3OLアクセスポイントのARN>',
    Key: 'tower_of_druaga.json'
  };
   
  const result:IFloor[] = await Promise.all([s3getObject(s3, param_raw), s3getObject(s3, param_filtered)]);
  const response:APIGatewayProxyResult = {
    statusCode: 200,
    body: JSON.stringify({
      raw: result[0],
      filtered: result[1]
    })
  }
  
  return response;
}
  
async function s3getObject(_s3:S3, _param:GetObjectRequest):Promise<IFloor> {
  const res:GetObjectOutput = await _s3.getObject(_param).promise();
  console.info(`[res] ${JSON.stringify(res)}`);
  return JSON.parse(res.Body.toString());
}

設定する(コンソール上で)

ということで、まずはS3OLをコンソール上で作成します。
といってもS3OLはS3アクセスポイントを必要とするので、まずはS3アクセスポイントを作成します。

コンソールから[S3]→[アクセスポイント]→[アクセスポイントの作成]と選択します。

f:id:Makky12:20210404084232p:plain

キー名 説明 備考
アクセスポイント名 一意な名前を指定
バケット 対象のS3バケットを指定 「S3の参照」から選択可能
ネットワークオリジン VPCかインターネットか。VPCならVPCIDも指定する。 今回は「インターネット」を指定

次に、S3OLのアクセスポイントを作成します。 コンソールから[S3]→[オブジェクトLambdaアクセスポイント]→[オブジェクトLambdaアクセスポイントの作成]と選択します。

f:id:Makky12:20210404091638p:plain

今回は下記項目のみ設定しました。(それ以外はデフォルトのまま)

キー名 説明 備考
オブジェクトLambdaアクセスポイント名 一意な名前を指定
サポートするアクセスポイント 対象のS3アクセスポイントのARNを指定 さっき作ったS3アクセスポイントのARN
Lambda関数の呼び出し 呼び出すLambda関数を名前で指定するか、ARNで指定するか 今回は「アカウントの関数から選択」を指定
Lambda関数(のARN) 「Lambda関数の呼び出し」の値に従い、関数名か関数のARNを指定

ここまで出来たら、動作を確認してみましょう。(今回はコンソール上で動作確認を行います)

動作確認は、コンソールから[S3]→[オブジェクトLambdaアクセスポイント]から上で作成したS3OLアクセスポイントを選択し、「オブジェクト」タブでキーをチェックして、「アクション」から「開く」を実施することで実施できます。

f:id:Makky12:20210404092904p:plain

ハマった点など

  

「writeGetObjectResponse関数がない)」といわれる

これは参考サイトの クラスメソッドさんの記事にある通り、「AWS側のaws-sdkランタイムのバージョンが最新ではない」ことが原因です。

対策ですが、上記の記事通りに最新版のaws-sdkをLambdaレイヤーとして登録すればOKです。(Laambdレイヤーの説明は、今回は省略します)

結果が「Forbidden」になる

自分はこれにかなりハマりました。

結果としては、S3バケット&S3OLアクセスポイントへのポリシー設定で、下記のActionを許可すればOKです。

  • S3バケット:s3:GetObject
  • S3OLアクセスポイント:s3-object-lambda:WriteGetObjectResponse

ただ、僕が勘違いしてたのは、

  • WriteGetObjectResponseを(S3OLアクセスポイントではなく)S3バケットに対して設定していた
  • WriteGetObjectResponseを「s3:WriteGetObjectResponse」と勘違いしていた

点で、ここの調査にだいぶ時間を費やしてしまいました。(分かってしまえばなんてことはないんですけどね...)

結果

というわけで、最終的にs3_object_lambda_base.ts(AWS上ではjsだけど)を呼び出した結果が下の通り。
「raw」が「tower_of_druaga.json」そのままの値なのに対し、「filter」の方はちゃんとFloorが素数の情報のみになっています。

なので、S3OLが正しく動いている、と考えてよさそうです。

HTTP/1.1 200 OK  
  
(中略)  
  
{
  "raw": [
    {
      "Type": "treasure",
      "Floor": 1,
      "Detail": [
        {
          "Condition": "グリーンスライムを3匹倒す",
          "Effect": "壁を宝箱を取る前後1回ずつ壊せる",
          "Memo": "",
          "Name": "カッパーマトック"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 2,
      "Detail": [
        {
          "Condition": "ブラックスライムを2匹倒す",
          "Effect": "足が速くなる",
          "Memo": "",
          "Name": "ジェットブーツ"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 3,
      "Detail": [
        {
          "Condition": "ブルーナイトのどちらかを倒す",
          "Effect": "ミスしても残機が減らない(1回だけ)",
          "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
          "Name": "ポーション・オブ・ヒーリング"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 4,
      "Detail": [
        {
          "Condition": "扉を通過する",
          "Effect": "フロア開始時、鍵がある方向を向くと音が鳴る",
          "Memo": "宝箱を出すより先に鍵を取ってしまうと出ない",
          "Name": "チャイム"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 5,
      "Detail": [
        {
          "Condition": "メイジの呪文を歩きながら盾で3回受ける",
          "Effect": "攻撃力UP",
          "Memo": "18階のドラゴンスレイヤーを取るのに必要",
          "Name": "ホワイトソード"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 6,
      "Detail": [
        {
          "Condition": "最上段に上がった後、下がる",
          "Effect": "10階までゴーストが見えるようになる",
          "Memo": "",
          "Name": "キャンドル"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 7,
      "Detail": [
        {
          "Condition": "カッパーマトックをなくす",
          "Effect": "宝箱を取る前に1回、取った後2回使える",
          "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
          "Name": "シルバーマトック"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 8,
      "Detail": [
        {
          "Condition": "ステート時点からX軸,Y軸共にずれた位置で剣を振る",
          "Effect": "体力が上がる(その階のみ)",
          "Memo": "",
          "Name": "ポーション・オブ・パワー"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 9,
      "Detail": [
        {
          "Condition": " 最上段の右から8列目と左から8列目の両地点を通過する。",
          "Effect": "体力が下がる(その階のみ)",
          "Memo": "",
          "Name": "ポーション・オブ・エナジー・ドレイン"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 10,
      "Detail": [
        {
          "Condition": "レッドスライムの放つ呪文を盾で受ける。",
          "Effect": "26階の宝箱(ハイパーガントレット)を取るのに必要。",
          "Memo": "レッドスライムの気分次第。",
          "Name": "ガントレット"
        }
      ]
    }
  ],
  "filtered": [
    {
      "Type": "treasure",
      "Floor": 2,
      "Detail": [
        {
          "Condition": "ブラックスライムを2匹倒す",
          "Effect": "足が速くなる",
          "Memo": "",
          "Name": "ジェットブーツ"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 3,
      "Detail": [
        {
          "Condition": "ブルーナイトのどちらかを倒す",
          "Effect": "ミスしても残機が減らない(1回だけ)",
          "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
          "Name": "ポーション・オブ・ヒーリング"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 5,
      "Detail": [
        {
          "Condition": "メイジの呪文を歩きながら盾で3回受ける",
          "Effect": "攻撃力UP",
          "Memo": "18階のドラゴンスレイヤーを取るのに必要",
          "Name": "ホワイトソード"
        }
      ]
    },
    {
      "Type": "treasure",
      "Floor": 7,
      "Detail": [
        {
          "Condition": "カッパーマトックをなくす",
          "Effect": "宝箱を取る前に1回、取った後2回使える",
          "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
          "Name": "シルバーマトック"
        }
      ]
    }
  ]
}

まとめ

というわけで、S3OLの紹介&実際に動かしてみました。

今までモジュールなどで行っていましたが、これを使うことで、確かに便利なケースは出てきそうですね。 (例えば公式ブログにあった画像サイズ変換とか、あどクラスメソッドさんの記事にもあった文字コード変換とか)

なお、今回はS3OLの設定をコンソール上で行いましたが、その2ではこれはCloudFormation(実際はServerless Frameworkだけど)から定義・作成する方法を書こうと思っています。(serverless.tsの内容は、その際に公開します。)   

告知

先日行われた「JAWS DAYS 2021 re:Connect」における、私のセッションのアーカイブ動画が公開されたしたので、よろしければご参照ください。 www.youtube.com

また、他の方の動画も公開されているので、見逃したという人も、この機会にぜひご覧ください。

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

参考資料:ドルアーガの塔 宝物リスト

過去のServerless Meetup Japan VirtualやQiita Advent Calendar 2020などで使用している「ドルアーガの塔 宝物リスト」の内容になります。
(「ドルアーガの塔」がわからない人は、ググってみると幸せになれるかもしれません。)

内容としては、下記情報を格納したオブジェクトの配列になります。(実際は60階分あるけど、今回は10階まで用意)

キー名 キー名(Detail内) 説明 備考
Type 内容の種類 「treasure」固定。パーティションキー
Floor 階数(1~60) ソートキー
Detail 下記4項目が格納されたオブジェクトの配列 配列なのは、45階だけ宝物が2つあるから
Condition 宝物の出現条件
Effect 宝物の効果。
Memo 一言メモ
Name 宝物のアイテム名 一部微妙に違ってるかも
[
  {
    "Type": "treasure",
    "Floor": 1,
    "Detail": [
      {
        "Condition": "グリーンスライムを3匹倒す",
        "Effect": "壁を宝箱を取る前後1回ずつ壊せる",
        "Memo": "",
        "Name": "カッパーマトック"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 2,
    "Detail": [
      {
        "Condition": "ブラックスライムを2匹倒す",
        "Effect": "足が速くなる",
        "Memo": "",
        "Name": "ジェットブーツ"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 3,
    "Detail": [
      {
        "Condition": "ブルーナイトのどちらかを倒す",
        "Effect": "ミスしても残機が減らない(1回だけ)",
        "Memo": "正確な条件は、「ブルーナイトのうち、先にフロアに出現した方を倒す」",
        "Name": "ポーション・オブ・ヒーリング"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 4,
    "Detail": [
      {
        "Condition": "扉を通過する",
        "Effect": "フロア開始時、鍵がある方向を向くと音が鳴る",
        "Memo": "宝箱を出すより先に鍵を取ってしまうと出ない",
        "Name": "チャイム"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 5,
    "Detail": [
      {
        "Condition": "メイジの呪文を歩きながら盾で3回受ける",
        "Effect": "攻撃力UP",
        "Memo": "18階のドラゴンスレイヤーを取るのに必要",
        "Name": "ホワイトソード"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 6,
    "Detail": [
      {
        "Condition": "最上段に上がった後、下がる",
        "Effect": "10階までゴーストが見えるようになる",
        "Memo": "",
        "Name": "キャンドル"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 7,
    "Detail": [
      {
        "Condition": "カッパーマトックをなくす",
        "Effect": "宝箱を取る前に1回、取った後2回使える",
        "Memo": "最大で宝箱を取る前に4回、取った後に5回まで使えるが、Effectの回数より多く使うと1/3の確率で壊れる",
        "Name": "シルバーマトック"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 8,
    "Detail": [
      {
        "Condition": "ステート時点からX軸,Y軸共にずれた位置で剣を振る",
        "Effect": "体力が上がる(その階のみ)",
        "Memo": "",
        "Name": "ポーション・オブ・パワー"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 9,
    "Detail": [
      {
        "Condition": " 最上段の右から8列目と左から8列目の両地点を通過する。",
        "Effect": "体力が下がる(その階のみ)",
        "Memo": "",
        "Name": "ポーション・オブ・エナジー・ドレイン"
      }
    ]
  },
  {
    "Type": "treasure",
    "Floor": 10,
    "Detail": [
      {
        "Condition": "レッドスライムの放つ呪文を盾で受ける。",
        "Effect": "26階の宝箱(ハイパーガントレット)を取るのに必要。",
        "Memo": "レッドスライムの気分次第。",
        "Name": "ガントレット"
      }
    ]
  }
]

【JAWS-UG】JAWS DAYS 2021 -re:Connect- で登壇しました。

はじめに

2021/03/20(土) にオンラインで「JAWS DAYS 2021 -re:Connect-」という、JAWS-UG(Japan AWS Users Group)のイベントが開催されました。

jawsdays2021.jaws-ug.jp

少し前から告知していましたが、このイベントで「AWS Lambdaのテストで役立つ各種ツール」という内容で登壇しましたので、今回はそのお話です。

登壇内容の概要

概要としては、2021/1/6(水) の「Serverless Meetup Japan Virtual #14(以下serverlessjp)」の登壇内容である「Serverless Framework Pluginで行うLambdaテスト」をベースに、その際お話しできなかった下記の内容を追加した形です。

  • aws-sdk-mockの紹介
    • serverlessjpでは、時間の関係まるまるカットした部分です。
  • メリット・デメリット(注意点)
    • serverlessjsの時より、一歩深いとこに踏み込んでます。

また、紹介したツールは以下の通りです。

  • aws-sdk-mock
  • Serverless Framework
  • Serverless Offline
  • Serverless DynamoDB Local
  • Serverless S3 Local

セッション資料&GitHubソース

セッション資料

www.slideshare.net

GitHubソース

github.com

目的(伝えたかった事)

テストをより便利かつ効率的に

自動テストについて、いろいろなやり方がありますので、その一例を紹介したかったのがあります。

例えば単体テストはともかく、結合テストってなかなかローカルで実行するのが難しい部分があります。(例えばDockerで環境を作成したり...でしょうか)

また、「結合テストは開発(またはテスト用)のAWSにデプロイしてE2Eテストで...」ということも少なくないと思います。

そのような時に「ツールを使うことで、ローカルでもある程度結合テストができる」というのを知っていると、例えばコスト面、および工数面などでも違ってきますし、ローカルテストも導入しやすくなる、と思いました。

ただし、「E2Eテスト自体を省略できる」ということではありません。
というより、いくらローカルで結合テストを行ったとしても、最終的なE2Eテストは必要だと思います。

自動テストの導入

僕自身が昨年から自動テストを導入して、自動テストの恩恵を受けまくっているので「絶対に自動テストは導入してほしい!」というのを伝えたかったです。

なかなか自動テストは導入が難しいし「そんな時間あるならプロダクトコードを...」と思ってしまいがちですが、本当に受けられる恩恵が大きいので、そういった意味で、今回資料に単体テストを追加しました。

また、その際にどうしても「AWSリソースの処理のMock化」がネックになってきますが、それを行うのにとても便利だった経験から、aws-sdk-mockを紹介しました。(登壇資料にも記載しましたが、E2Eテストでの再現が難しい「AWSリソースの処理でエラーになった場合」のテストを行えるのが大きなメリットです)

感想・反省点など

運営の方々の対応が素晴らしかった

正直、大きなイベントということもあり、不安もありましたが、運営の方々の対応が本当に素晴らしかったので、そこまで大きなトラブルはありませんでした。

この場を借りて、お礼を申し上げたいと思います。

あまり参加できなかった

当日が結構プライベートで予定が詰まっており、あまり他の方のセッションを聴くことができなかったのが残念でした。(なのでアーカイブ動画の公開が楽しみです)
またトークにも参加できなかったので「もっといろんな方とお話ししたり、繋がりを持ちたかったなあ...」という反省が残ってしまったのも残念でした。(「縁」というのはいろんな意味で、非常に大事ですからね)

次回はちゃんと都合をつけて、フルで参加できるようにしたいです。(ただ地震の影響で、懇親会自体は急遽中止になってしまいましたね。でも正しい対応だと思います。)

終わり良ければすべて良し(遠い目)

僕のセッションの終わり方が中途半端な終わり方で「途中で切れた?」みたいに思われた人もいたようです。(実際、途中で切れたんだけど)

微妙な時間の残り方をした関係で、「終わりにします...か?デモをやります...か?」みたいな感じにしてしまったのが良くなかったと思います。

なので、次回があるならば、その点はしっかり修正したいと思います。

まだまだ知らないことが山ほど

後で他の方のセッション概要や資料を見たのですが、とても有用な内容がいっぱいあり、「またまだ勉強することが山ほどあるなあ」と思いました。

個人的な感想として、他の人のセッションは設計や運用に関わるような部分が多く、自分がまさに知りたい部分なので、次回があればそういう部分で何か登壇したいと思いましたし、それができるようにもっと学ばなければ、と思いました。

最後に

JAWS DAYSは「聴く側」として参加経験があるのですが、「登壇側」としての参加は初めてだったので緊張しましたが、終わってみれば本当にいい経験だったと思います。

来年はオンライン/オフラインどちらになるかはわかりませんが(個人としてはオフラインがいいなあと思っていますが)、また可能であれば登壇者として参加できればなあ、と思っていますし、それができるように自分も今以上に努力しなければ...と強く思いました。

また、今回学んだ知識を業務でも活かしたいですし、活かせる環境で働きたい。
そして、それができるようにならなければならない、とも強く思いました。

最後になりますが、運営の方々、セッションを聴いてくださった方々、ツイートなどで盛り上げてくれた&感想などをしてくれた方々、本当にありがとうございました。

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

【AWS】【Serverless Framework】CloudFormationでのリソースまとめ方(Part1)

はじめに

みなさん、CroudFormation(以下「Cfnと記載」)使ってますか?

アプリ環境をクラウドで構築する際、構成やリソースをテンプレートファイルで定義することも増えました。(いわゆる「IaC(Infrastructure as Code)」)

しかし初めのうちはともかく、規模が大きくなってくると、下記のような問題が発生します。

  • リソース数が膨大な数になる。
  • リソース数の増加により、管理が大変になる。

そこで、今回はCfnの構成・リソースを管理する際の考え方(=管理の仕方)について、自分なりの考えを書きました。

前提

個人的には、Cfnでの構成・リソース管理としては、こんな感じに分かれるのかな、と思ってます。

  • 同一プロジェクトで管理
    • 1ファイルにすべて記載
    • スタックのネスト(親子関係をつける)
  • 個別プロジェクトで管理
    • リソース種別で管理
    • サービス単位で管理

なので上記について、特徴やメリット・デメリットを説明したいと思います。

ただ、結構長くなりそうなので、パートを2パートに分けました。
今回はパート1として「同一プロジェクトで管理」について書きたいと思います。

なおAWS公式ドキュメントに「AWS CloudFormation ベストプラクティス」というのがあるので、こちらを一読するのをお勧めします。
docs.aws.amazon.com

同一プロジェクトで管理

名前の通り、1つのプロジェクトでCfnの構成・リソースを管理するパターンです。
「スタックのネスト」のように、スタック(≒テンプレートファイル)を分けるケースもありますが、1回のデプロイコマンドで全リソースをデプロイできます。

1ファイルにすべて記載

特に難しいことは意識せず、1テンプレートファイルにすべての構成・リソースを記載する方法です。
管理方法としては、いたってシンプルです。

メリット:管理がしやすい(リソース数が少ない場合)

他のパターンと違い、テンプレートファイルは1つだけなので(環境ごとの差分ファイルは除く)、管理がしやすい(=手っ取り早い)と思います。
特に構成・リソースが少ないうちは、なおさら管理コストは少ないと思います。

なので、とりあえずアプリを作れれば良い(規模が大きくなる・運用管理などは想定しない)場合は、この方法を採用するのが手っ取り早いと思います。

デメリット1:管理が大変(リソース数が多い場合)

ただ、先程「特に構成・リソースが少ないうちは、なおさら管理コストは少ない」と書きましたが、裏を返せば「構成・リソースが多くなると、管理コストが高くなる」ということです。

特に、1ファイルに大量の定義がなされている場合、どこに何があるかを探すだけでも大変になるかと思います。(依存関係があるとなおさら)

また、「デプロイ時に全リソースをデプロイしなければならない(≒デプロイに時間がかかる)」デメリットもあります。

例えば「S3バケットを1つ追加したいだけ」というケースでも、それ以外の全リソースをデプロイしなければならず、時間がかかります。(無関係な他の全リソースもデプロイしなければならない)

デメリット2:リソース数の上限に引っかかりやすい

Cfnでは「1スタックに格納できるリソースは500個まで」という制限があります。
なので1ファイルにあまりにたくさん構成・リソースを定義しすぎると、この上限に引っかかってデプロイができない...という事態が発生しやすくなります。

※ちなみに、500個という上限も昨年10月に引き上げられたもので、それまでは200個が上限でした。
なので、ちょっと規模が大きいアプリの構成を1ファイルに定義していると、すぐ上限に引っかかる...というケースが結構あったと思います。(僕もありました)

aws.amazon.com

スタックのネスト(親子関係をつける)

上記の「1ファイルにすべて記載」で述べたような定義ファイルの肥大化、およびそれに伴う問題を解消する方法として「Cfnスタックを分ける」というのがあります。

この「スタックのネスト(親子関係をつける) 」は「Cfnスタックを分ける」方法のうち、同一プロジェクトで全スタックを管理する方法になります。

docs.aws.amazon.com

メリット1:リソースの上限を気にしなくてよい

先程の「リソースの上限」は、個々のスタック単位の制限なので、個々のスタックで管理しているリソースが500を超えなければ、Cfnのリソースの上限には引っかかりません。(たとえプロジェクト全体で管理しているリソース数が500を超えたとしても)

なので、ネストしている個々のスタックの管理さえ気を付ければ、リソース数を気にする必要はなくなります。

メリット2:リソースの管理について、柔軟性がある

スタックのネストでは、スタックをいくつかに分けます。 そしてその分け方は自由です。
なので(パート2の「個別プロジェクトで管理」にも言えますが)、リソースの管理単位を柔軟に決めることができます。

ですので、プロジェクトで管理しやすい単位でスタックを分割することにより、リソースの管理がしやすくなります。

例えば「個別プロジェクトで管理」に書いたような「リソース単位」「サービス単位」でのスタック分割などを行うことで、リソース管理がしやすくなります。

デメリット1:慣れるまで操作がややこしい

スタックのネストは、「1ファイルにすべて記載」みたいに1デプロイで終了するわけではないですし、

  • 事前に下位テンプレートのアップロードが必要(S3に格納する必要がある)
  • 親子スタック間での値の受け渡しにクセがある
    • クロススタック参照みたいに、定義一つで終わり...というわけではない

というのがあり、慣れるまでちょっとややこしいかもしれません。(実際、スタックのネストはちょっと癖があるなあ...というのが個人的な感想です)

とはいえ、ここは慣れの部分もあるかもしれませんし、慣れればそこまで問題にはならないかもです。

デメリット2:全リソースをデプロイしなければならない&そのせいでデプロイに時間がかかる

これは先程の「1ファイルにすべて記載」と同じですが、スタックを分割しても、1デプロイで全リソースをデプロイする事には変わりません。

そのため「全リソースをデプロイしなければならない&そのせいでデプロイに時間がかかる」デメリットは変わりません。(事前に下位テンプレートのアップロードをしても、デプロイまでは実施しません)

Serverless Frameworkでのスタックのネスト

ちなみにServerless Frameworkを使用する場合、「serverless-plugin-split-stacks」というプラグインを使用するだけで、スタックのネストを自動で行ってくれます。

www.serverless.com

基本は「serverless-plugin-split-stacks」プラグインに振り分け方式を設定するだけで自動で振り分けを行ってくれますが、「stacks-map.js」ファイルを作成することで、振り分け方式をカスタマイズすることもできます。

僕のブログでも、過去にこの「serverless-plugin-split-stacks」について触れていますので、よろしければ参照ください。

makky12.hatenablog.com

ただし、公式ページにも下記の記載がされている通り、いわゆる「銀の弾丸」的なプラグインではありませんので、まずはアプリのスタックの管理(=リソース数削減)の見直しを検討してください。

This plugin is not a substitute for fine-grained services - try to limit the size of your service.

(訳:このプラグインは(スタックのネストについて)万能なサービスの代わりではありません。あなたのサービス(=アプリ)のスタックサイズを制限することを試してください)

まとめ

以上、同一プロジェクトで管理する方法について書きました。

「個別プロジェクトで管理」については、少し長くなるかもしれないので、パート2として近いうちにアップしたいと思います。

告知

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

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

それでは、パート1はこの辺で。

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