aws-sdk-client-mock-vitestを利用して、aws-sdkをmockする！

# はじめに

aws-sdkをmockしたいです。

ただ、aws-sdkは微妙にmockしづらい仕様になっており、下記ドキュメントにあるvitestのアサーションでは検証が難しいです。

https://vitest.dev/api/expect.html

そこで登場するのが「aws-sdk-client-mock-vitest」です！
vitest用に作られたaws-sdk-client-mock-vitestというライブラリがあります。

https://www.npmjs.com/package/aws-sdk-client-mock-vitest

今回はこれをさわってみます！！

# 動作環境構築

## プロジェクト構築

ドキュメントを参考にプロジェクトを作っていきます。

https://ja.vitejs.dev/guide/#%E6%9C%80%E5%88%9D%E3%81%AE-vite-%E3%83%95%E3%82%9A%E3%83%AD%E3%82%B7%E3%82%99%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E7%94%9F%E6%88%90%E3%81%99%E3%82%8B

https://vitest.dev/guide/#adding-vitest-to-your-project

```shell
npm create vite@latest sdk-mock-vitest -- --template vanilla-ts

cd sdk-mock-vitest

npm install

npm install -D vitest
```

```shell
sdk-mock-vitest/
├── node_modules/
├── public/
├── src/
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
└── tsconfig.json
```

vitestが動くプロジェクトができました。
ファイルを作成し、vitestが動くかテストします。

```shell
mkdir __test__
touch __test__/sample.test.ts
```

作成したファイルの中身です。
1とは、1なのか？
```sample.test.ts
import { expect, test } from 'vitest'

test('1 equal 1', () => {
  expect(1).toBe(1)
})
```

package.jsonにvitestを実行するscriptを追記します。
```diff_json
{
  "name": "sdk-mock-vitest",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
+   "test": "vitest",
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "^5.5.3",
    "vite": "^5.4.1"
  }
}
```

vitestが実行できることを確認します。
```shell
$ npm run test

> sdk-mock-vitest@0.0.0 test
> vitest


 DEV  v2.0.5 C:/work/sdk-mock-vitest

 ✓ __test__/sample.test.ts (1)
   ✓ 1 equal 1

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  08:43:44
   Duration  1.49s (transform 140ms, setup 0ms, collect 160ms, tests 8ms, environment 1ms, prepare 600ms) 


 PASS  Waiting for file changes...
       press h to show help, press q to quit
```

良い感じですね。

では、本題のaws-sdk-client-mock-vitestをインストールしていきます。

## aws-sdk-client-mock-vitestのインストール

aws-sdkは実装が簡単なs3のものを使ってみます。

```shell
npm i @aws-sdk/client-s3
npm i -D aws-sdk-client-mock aws-sdk-client-mock-vitest
```

```shell
touch src/sample.ts
```

以下の公式リポジトリを参考にコードを実装します。

https://github.com/awsdocs/aws-doc-sdk-examples/blob/main/javascriptv3/example_code/s3/actions/list-buckets.js

```src/sample.ts
import { S3Client, ListObjectsV2Command, } from "@aws-sdk/client-s3";

const client = new S3Client({ region: "ap-northeast-1" });

export const getObjectsCount = async (bucketName: string) => {
    const command = new ListObjectsV2Command({
        Bucket: bucketName,
    });
    try {
        const result = await client.send(command);
        console.log(result);
        const { Contents } = result;
        return Contents?.length;
    } catch (err) {
        console.error(err);
    }
};

// 動作確認用に関数を実行する
getObjectsCount('test-s3')
```

引数にバケット名を渡し、バケット内のオブジェクトの数を返却する関数です。

```src/sample.ts
// 動作確認用に関数を実行する
getObjectsCount('test-s3')
```

動作確認用に、一時的に関数を実行するコードを埋め込んでいます。

ローカルで実行するとエラーが発生し、mockが必要なことを確認します。

ローカルでtsファイルを実行するための「tsx」というライブラリをインストールします。

