jest.mockでaws-sdkをスタブする

## はじめに
Jestでaws-sdkを使ったテストを作成したので、どのようにテストしたのかについてまとめてみました。

## 自己紹介
私は2022年10月にフロントエンドエンジニアとしてニジボックスへ入社しました。
ニジボックスへ入社する前は自社開発企業で1年ほどRailsを書いていましたが、現在は GraphQL、TypeScriptといった技術を勉強しつつ、実務で扱っています。

## 前提
自己紹介でも書いたように、今までRailsの経験しかなかったため、テストの経験はRSpecのみのJest初心者が記事を書いています。
そのため見る人によっては拙い内容かもしれませんが、誰かの助けになったら嬉しいので記事に残します！
ちなみに、この記事の中ではS3Clientをスタブ化してテストを行っています。
環境はNode.js、言語はTypeScriptです。

## 実行コード
```typescript:uploadS3.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
export const uploadContentsToS3 = async (
  contents: string,
  s3BucketName: string
) => {
  const s3 = new S3Client({ region: 'ap-northeast-1' })
  const fileName = 'fileName.csv'
  const putCommand = new PutObjectCommand({
    Bucket: s3BucketName,
    Key: fileName,
    ACL: 'private',
    Body: contents,
  })
  const s3Object = await s3.send(putCommand)
  const etag = s3Object.ETag
  if (!etag) {
    const deleteCommand = new DeleteObjectCommand({
      Bucket: s3BucketName,
      Key: fileName,
    })
    await s3.send(deleteCommand)
    throw new Error(
      'Error uploading object(Etag is nothing Therefore, the object is deleted)'
    )
  }
  return { fileName, etag }
}
```

## テストコード
```typescript:uploadS3.test.ts
import { uploadContentsToS3 } from '../uploadS3.ts'
import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
let hasEtag: boolean
let sendMock: jest.Mock<void, unknown[]>
// jest.mockでaws-sdk全体をスタブする
jest.mock('@aws-sdk/client-s3', () => {
  // jest.requireActual -> モックではない本物のモジュールを返してくれる
  const originalModule = jest.requireActual('@aws-sdk/client-s3')
  class StubS3Client {
    constructor() {
      // sendメソッドが呼ばれた回数を数えるため、sendMockを用意する
      sendMock = jest.fn()
    }
    async send(...args: unknown[]) {
      // sendメソッドが呼ばれたらsendMockが呼ばれるようにしておく
      sendMock(...args)
      if (hasEtag) {
        return { ETag: 'test' }
      } else {
        return {}
      }
    }
  }
  return {
    // S3Clientのみスタブしたいので、それ以外は本物を返すようにする
    ...originalModule,
    S3Client: StubS3Client,
  }
})
describe(uploadContentsToS3, () => {
    const contents = 'test'
    const s3BucketName = 'test'
    describe('when an object could not be created but Etag is nothing', () => {
      it('fails job', async () => {
        hasEtag = false
        await expect(
          uploadContentsToS3(contents, s3BucketName)
        ).rejects.toThrowError(
          new Error(
            'Error uploading object(Etag is nothing Therefore, the object is deleted)'
          )
        )
        expect(sendMock).toHaveBeenCalledTimes(2)
        expect(sendMock.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand)
        expect(sendMock.mock.calls[1][0]).toBeInstanceOf(DeleteObjectCommand)
      })
    })
    describe('success', () => {
      it('return { fileName, etag }', async () => {
        hasEtag = true
        await expect(
          uploadContentsToS3(contents, s3BucketName)
        ).resolves.toEqual({ fileName: 'fileName.csv', etag: 'test' })
        expect(sendMock).toHaveBeenCalledTimes(1)
        expect(sendMock.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand)
      })
    })
  })
```
`jest.mock`
第一引数に渡されたモジュールのモックを作成します。
第二引数で置き換えた後のオブジェクトを定義できます。
ここでは使用していませんが、第三引数にオプション指定もできるようです。（システム内のどこにも存在しないモジュールのモックを作成したいときに使うようです）
使用する際はトップレベルに記述する必要があります。
参考：[jest.mock](https://jestjs.io/ja/docs/jest-object#jestmockmodulename-factory-options)

`jest.requireActual`
モックではなく、本物のモジュールを返してくれます。
今回のコードではS3Clientのみスタブするため、`jest.requireActual`を使いテストを実行できるようにしました。
参考：[jest.requireActual](https://jestjs.io/ja/docs/jest-object#jestrequireactualmodulename)

`toHaveBeenCalledTimes(number)`
呼ばれた回数を確認します。
参考：[toHaveVeenCalledTimes(number)](https://jestjs.io/ja/docs/expect#tohavebeencalledtimesnumber)

`toBeInstanceOf(Class)`
結果が特定のクラスのインスタンスであるかどうかを確認します。
参考：[toBeInstanceOf(Class)](https://jestjs.io/ja/docs/expect#tobeinstanceofclass)

S3Clientをスタブ化し、sendメソッドが呼ばれたタイミングでsendMockを呼ぶことで、`toHaveBeenCalledTimes(number)`や`toBeInstanceOf(Class)`を使えるようにしています。

## 実行してみる
↓参考までに、テスト実行時の手元のpackage.jsonの一部を抜粋しています。
```json:package.json
{
  "version": "1.0.0",
  "dependencies": {
    "@aws-sdk/client-s3": "3.218.0",
  },
  "devDependencies": {
    "@types/jest": "29.2.3",
    "@types/node": "18.7.21",
    "jest": "29.3.1",
    "jest-environment-node": "29.3.1",
    "jest-environment-node-single-context": "29.0.0",
    "ts-jest": "29.0.3",
    "ts-node": "10.9.1",
    "ts-node-dev": "2.0.0",
    "typescript": "4.9.3",
  }
}

```

テストを実行してみます。
```shell:コンソール
yarn test
yarn run v1.22.18
 PASS  test/uploadS3.test.ts (6.233 s)
  uploadS3.ts functions
    uploadS3.ts
      when an object could not be created but Etag is nothing
        ✓ fails job (15 ms)
      success
        ✓ return { fileName, etag } (46 ms)
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        8.388 s
Ran all test suites.
Done in 9.45s.
```
通りました！

## おわりに
使い慣れたRSpecと見比べると、なんとなく構成が似ているので「読むことはできるな」と思いつつも、モックの作り方が全然違い（当たり前ですが、RSpecの`allow`とかがない）、どう組み立ててスタブしていくのか悩みました。
`spyOn()`などまだ使ったことがない/知らないメソッドがたくさんあると思うので、これからたくさんテストを書いてJestマスターになりたいと思います！