Technote

by sizuhiko

aws-sdk-client-mock はどのように aws-sdk をモックしているのか?

前回の記事AWS Lambda の Node.js 14 を 18 に移行する(aws-sdk v3 移行編)

aws-sdk-client-mock 便利ですね。 しかし、どうやって SFNClient をモックしているんだろうか?と思いませんか? sfn.start の実装側では、普通に aws-sdk から import した SFNClient を使って実装していて、 mockClient の結果で得られたモックを使っているわけでもありません。 そのあたりは、次回の記事で明らかにしていきたいと思っていますので、ご期待ください。

書いたとおり、どうやってモックしているのか、紹介していきます。

前回のおさらい

aws-sdk v3 でのテストコードは以下のようになることを紹介しました。

import 'aws-sdk-client-mock-jest';
import { mockClient } from 'aws-sdk-client-mock';
import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';

const SFNClientMock = mockClient(SFNClient);
beforeEach(() => SFNClientMock.reset());

SFNClientMock.on(StartExecutionCommand).resolves({ executionArn });
const resuls = await sfn.start(sfnArn, event);

expect(results.executionArn).toBe(executionArn);
expect(SFNClientMock).toHaveReceivedNthCommanddWith(1, StartExecutionCommand, {
  stateMachineArn: arn, input: JSON.stringify(event)
});

モックするときは mockClient(SFNClient) とクラスを渡しているだけでしたね。 生成した SFNClientMock はテストクラスでしか利用していません。

aws-sdk-client-mock の実装を見てみる

mockClientのコードを見てみましょう。