https://tsx.is/

```shell
npm i -D tsx
```

以下のコマンドでコードを実行します。

```
npx tsx src/sample.ts 
```


```shell
$ npx tsx src/sample.ts 
CredentialsProviderError: Could not load credentials from any providers
    at C:\work\XXXXXX
    ...
    ...
```

エラーが出ました！


```shell
CredentialsProviderError: Could not load credentials from any providers
```

CredentialsProviderErrorが出ています。いい感じですね。


一応、コードがバグっていないか、EC2上で実行します。

（～S3とEC2の準備は超割愛～）
S3を作成し、ファイルを投げ込みます。
EC2上でコードを実行するため、上記で作ったコードをcodecommitにpushします。
codecommitやS3操作権限のあるEC2でリポジトリをcloneして、実行してみます。

![cap01.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/7c284e36-f400-b8fd-b7a0-39c5c716103e.png)

EC2上では動きました。

コードは問題なさそうなので、動作確認用の行を消しておきます。

```diff_typescript
import { S3Client, ListObjectsV2Command, } from "@aws-sdk/client-s3";

const client = new S3Client({ region: "ap-northeast-1" });

export const getObjectsCount = async (bucketName: string) => {
    const command = new ListObjectsV2Command({
        Bucket: bucketName,
    });
    try {
        const result = await client.send(command);
        console.log(result);
        const { Contents } = result;
        return Contents?.length;
    } catch (err) {
        console.error(err);
    }
};

- // 動作確認用に関数を実行する
- getObjectsCount('test-s3')
```

## テストコードの実装

テストコードを実装します。

今回実装した関数は、実行されると引数で指定したバケットに保存されたオブジェクトの数が返却されます。

テストでは2つのテストケースを確認しようと思います。

1. バケット内のオブジェクトの数が返却されること
→そのままの内容です。関数を実行し、想定したnumberが返却されることを確認します。

2. S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること
→関数を実行した際の引数が、ListObjectsV2Commandを使用する際に対象バケット名として利用されていることを確認します。

しつこいですが、一応mockしないで書いてみます。

```sample.test.ts
import { expect, describe, it } from 'vitest'
import { getObjectsCount } from '../src/sample'

describe('getObjectsCount', () => {
    it('バケット内のオブジェクトの数が返却されること', async () => {
        const result = await getObjectsCount('bucket-name');
        expect(result).toBe(1);
    })
    it.todo('S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること', async () => { })
})
```

落ちるはずなので、落とします。

```
> vitest


 DEV  v2.0.5 C:/work/spike/sdk-mock-vitest

stderr | __test__/sample.test.ts > getObjectsCount > バケット内のオブジェクトの数が返却されること
CredentialsProviderError: Could not load credentials from any providers
    at XXXXXX
    ...
    ...

 ❯ __test__/sample.test.ts (2)
   ❯ getObjectsCount (2)
     × バケット内のオブジェクトの数が返却されること
     ↓ S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること [skipped]

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
 FAIL  __test__/sample.test.ts > getObjectsCount > バケット内のオブジェクトの数が返却されること
AssertionError: expected undefined to be 1 // Object.is equality

- Expected:
1

+ Received:
undefined
```

テストが落ちました。mockしましょう！

使い方は簡単なので、リポジトリのサンプルを参考し、実装してみます。

https://www.npmjs.com/package/aws-sdk-client-mock-vitest

今回は以下のように実装しました。

