CLOVER🍀

That was when it all began.

AWS SDK v3 Client mockでAWS SDK for JavaScript v3をモックする

これは、なにをしたくて書いたもの?

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 for JavaScript

モックライブラリとしてaws-sdk-mockがありました。

GitHub - dwyl/aws-sdk-mock: AWSomocks for Javascript/Node.js aws-sdk tested, documented & maintained. Contributions welcome!

AWS SDK for JavaScript v3とモックライブラリ

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のリポジトリはこちらです。

GitHub - m-radzikowski/aws-sdk-client-mock: AWS JavaScript SDK v3 mocks for easy unit testing. 🖋️ Typed 🔬 Tested 📄 Documented 🛠️ Maintained

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 CLIとダミーのクレデンシャルの設定。

$ 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をモックしてみました。

コマンドだけではなくて引数に対してもモックの振る舞いを柔軟に指定できるので、便利だなと思います。