export const mockClient = <TInput extends object, TOutput extends MetadataBearer>(
    client: InstanceOrClassType<Client<TInput, TOutput, any>>,
): AwsClientStub<Client<TInput, TOutput, any>> => {
    const instance = isClientInstance(client) ? client : client.prototype;

ふむふむ mockClient の引数がインスタンスかどうか調べて、クラスだったら prototype が利用されるわけですか。 さらに見てみると

const sendStub = stub(instance, 'send') as SinonStub<[Command<TInput, any, TOutput, any, any>], Promise<TOutput>>;

なるほど send メソッドをスタブしてますね。 あー、つまりクラスを指定すると、 prototype 定義が置き換わっちゃうわけですね。

使い分けが必要

prototype が置き換わることで mockClient に渡したクラスの send はすべてのシーンでスタブに置き換わります。 テストしたい実装側でクライアントのインスタンスをDIなどで注入していない場合は、この方法で良いですね。 逆にシーンによっては、実際に send を実行したい場合は困ってしまいます。

localstack などを使って部分的にインテグレーションテストをしたい場合は、なるべくインスタンスをDIできる仕組みにした方が良いでしょう。 インスタンスを生成するメソッドを作って、それをモックして mockClient の戻り値を返すようにしても良いですね。

とくにイングレーションテストとかなく、すべてのクライアント呼び出しをモックするのであれば、クラス指定が楽ですね。

このように使い分けることで、テストコードを書いていきたいですね。 簡単ではありますが、aws-sdk-client-mock はどのように aws-sdk をモックしているのか? を紹介しました。

AWS Lambda の Node.js 14 を 18 に移行する(aws-sdk v3 移行編)

先日も紹介したクラメソさんの [アップデート] AWS Lambdaが Node.js 18をサポートしました 記事でもふれられていましたが、Node.js が 18 になるのに伴い、イメージ内の aws-sdk が v3 になりました。 Node.js 16 までは v2 系だったのですが、ここでは多くの変更が発生しています。

  • npm install する対象が aws-sdk から @aws-sdk/client-xxx のように AWS のサービスごとのクライアントライブラリになった
  • 書き方が、各クライアントを new して send でコマンドを送信する記述に統一された

大きくはこの2点でしょうか。 私たちは aws-sdk の利用に関してリポジトリパターンの中に閉じ込めているので、基本的にそこだけ対応すれば良いのですが、API で同期実行だったものが非同期実行に変更となっているので、そこだけはインターフェースを Promise に変更する必要があります。 これは実際のコマンド内部は同期で問題ないけど、 Client.send(command) が基本的に Promise を返却するようになったための副作用だと思ってください。 ( aws-sdk v2 のときは command().promise() のようにすると、非同期コマンドは Promise を返却するようになっていたのですが、この promise() 部分がなくなり、基本的な戻りは Promise になっています)

具体的なコードで見る v2 と v3 の違い

StepFuntions を実行するコードで違いをみてみましょう。

v2 のコード

import { StepFunctions } from 'aws-sdk';

const stepFunctions = new StepFunctions();
const results = await stepFunctions.startExecution({
  stateMachineArn: arn,
  input: JSON.stringify(event),
}).promise();

v3 のコード

import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';
import { build, parse } from '@aws-sdk/util-arn-parse';

const { region } = parse(arn);
const stepFunctions = new SFNClient({ region });
const results = await stepFunctions.send(new StartExecutionCommand({
  stateMachineArn: arn,
  input: JSON.stringify(event),  
}));

StepFunctions では、利用するライブラリ名が aws-sdk/client-sfn になりました。 ほとんどの場合は、サービス名を使っています(たとえば S3 なら aws-sdk/client-s3 だし、 S3Client です)が、StepFunctions の場合は sfn に省略されているので注意が必要です。(僕はこの略名に気付くまでメチャクチャ探したことを告白しておきます)

クライアントライブラリを探すときは AWS SDK for JavaScript v3 の公式ドキュメントを参照すると良いのですが、ぱっとみてサービス名からクライアントライブラリが見つけられない場合は、それっぽい略名から探してみると良いでしょう。

v2 と v3 を比較するとわかりますが、大きな変更でないことはわかると思います。

v3 移行するときに役立つドキュメント

次に v3 移行するときに役立つドキュメントを紹介していきます。

1つ目は公式のSDKドキュメントですね。クライアントライブラリを探すときも、コマンド名を確認するときにも良く参照します。

2つ目はエラーハンドリングのやり方の違いについてです。詳しくはドキュメントを読めばわかるのですが、従来はエラーの判定方法が (<AWSError>e).code === 'ExecutionDoesNotExist' みたいにしていたのを e instanceof ExecutionDoesNotExist のように例外クラスのインスタンスとして判定できるようになっています。これも v3 に移行するときに注意したいポイントです。

3つ目はSDKのv3へのアップグレードガイドで、主に破壊的変更があった部分について解説されています。単純に XxxxClient と XxxxCommand に変更したときに型エラーが出たときは、このドキュメントを読んで破壊的変更(変数名の変更や、JSON構造の変更など)がないか確認すると解決への近道になります。

ただし、先ほどの例で書いた StepFunctions のコンストラクタのように

const stepFunctions = new SFNClient({ region });

リージョンが v3 から必須パラメータになったのですが、こういった変更は動かしてみるまでわからないことが多いので、ユニットテストを書いておくことは重要です。

ユニットテスト

私たちは以下のサービスについては、モックせずにローカルで代替ライブラリを使って結合テストを行なっています。

v2 でのユニットテスト

上記以外について、v2 のときは以下のようにモックしていました。ここでは Jest での書き方を紹介します。 sfn の start というメソッドで StepFunctions を呼び出していると想定してください。

const startExecutionMock = jest.fn(); // startExecution の引数チェック用
const startExecution = jest.fn(); // startExecution の戻り値モック用
jest.mock('aws-sdk', () => {
  return {
    StepFunctions: jest.fn().mockImplementation(() => {
      return {
        startExecution: startExecutionMock.mockImplementation(() => {
          return { promise: startExecution };
        }),
      };
    });
  };
});

beforeEach(() => {
  startExecutionMock.mockReset();
  startExecution.mockReset();
});

startExecution.mockResolvedValue({ executionArn });
const resuls = await sfn.start(sfnArn, event);

expect(results.executionArn).toBe(executionArn);
expect(startExecutionMock.mock.calls).toEqual([
  [{ stateMachineArn: arn, input: JSON.stringify(event) }]
]);

v2 でのモックは面倒でしたね。

v3 でのユニットテスト

v3 で aws-sdk をモックするときは AWS SDK v3 Client mock を利用します。

Library recommended by the AWS SDK for JavaScript team - see the introductory post on the AWS blog.

ということで公式にもオススメされるライブラリということで良いですね。

上記の v3 でのテストコードは以下のようになります。

import 'aws-sdk-client-mock-jest';
import { mockClient } from 'aws-sdk-client-mock';
import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';

const SFNClientMock = mockClient(SFNClient);
beforeEach(() => SFNClientMock.reset());

SFNClientMock.on(StartExecutionCommand).resolves({ executionArn });
const resuls = await sfn.start(sfnArn, event);

expect(results.executionArn).toBe(executionArn);
expect(SFNClientMock).toHaveReceivedNthCommanddWith(1, StartExecutionCommand, {
  stateMachineArn: arn, input: JSON.stringify(event)
});

モックライブラリを使うと、v2 のときより可読性も良くわかりやすくなっています。

さいごに

ここまで aws-sdk を v2 から v3 にする方法をまとめてみました。 aws-sdk-client-mock 便利ですね。 しかし、どうやって SFNClient をモックしているんだろうか?と思いませんか? sfn.start の実装側では、普通に aws-sdk から import した SFNClient を使って実装していて、 mockClient の結果で得られたモックを使っているわけでもありません。 そのあたりは、次回の記事で明らかにしていきたいと思っていますので、ご期待ください。

AWS Lambda の Node.js 14 を 18 に移行する(CI/CD環境移行編)

クラメソさんの [アップデート] AWS Lambdaが Node.js 18をサポートしました 記事のとおり、やっと AWS Lambda でも Node.js 18 が使えるようになりました。

現在 Node.js 14 を使って AWS Lambda で API サーバーを構築しているのですが、 Node.js 16 の LTS が 9ヶ月終了が早まったこともあり、 このタイミングで Node.js 18 へ移行することにしました。

本稿では CI/CD 環境を Node.js 14 から Node.js 18 へ移行するときに実施した作業を振り返って、まとめておきます。

現在の構成はこんな感じです。

  • Node.js 14 / TypeScript
  • CI/CD に GitHub Actions を利用
  • Amazonlinux ベースの amazon/aws-cli コンテナをベースにパッケージを追加した独自コンテナイメージで CI/CD を実施
  • Serverless Framework でビルド/デプロイ

CI/CD イメージの Node.js を 18 にする

まずはビルド/デプロイをするコンテナイメージを Node.js 18 にしていきます。 実際に Node.js 18 をインストールしようとしたのですが、エラーになってしまいます。

具体的なエラーは /lib64/libc.so.6: version `GLIBC_2.14’ not found. Why am I getting this error? と一緒で、調べていくと Amazonlinux2 ベースである CentOS7 と Node.js 18 は nodeのv18を使ったらエラーになった(CentOS7) などにもあるように単純な話ではないようです。

おや?でも Lambda は Node.js 18 が使えるんですよね?どういうこと?と思いますよね。

Node.js のディストリビューション issue を確認すると distribution package Amazon Linux 2022 not supported なんてのがありました。

で、Amazon Linux 2022 って何なん?!

amazonlinux のコンテナイメージをみると、 latestamazonlinux2 なんですが、タグをみていくと確かに 2022 があります。 あと現時点では aws-cli のイメージは 2022 には追従していません。

そこで、 amazonlinux2022 をベースにして Node.js 18 をインストールするのを試してみます。

しかし、ここでプロビジョニングツールが amazonlinux 2022 に対応していないことがわかります(グルグル循環して脳が溶けてくる… 溶けてやがる、まだ早すぎたんだ….)。違うプロビジョニングツールも調べてみたのですが、どれも 2022 には対応していませんでした。ここでプロビジョニングツールを捨てる選択となりました。

(実際僕らは Chef を使っているんですが、Chef Workstation 自体に amazonlinux 2022 の対応は入っていました(Omnitruck artifact does not exist for version 17 on platform Amazon Linux 2022 )。ただまだリリースパッケージに含まれていないので、いずれリリースされるバージョンでは対応されているでしょう。Ansibleも調べましたが、同様にまだリリースパッケージには含まれていませんでした。)

調べていたら、AmazonLinux3じゃなくってAmazon Linux 2022 (AL2022) だってさ。 という記事が見つかりました。 そんで

AmazonLinux2022以降はメジャーバージョンが2年ごとにリリースされる

まじかー。まぁ今までの AmazonLinux2 が長かったですね。そのぐらいで OS イメージを最新にしていかないとですよね。 ということで、今後も踏まえて CI/CD のフローを含め利用するコンテナイメージを検討しなおすことにしました。 (2年ごとの変更がプロビジョニングツールを捨てる決定的な要因になったのは間違いない)

CI/CD フローの変更

現時点のフローを整理してみました

  1. チェックアウト
  2. ビルド
  3. デプロイが必要な場合
    1. 環境のスイッチロール
    2. デプロイ

だいたいこんな感じです。 これを今までは全部1つのコンテナでやっていたのですが、それぞれ分割していくことにしました。

  1. チェックアウト -> alpine/git
  2. ビルド -> amazon/aws-lambda-nodejs:18 ベースに zip/unzip を追加したもの
  3. デプロイが必要な場合
    1. 環境のスイッチロール -> amazon/aws-cli ベースに jq を追加したもの
    2. デプロイ -> node:18

ビルドに関しては node:18 でも良かったのですが、ここは以前と同じく稼働する OS イメージと合わせています。 今後 OS イメージを追従するというより、実際の Lambda 実行コンテナイメージを使うことで、 Node.js ランタイムにあった OS バージョンを気にせず利用できるメリットがあると考えました(サイズ大きいけど)。

それ以外は必要な部分に最低限のコンテナという感じですね。

さいごに

LTS に合わせて開発環境をアップデートしていくのは、とても大事ですね。 記事にすると、さらっと解決したように見えますが、それぞれのツールのソースコードや issue を確認しながら進んでいたので、かなり時間を取られてしまいました。 ただ、今後同じようにな環境でアップデートしていこうと思う人の助けになればと思います。

Node.js で Lambda ハンドラのテストを書くときに AWS イベントを生成する

AWS Lambda ハンドラをテストしたいとき、多くはリクエストのバリデーションの結果で 400 エラーを返すのか、ロジックが正常終了したとき 200 を戻すのか、みたいなことを書きたいことがあると思います。

TypeScript で Lambda ハンドラを記述する場合は、以下のようになると思います。

import { APIGatewayProxyHandler } from 'aws-lambda';

export const hello: APIGatewayProxyHandler = async (event) => {
  // バリデーションやロジックの呼び出し
}

このとき eventAPIGatewayProxyEvent 型になるのですが、項目がたくさんあります。 自分で定義した interface などの場合、 factory.ts だったり、factory-bot みたいなライブラリを使って書けば良いのですが、 Lambda ハンドラのイベントはとても項目が多いので、 factory 定義を書くのも大変です。

faker-ts の利用

そこで以前は faker-ts というライブラリを使っていて @types/aws-lambda から適当な値を生成していました。

import { tsMock } from 'faker-ts';

const mocker = tsMock(['/node_modules/@types/aws-lambda/index.d.ts']);
const event = mocker.generateMock('APIGatewayProxyEvent');

しばらくこの方法で問題はなかったのですが、 Node.js v16 と jest の組み合わせになり、メモリ不足エラーが出るようになってしまったので、見直しが必要になりました。

faker-ts の仕組み

faker-ts は以下の2つのステップから構成されていました。

これを別々に実行して試していくプランとしました。

typescript-json-schema の利用

まずはJSONスキーマの生成から。 このNPMパッケージには CLI ツールも付いているので、npm script でスキーマファイルを生成できるようにしました。

"scripts": {
  "tjs": "typescript-json-schema --required"
}

のように package.json に記述したら、コマンドを実行して JSONスキーマファイルを生成します。

$ npm run tjs -- -o test/schema/APIGatewayProxyEvent.json node_modules/@types/aws-lambda/index.d.ts APIGatewayProxyEvent

このようにして、必要な型のJSONスキーマを test/schema の下に生成していきます。

json-schema-faker の利用

テストコードでは、以下のようにすることでJSONオブジェクトを生成します。

import { JSONSchemaFaker } from 'json-schema-faker';
import { faker } from '@faker-js/faker/locale/ja';

JSONSchemaFaker.option('useExamplesValue', true);
JSONSchemaFaker.option('useDefaultValue', true);
JSONSchemaFaker.option('faker', () => faker);

const event = <APIGatewayProxyEvent>JSONSchemaFaker.generate(require('test/schema/APIGatewayProxyEvent.json'));
event.body = /** テストで使う入力値 */

さいごに

これで TypeScript Interface から型定義に従った Fake オブジェクトが作れるようになりました。 faker-ts はあまりメンテされていなかったので、コードを fork してライブラリを最新追従したりして使っていたのですが、その手間も不要となりました。

2つに処理を分割してわかったのは、 typescript-json-schema が結構時間がかかっていたので、これを CLI にすることでメモリの利用状況も少なくなりました。 そのため、毎回JSONスキーマを生成しなくなった分、テストも速くなり良いことが多かったです。 この方法だと自分で作った TypeScript の型などでも一度 JSONスキーマを生成しておけば、複数の factory ライブラリを使わなくても良くなるので、良いかもしれません。(ただ現時点では factory-bot とかの方が使い勝手が良いとは思います。たとえば buildList みたいなことができないので)

Node.js で AWS SQS を使ったコードの自動テストを記述する

前回のNode.js で BigQuery を使ったコードの自動テストを記述すると同じように外部サービスを使った部分の自動テストについてです。 今回は AWS SQS を使ったコードの自動テストを書く場合です。

BigQuery と違い、 AWS SQS のエミュレータは検索するとすぐに見つかりました。 たとえば roribio16/alpine-sqs というもの。 こちらは日本語の使ってみた記事もいくつか出ています。

roribio16/alpine-sqs

さっそく上記の記事や、公式ドキュメントを参考に動かしてみます。

Dockerコンテナをあげて aws cli から実行してみると、簡単に利用することができたので、 Node.js + Jest のテストコードを書いて試してみます。 すると ECONNRESET エラーとなって動きません。おやぁ?

GitHub のリポジトリを見てみると、関連する issue が出てました。 どうも Apple Silicon (いわゆるM1とかM2とか)からだとエラーになるようです。 解決するための Pull Request も出てるんですが、マージされる気配がないようです。 現在、開発用の端末は M1 MacBook Pro を使っているので、困ってしまいました。

alpine-sqs の Dockerfile を見ると elasticmq-server を使っているようなので、それを調べてみます。

elasticmq-server

ElasticMQ の GitHub を見てみると、普通に

Amazon SQS-compatible interface

って書いてありました。 これだけで良いのでは?という感じです。 (alpine-sqs の利点は何だったんだろう?)

で、それっぽいキーワードで検索すると、記事もありました。

調べ方の問題ですかね?キーワードの指定の方法によっては、こっちの方がたくさん出てきました。

さてこちらも Docker コンテナがあるので、それを使って起動します。

$ docker run -d -rm -p 9324:9324 -p 9325:9325 softwaremill/elasticmq-native

起動時にキューを作っておくには config ファイルを記述してファイルをマウントします。

$ docker run -d -rm -p 9324:9324 -p 9325:9325 -v `pwd`/custom.conf:/opt/elasticmq.conf softwaremill/elasticmq-native

config の書き方は、 GitHub に例が書いてあるので、そちらを参考にしてください。

Node.js からの接続

まず aws-sdk のクレデンシャルに適当な値を設定しておきます。 あとは SQS クラスのインスタンスを生成するだけです。

import * as AWS from 'aws-sdk';
import { SQS } from 'aws-sdk';

AWS.config.credentials = {
  secretAccessKey: 'secretAccessKey',
  accessKeyId: 'accessKeyId',
  sessionToken: 'sessionToken',
};

let sqs: SQS;

beforeAll(() => {
  sqs = new SQS({
    region: 'ap-northeast-1',
    endpoint: 'http://localhost:9324',
  })
});

キューのURLはデフォルト設定だと http://localhost:9324/000000000000/キュー名 のようになります。 このあたりは aws-cli などでキュー一覧を取得してもわかるので、そこから情報を取得しても大丈夫でしょう。

さいごに

はい。 これで AWS SQS を使ったコードの UnitTest も書けるようになりますね。 どんどん UnitTest を充実させていきましょう。