```__test__/sample.test.ts
import { expect, describe, it } from 'vitest'
import { mockClient } from "aws-sdk-client-mock";
import {
    S3Client,
    ListObjectsV2Command,
    ListObjectsV2CommandOutput
} from "@aws-sdk/client-s3";
import { getObjectsCount } from '../src/sample'

const s3MockClient = mockClient(S3Client);

// ダミーとして引数に使用するバケット名文字列
const bucketName = 'bucket-name'

// ダミーとして返却するListObjectsV2Commandのoutput
const mockOutput: ListObjectsV2CommandOutput = {
    $metadata: {
        httpStatusCode: 200,
        requestId: 'TESTTESTTESTTEST',
        extendedRequestId: 'TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST',
        cfId: undefined,
        attempts: 1,
        totalRetryDelay: 0
    },
    "Contents": [
        {
            "ETag": "\"70ee1738b6b21e2c8a43f3a5ab0eee71\"",
            "Key": "happyface.jpg",
            "LastModified": new Date(),
            "Size": 11,
            "StorageClass": "STANDARD"
        },
        {
            "ETag": "\"becf17f89c30367a9a44495d62ed521a-1\"",
            "Key": "test.jpg",
            "LastModified": new Date(),
            "Size": 4192256,
            "StorageClass": "STANDARD"
        }
    ],
    "IsTruncated": true,
    "KeyCount": 2,
    "MaxKeys": 2,
    "Name": "DOC-E-AMPLE-BUCKET",
    "NextContinuationToken": "1w41l63U0xa8q7smH50vCxyTQqdxo69O3EmK28Bi5PcROI4wI/EyIJg==",
    "Prefix": ""
}

describe('getObjectsCount関数', () => {
    it('バケット内のオブジェクトの数が返却されること', async () => {
        // mock処理
        s3MockClient.on(ListObjectsV2Command).resolves(mockOutput)

        // 関数実行
        const result = await getObjectsCount(bucketName);

        // 関数の結果を確認する
        expect(result).toBe(mockOutput.Contents!.length);
    })
    it.skip('S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること', async () => { })
})
```

まず、mockクライアントと、mockにふるまわせるコマンドをimportします。
```__test__/sample.test.ts
import { mockClient } from "aws-sdk-client-mock";
import {
    S3Client,
    ListObjectsV2Command,
    ListObjectsV2CommandOutput
} from "@aws-sdk/client-s3";
```

ダミーのListObjectsコマンドの戻り値を定義します。
```__test__/sample.test.ts
const mockOutput: ListObjectsV2CommandOutput = {
    ...
}
```

事前準備は以上です。

実際にaws-sdkのclientをmockしていきます。

```__test__/sample.test.ts
const s3MockClient = mockClient(S3Client);
```

clientがmockに置き換わるので、以下のような実装で、自由にコマンドの結果をmockできます。
```__test__/sample.test.ts
        // mock処理
        s3MockClient.on(ListObjectsV2Command).resolves(mockOutput)
```

このように実装すると、mockClientはListObjectsV2Commandがsendされた時、定義したmockオブジェクトを返却します。

ですので、関数の1つ目のテストは以下に実装してみます。
```__test__/sample.test.ts
        // 関数実行
        const result = await getObjectsCount(bucketName);

        // 関数の結果を確認する
        expect(result).toBe(mockOutput.Contents!.length);
```

関数の戻り値 ```result``` は、mockが返却するオブジェクト内のContents配列（バケット内のオブジェクト配列）のlengthと等しいはずです。

あらためてテストを実行してみます。
```shell
$ npm run test

 RERUN  __test__/sample.test.ts x9

 ✓ __test__/sample.test.ts (2)
   ✓ getObjectsCount関数 (2)
     ✓ バケット内のオブジェクトの数が返却されること
     ↓ S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること [skipped]

 Test Files  1 passed (1)
      Tests  1 passed | 1 skipped (2)
   Start at  11:42:41
   Duration  1.07s


 PASS  Waiting for file changes...
```

passしました！いい感じにmockできてそうです。


つづいて、引数で渡した文字列を、オブジェクト数を取得する対象バケット名としてインプットで利用していることをテストします。

vitestやjestの ```toHaveBeenCalledWith``` アサーションを用いるイメージです。

https://vitest.dev/api/expect.html

ただ、aws-sdkは微妙にmockしづらい仕様になっており、上記ドキュメントにあるvitestのアサーションでは検証が難しいです。

