これは、なにをしたくて書いたもの?
AWS SDK for JavaScript v2の時には、モックライブラリとしてaws-sdk-mockがありました。
そういえば、AWS SDK for JavaScript v3の場合はどうなのかというと、AWS SDK v3 Client mockというものが存在しているようです。
Mocking modular AWS SDK for JavaScript (v3) in Unit Tests | AWS Developer Tools Blog
こちらを試してみようかなということで。
AWS SDK for JavaScript v2とモックライブラリ
AWS SDK for JavaScript v2の時には、
モックライブラリとしてaws-sdk-mockがありました。
AWS SDK for JavaScript v3とモックライブラリ
AWS SDK for JavaScript v3の場合はどうなのかというと、
AWS SDK v3 Client mockというモックライブラリがあり、AWSがブログエントリーを出しています。
Mocking modular AWS SDK for JavaScript (v3) in Unit Tests | AWS Developer Tools Blog
AWS SDK v3 Client mockのリポジトリはこちらです。
AWS SDK v3 Client mock
AWS SDK v3 Client mockの機能は、以下のようですね。
- 各種クライアントの
Command
操作(Client#send
)をモックできる- 戻り値を指定できる
- 引数に応じた振る舞いを指定できる
- エラーをスローできる
- Jestのマッチャーがある
APIリファレンスはこちらです。
AWS SDK v3 Client mock - v2.0.1
今回は、こちらを使ってAmazon SQSへのアクセスをモックで差し替えてみたいと思います。
環境
今回の環境は、こちら。
$ node --version v18.12.1 $ npm --version 8.19.2
Amazon SQSの代替サービスとして、ElasticMQを使うことにします。
$ java --version openjdk 17.0.5 2022-10-18 OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04) OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing) $ java -jar elasticmq-server-1.3.14.jar
$ aws --version aws-cli/2.9.12 Python/3.9.11 Linux/5.15.0-56-generic exe/x86_64.ubuntu.22 prompt/off $ export AWS_ACCESS_KEY_ID=test $ export AWS_SECRET_ACCESS_KEY=test $ export AWS_DEFAULT_REGION=us-east-1
ElasticMQにキューを作成しておきます。今回はFIFOキューにしました。
$ aws sqs create-queue --endpoint-url http://localhost:9324 --queue-name myqueue.fifo --attributes FifoQueue=true,ContentBasedDeduplication=true { "QueueUrl": "http://localhost:9324/000000000000/myqueue.fifo" }
Amazon SQSにアクセスするコードとテストを書く
それでは、まずはAmazon SQSにアクセスするコードを書いていきます。
npmプロジェクトを作成して、TypeScript等の依存関係を追加。
$ npm init -y $ npm i -D typescript $ npm i -D @types/node@v18 $ npm i -D prettier $ npm i -D jest @types/jest $ npm i -D esbuild esbuild-jest $ mkdir src test
AWS SDK for JavaScript v3のAmazon SQS向けのライブラリを追加。
$ npm i @aws-sdk/client-sqs
この時点での依存関係。
"devDependencies": { "@types/jest": "^29.2.5", "@types/node": "^18.11.18", "esbuild": "^0.16.13", "esbuild-jest": "^0.5.0", "jest": "^29.3.1", "prettier": "^2.8.1", "typescript": "^4.9.4" }, "dependencies": { "@aws-sdk/client-sqs": "^3.241.0" }
scripts
。
"scripts": { "build": "tsc --project .", "build:watch": "tsc --project . --watch", "typecheck": "tsc --project ./tsconfig.typecheck.json", "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch", "test": "jest", "format": "prettier --write src test" },
各種設定ファイル。
tsconfig.json
{ "compilerOptions": { "target": "esnext", "module": "commonjs", "moduleResolution": "node", "lib": ["esnext"], "baseUrl": "./src", "outDir": "dist", "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, "noPropertyAccessFromIndexSignature": true, "esModuleInterop": true }, "include": [ "src" ] }
tsconfig.typecheck.json
{ "extends": "./tsconfig", "compilerOptions": { "baseUrl": "./", "noEmit": true }, "include": [ "src", "test" ] }
.prettierrc.json
{ "singleQuote": true, "printWidth": 120 }
jest.config.js
module.exports = { testEnvironment: 'node', transform: { "^.+\\.tsx?$": "esbuild-jest" } };
ソースコード。Amazon SQSにメッセージを送信する関数と、メッセージを受信する関数の2つを作成しました。
src/sqs-client-app.ts
import { DeleteMessageCommand, DeleteMessageCommandInput, ReceiveMessageCommand, ReceiveMessageCommandInput, SendMessageCommand, SendMessageCommandInput, SQSClient, } from '@aws-sdk/client-sqs'; import { randomUUID } from 'crypto'; const sqsClient = new SQSClient({ credentials: { accessKeyId: 'test', secretAccessKey: 'test', }, region: 'us-east-1', endpoint: 'http://localhost:9324', }); export async function sendMessage(queueUrl: string, messageGroupId: string, messageBody: string): Promise<string> { const input: SendMessageCommandInput = { QueueUrl: queueUrl, MessageGroupId: messageGroupId, MessageDeduplicationId: randomUUID(), MessageBody: messageBody, }; const command = new SendMessageCommand(input); const output = await sqsClient.send(command); return output.MessageId!; } export async function receiveMessages(queueUrl: string): Promise<string[]> { const receiveMessageInput: ReceiveMessageCommandInput = { QueueUrl: queueUrl, ReceiveRequestAttemptId: randomUUID(), MaxNumberOfMessages: 10, }; const receiveMessageCommand = new ReceiveMessageCommand(receiveMessageInput); const receiveMessageOutput = await sqsClient.send(receiveMessageCommand); let messages: string[]; if (receiveMessageOutput.Messages !== undefined) { messages = receiveMessageOutput.Messages.map((m) => m.Body!); for (const message of receiveMessageOutput.Messages) { await deleteMessage(queueUrl, message.ReceiptHandle!); } } else { messages = []; } return messages; } async function deleteMessage(queueUrl: string, receiptHandle: string): Promise<void> { const deleteMessageInput: DeleteMessageCommandInput = { QueueUrl: queueUrl, ReceiptHandle: receiptHandle, }; const deleteMessageComnand = new DeleteMessageCommand(deleteMessageInput); await sqsClient.send(deleteMessageComnand); }
動作確認は、テストコードで行っておきます。
test/sqs-client-app.test.ts
import { setTimeout } from 'timers/promises'; import { receiveMessages, sendMessage } from '../src/sqs-client-app'; test('with ElasticMQ test', async () => { const queueUrl = 'http://localhost:9324/000000000000/myqueue.fifo'; const messageGroupId = 'message-group-1'; for (const i of [1, 2, 3, 4, 5]) { const id = await sendMessage(queueUrl, messageGroupId, `Hello SQS ${i}!!!`); expect(id).not.toBeUndefined(); } await setTimeout(2000); const messages = await receiveMessages(queueUrl); expect(messages).toHaveLength(5); expect(messages).toEqual(['Hello SQS 1!!!', 'Hello SQS 2!!!', 'Hello SQS 3!!!', 'Hello SQS 4!!!', 'Hello SQS 5!!!']); });
AWS SDK v3 Client mockを使って、AWS SDKをモックする
では、AWS SDK v3 Client mockを使ってAWS SDKをモックします。
まずはAWS SDK v3 Client mockをインストール。
$ npm i -D aws-sdk-client-mock
この時点での依存関係。
"devDependencies": { "@types/jest": "^29.2.5", "@types/node": "^18.11.18", "aws-sdk-client-mock": "^2.0.1", "esbuild": "^0.16.13", "esbuild-jest": "^0.5.0", "jest": "^29.3.1", "prettier": "^2.8.1", "typescript": "^4.9.4" }, "dependencies": { "@aws-sdk/client-sqs": "^3.241.0" }
このあたりを見ながら、使ってみます。
AWS SDK v3 Client mock / Usage / Mock
AWS SDK v3 Client mock / Usage / Inspect
まずは雛形的に以下を用意。
test/sqs-client-app-mock.test.ts
import { DeleteMessageCommand, ReceiveMessageCommand, SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs'; import { mockClient } from 'aws-sdk-client-mock'; import { receiveMessages, sendMessage } from '../src/sqs-client-app'; const sqsMock = mockClient(SQSClient); beforeEach(() => { sqsMock.reset(); }); // ここに、テストを書く! }
Amazon SQSのクライアントをモックするようにして、テストの都度リセットするようにしています。
メッセージ送信を行うSendMessageCommand
の呼び出し確認。コマンドの呼び出しをモックする感じですね。
test('sendMessage test, with mock', async () => { sqsMock.on(SendMessageCommand).resolves({}); const id1 = await sendMessage('url', 'groupid', 'body1'); const id2 = await sendMessage('url', 'groupid', 'body2'); expect(id1).toBeUndefined(); expect(id2).toBeUndefined(); expect(sqsMock.calls()).toHaveLength(2); const call1 = sqsMock.call(0); const sendCommand1 = call1.firstArg as SendMessageCommand; expect(sendCommand1.input.MessageBody).toBe('body1'); const call2 = sqsMock.call(1); const sendCommand2 = call2.firstArg as SendMessageCommand; expect(sendCommand2.input.MessageBody).toBe('body2'); });
ここでは、呼び出しが行われたこととinput
に指定した値の確認をしています。
呼び出す引数に応じて、戻り値も指定することもできます。resolves
でコマンドの戻り値を指定します。
test('sendMessage test, with mock, with arguments', async () => { sqsMock .on(SendMessageCommand, { MessageBody: 'body1' }) .resolves({ MessageId: 'id1' }) .on(SendMessageCommand, { MessageBody: 'body2' }) .resolves({ MessageId: 'id2' }); const id1 = await sendMessage('url', 'groupid', 'body1'); const id2 = await sendMessage('url', 'groupid', 'body2'); expect(id1).toBe('id1'); expect(id2).toBe('id2'); expect(sqsMock.calls()).toHaveLength(2); });
メッセージ受信を行うReceiveMessageCommand
に対する呼び出し確認。
test('receiveMessages test, with mock', async () => { sqsMock.on(ReceiveMessageCommand).resolves({ Messages: [ { Body: 'mock body1', }, { Body: 'mock body2', }, ], }); const messages = await receiveMessages('url'); expect(messages).toHaveLength(2); expect(messages).toEqual(['mock body1', 'mock body2']); });
こんな感じで、複数のコマンドをつなげてモックを設定することもできます。
test('receiveMessages test, with mock, with arguments', async () => { sqsMock .on(ReceiveMessageCommand) .resolves({ Messages: [ { Body: 'mock body1', ReceiptHandle: 'receipt handle1', }, { Body: 'mock body2', ReceiptHandle: 'receipt handle2', }, ], }) .on(DeleteMessageCommand, { ReceiptHandle: 'receipt handle1', }) .resolves({}) .on(DeleteMessageCommand, { ReceiptHandle: 'receipt handle2', }) .resolves({}); const messages = await receiveMessages('url'); expect(messages).toHaveLength(2); expect(messages).toEqual(['mock body1', 'mock body2']); const calls = sqsMock.calls(); expect(calls).toHaveLength(3); expect(calls[0].firstArg).toBeInstanceOf(ReceiveMessageCommand); expect(calls[1].firstArg).toBeInstanceOf(DeleteMessageCommand); expect(calls[2].firstArg).toBeInstanceOf(DeleteMessageCommand); expect((calls[1].firstArg as DeleteMessageCommand).input.ReceiptHandle).toBe('receipt handle1'); expect((calls[2].firstArg as DeleteMessageCommand).input.ReceiptHandle).toBe('receipt handle2'); });
なかなか便利ですね。
AWS SDK v3 Client mockの提供するJestマッチャーを使う
最後に、AWS SDK v3 Client mockの提供するJestマッチャーを使ってみます。
AWS SDK v3 Client mockの提供するJestマッチャーを使うには、npmモジュールの追加が必要です。
$ npm i -D aws-sdk-client-mock-jest
この時の依存関係。
"devDependencies": { "@types/jest": "^29.2.5", "@types/node": "^18.11.18", "aws-sdk-client-mock": "^2.0.1", "aws-sdk-client-mock-jest": "^2.0.1", "esbuild": "^0.16.13", "esbuild-jest": "^0.5.0", "jest": "^29.3.1", "prettier": "^2.8.1", "typescript": "^4.9.4" }, "dependencies": { "@aws-sdk/client-sqs": "^3.241.0" }
ドキュメントとしてはこちらになります。
AWS SDK v3 Client mock / Usage / Jest matchers
このJestマッチャーを使うと、先ほどまで書いていたモックに対するアサーションをもう少し簡潔に書くことができます。
※少しテストの数は絞りました
test/sqs-client-app-mock-jest.test.ts
import { DeleteMessageCommand, ReceiveMessageCommand, SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; import { receiveMessages, sendMessage } from '../src/sqs-client-app'; const sqsMock = mockClient(SQSClient); beforeEach(() => { sqsMock.reset(); }); test('sendMessage test, with mock', async () => { sqsMock.on(SendMessageCommand).resolves({}); const id1 = await sendMessage('url', 'groupid', 'body1'); const id2 = await sendMessage('url', 'groupid', 'body2'); expect(id1).toBeUndefined(); expect(id2).toBeUndefined(); expect(sqsMock).toReceiveCommand(SendMessageCommand); expect(sqsMock).toReceiveCommandWith(SendMessageCommand, { MessageBody: 'body1' }); expect(sqsMock).toReceiveNthCommandWith(2, SendMessageCommand, { MessageBody: 'body2' }); expect(sqsMock).toReceiveCommandTimes(SendMessageCommand, 2); }); test('receiveMessages test, with mock, with arguments', async () => { sqsMock .on(ReceiveMessageCommand) .resolves({ Messages: [ { Body: 'mock body1', ReceiptHandle: 'receipt handle1', }, { Body: 'mock body2', ReceiptHandle: 'receipt handle2', }, ], }) .on(DeleteMessageCommand, { ReceiptHandle: 'receipt handle1', }) .resolves({}) .on(DeleteMessageCommand, { ReceiptHandle: 'receipt handle2', }) .resolves({}); const messages = await receiveMessages('url'); expect(messages).toHaveLength(2); expect(messages).toEqual(['mock body1', 'mock body2']); expect(sqsMock).toReceiveCommandTimes(ReceiveMessageCommand, 1); expect(sqsMock).toReceiveCommandTimes(DeleteMessageCommand, 2); expect(sqsMock).toReceiveCommandWith(ReceiveMessageCommand, { QueueUrl: 'url' }); expect(sqsMock).toReceiveNthCommandWith(2, DeleteMessageCommand, { ReceiptHandle: 'receipt handle1' }); expect(sqsMock).toReceiveNthCommandWith(3, DeleteMessageCommand, { ReceiptHandle: 'receipt handle2' }); });
まとめ
AWS SDK v3 Client mockを使って、AWS SDK for JavaScript v3をモックしてみました。
コマンドだけではなくて引数に対してもモックの振る舞いを柔軟に指定できるので、便利だなと思います。