そこで登場するのが「aws-sdk-client-mock-vitest」です！

https://www.npmjs.com/package/aws-sdk-client-mock-vitest

このライブラリを使うと、aws-sdk用にアサーションを拡張することができます。

さっそくやっていきます。

まず、アサーションを拡張するコードを用意します。ドキュメントからパクっています。

```shell
touch __test__/setup.ts
```

```__test__/setup.ts
import { expect } from "vitest";
import {
    toReceiveCommandTimes,
    toHaveReceivedCommandTimes,
    toReceiveCommandOnce,
    toHaveReceivedCommandOnce,
    toReceiveCommand,
    toHaveReceivedCommand,
    toReceiveCommandWith,
    toHaveReceivedCommandWith,
    toReceiveNthCommandWith,
    toHaveReceivedNthCommandWith,
    toReceiveLastCommandWith,
    toHaveReceivedLastCommandWith,
} from "aws-sdk-client-mock-vitest";

expect.extend({
    toReceiveCommandTimes,
    toHaveReceivedCommandTimes,
    toReceiveCommandOnce,
    toHaveReceivedCommandOnce,
    toReceiveCommand,
    toHaveReceivedCommand,
    toReceiveCommandWith,
    toHaveReceivedCommandWith,
    toReceiveNthCommandWith,
    toHaveReceivedNthCommandWith,
    toReceiveLastCommandWith,
    toHaveReceivedLastCommandWith,
});
```

aws-sdk-client-mock-vitestライブラリから、拡張されたアサーションメソッドをインポートします。
また、 ```expect.extend()``` 関数で拡張されたアサーションメソッドを使用可能にしています。

このファイルをテスト実行時に読み込まないといけないので、vitestの設定ファイルを作成し、読み取るよう記述します。

https://vitest.dev/config/#setupfiles

```shell
touch vitest.config.ts
```

```vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
    test: {
        setupFiles: ["__test__/setup.ts"],
    },
});
```

また、TypeScriptを使用しているので、型定義も拡張します。

```shell
touch __test__/vitest.d.ts
```

```vitest.d.ts
import "vitest";
import { CustomMatcher } from "aws-sdk-client-mock-vitest";

declare module "vitest" {
    interface Assertion<T = any> extends CustomMatcher<T> { }
    interface AsymmetricMatchersContaining extends CustomMatcher { }
}
```

vscodeで拡張したアサーションが補完表示されたら疎通しています！！

![cap02.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/a04abb91-6f16-a38d-bf03-abea6438f167.png)

バッチリです！！！

では、拡張したアサーションを使用し、ListObjectsV2Command実行時のinputを検証します。

```diff_typescript
import { expect, describe, it } from 'vitest'
import { mockClient } from "aws-sdk-client-mock";
import {
    S3Client,
    ListObjectsV2Command,
    ListObjectsV2CommandOutput
} from "@aws-sdk/client-s3";
import { getObjectsCount } from '../src/sample'

const s3MockClient = mockClient(S3Client);

// ダミーとして引数に使用するバケット名文字列
const bucketName = 'bucket-name'

// ダミーとして返却するListObjectsV2Commandのoutput
const mockOutput: ListObjectsV2CommandOutput = {
    $metadata: {
        httpStatusCode: 200,
        requestId: 'TESTTESTTESTTEST',
        extendedRequestId: 'TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST-TEST',
        cfId: undefined,
        attempts: 1,
        totalRetryDelay: 0
    },
    "Contents": [
        {
            "ETag": "\"70ee1738b6b21e2c8a43f3a5ab0eee71\"",
            "Key": "happyface.jpg",
            "LastModified": new Date(),
            "Size": 11,
            "StorageClass": "STANDARD"
        },
        {
            "ETag": "\"becf17f89c30367a9a44495d62ed521a-1\"",
            "Key": "test.jpg",
            "LastModified": new Date(),
            "Size": 4192256,
            "StorageClass": "STANDARD"
        }
    ],
    "IsTruncated": true,
    "KeyCount": 2,
    "MaxKeys": 2,
    "Name": "DOC-E-AMPLE-BUCKET",
    "NextContinuationToken": "1w41l63U0xa8q7smH50vCxyTQqdxo69O3EmK28Bi5PcROI4wI/EyIJg==",
    "Prefix": ""
}

describe('getObjectsCount関数', () => {
    it('バケット内のオブジェクトの数が返却されること', async () => {
        // mock処理
        s3MockClient.on(ListObjectsV2Command).resolves(mockOutput)

        // 関数実行
        const result = await getObjectsCount(bucketName);

        // 関数の結果を確認する
        expect(result).toBe(mockOutput.Contents!.length);
    })
    it('S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること', async () => {
+        // mock処理
+        s3MockClient.on(ListObjectsV2Command).resolves(mockOutput)
+
+        // 関数実行
+        await getObjectsCount(bucketName);
+
+        // 関数の結果を確認する
+        expect(s3MockClient).toHaveReceivedCommandWith(ListObjectsV2Command, {
+            Bucket: bucketName
+        });
    })
})
```

aws-sdk-client-mock-vitestには ```toHaveReceivedCommandWith``` という、
コマンドがどういうinputでsendされたか？を検証できるメソッドがあるので、それを使います。

```__test__/sample.test.ts
        // 関数の結果を確認する
        expect(s3MockClient).toHaveReceivedCommandWith(ListObjectsV2Command, {
            Bucket: bucketName
        });
```


inputの型はライブラリのコメントや以下ドキュメントを参考にします。

![cap04.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/2070d41b-04ba-bfb3-f323-ec3bc5335b2e.png)

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-s3/Interface/ListObjectsV2CommandInput/

```Bucket: string``` がインプットなので、引数 ```bucketName``` がBucketとして与えられていることが確認できればよさそうです。

テストを実行してみます。

```shell
 RERUN  __test__/sample.test.ts x2

 ✓ __test__/sample.test.ts (2)
   ✓ getObjectsCount関数 (2)
     ✓ バケット内のオブジェクトの数が返却されること
     ✓ S3クライアントが、指定されたbucketNameを使用してListObjectsV2Commandを呼び出していること

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  15:56:33
   Duration  995ms


 PASS  Waiting for file changes...
```

やったー。

テストを壊してみます。
```diff_typescript
        // 関数の結果を確認する
        expect(s3MockClient).toHaveReceivedCommandWith(ListObjectsV2Command, {
-           Bucket: bucketName
+           Bucket: 'aiueo'
        });
```

```
 FAIL  __test__/sample.test.ts > getObjectsCount関数 > 引数で渡した文字列を、対象バケット名としてインプッ 
トで利用していること
Error: expected "ListObjectsV2Command" to be called with arguments: Object {
  "Bucket": "aiueo",
}

Received:

   1st ListObjectsV2Command call

  Object {
-   "Bucket": "aiueo",
+   "Bucket": "bucket-name",
  }

   2nd ListObjectsV2Command call

  Object {
-   "Bucket": "aiueo",
+   "Bucket": "bucket-name",
  }

Number of calls: 2
 ❯ __test__/sample.test.ts:68:30
     66| 
     67|         // 関数の結果を確認する
     68|         expect(s3MockClient).toHaveReceivedCommandWith(ListObjectsV2Command, {
       |                              ^
     69|             Bucket: 'aiueo'
     70|         });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  15:58:43
   Duration  1.12s
```

エラーが良い感じにでました。

```shell
   1st ListObjectsV2Command call

  Object {
-   "Bucket": "aiueo",
+   "Bucket": "bucket-name",
  }
```

アサーションもしっかり動いてそうです！！！

# おわりに
aws-sdk-client-mock-vitestをつかってみました。
vitestとの疎通もできてかなりいい感じでした。

mockはやや苦手なのですが、TSでテストを書けば型が検証されるおかげで不安が減って助かります。
あとvitest動作はやすぎ！