jestとaws-sdk-client-mockの使い方

Lambdaのテストを書くときにいつも書き方を忘れるのでメモします
jestとaws-sdk-client-mockを使います

## 環境整備

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

```bash
npm install -D aws-sdk-client-mock jest babel-jest @babel/core @babel/preset-env jest-html-reporters
```

```javascript:bable.config.js
module.exports = {
    presets: [
        [
            "@babel/preset-env"
        ]
    ],
}
```

```javascript:jest.config.js
module.exports = {
    clearMocks: true,
    collectCoverage: true,
    coverageDirectory: "coverage",
    coveragePathIgnorePatterns: ["/node_modules/"],
    reporters: [
        'jest-html-reporters'
    ],
    testEnvironment: "node",
    moduleFileExtensions: ["js", "mjs", "json", "node"],
    testRegex: ".*test.js",
    testPathIgnorePatterns: ["/node_modules/"],
    transform: {
        "^.+\\.m?js?$": "babel-jest",
    },
    setupFiles: ["<rootDir>/jest.setup.js"],
};
```

```javascript:jest.setup.js
// 最初に呼び出したいスクリプトを設定
// 環境変数の設定とかお好みで
process.env.TABLE_NAME = "hoge";
process.env.BUCKET_NAME = "hoge"
process.env.AWS_ACCESS_KEY_ID = "dummy";
process.env.AWS_SECRET_ACCESS_KEY = "dummy";
process.env.AWS_REGION = "us-west-2";
```
## DynamoDBからItemを取得するコード
```javascript
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

const initDynamoClient = () => {
    const marshallOptions = {
        removeUndefinedValues: true,
    };
    const translateConfig = { marshallOptions };
    const DynamoDBclient = new DynamoDBClient({
        region: 'ap-northeast-1'
    });
    
    const dynamo = DynamoDBDocumentClient.from( DynamoDBclient, translateConfig );
    return dynamo;
}

const response = (statusCode, body) => {
    return {
        statusCode: statusCode,
        headers: {
            "Access-Control-Allow-Headers" : "Content-Type",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET"
        },
        body: JSON.stringify(
            body
        ),
    };
};

export const handler = async (event) => {
    try {
        const dynamo = initDynamoClient();
        const id = event.pathParameters.id;
        const dynamo_data = await dynamo.send(
            new GetCommand({
                TableName: "Dynamo_test",
                Key: {
                    id: id,
                },
            })
        );
        if (dynamo_data.Item === undefined) {
            return response(404, {"message": "not found."});
        } else {
            return response(200, dynamo_data.Item);
        }
    } catch (error) {
        console.log(error);
        return {
            statusCode: 500,
            headers: {
                "Access-Control-Allow-Headers" : "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET"
            },
            body: JSON.stringify({"message": "500err"}),
        };
    }
};
```

## テストコード
```javascript
import { handler as getItem } from "../get_dynamo.mjs";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { mockClient } from "aws-sdk-client-mock";

const ddbMock = mockClient(DynamoDBDocumentClient)

describe("handler get item", () => {
    beforeEach(() => {
        ddbMock.reset()
    })

    it("get item test", async () => {
        const event = {
            "pathParameters": {
                "id": "test"
            }
        };
        const expectValue = {
            Item: { id: "test", value: "memo" }
        };
        // すべてのGetCommandの結果を同じにしたい場合resolvesでOK
        // 1回目の呼び出しと2回目のレスポンスを変更したい場合resolvesOnceを使う
        ddbMock.on(GetCommand).resolves(expectValue)
        const result = await getItem(event)
        expect(result).toEqual({
            statusCode: 200,
            headers: {
                "Access-Control-Allow-Headers" : "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET"
            },
            body: JSON.stringify(
                {id:"test",value:"memo"}
            )
        })
        const callsOfGet = ddbMock.commandCalls(GetCommand);
        // コマンドが何回呼ばれたか検証
        expect(callsOfGet.length).toBe(1);
        // 指定されたパラメータで呼ばれたか検証
        expect(callsOfGet[0].args[0].input).toEqual({
            TableName: "Dynamo_test",
            Key: {
                "id": "test"    
            },
        });
    }

    it("item not found test", async () => {
        const event = {
            "pathParameters": {
                "id": "notfoundID"
            }
        };
        const expectValue = {
            Item: undefined
        };
        ddbMock.on(GetCommand).resolves(expectValue)
        const result = await getItem(event)
        expect(result).toEqual({
            statusCode: 404,
            headers: {
                "Access-Control-Allow-Headers" : "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET"
            },
            body: JSON.stringify(
                {"message": "not found."}
            ),
        });
    })

    it("handler response test", async () => {
        const event = {
            "pathParameters": {
                "id": "test"
            }
        };
        const expectValue = {
            Item: { id: event.pathParameters.id, value: "memo" }
        };
        ddbMock.on(GetCommand).resolves(expectValue)
        const result = await getItem(event)
        expect(result).toEqual({
            statusCode: 200,
            headers: {
                "Access-Control-Allow-Headers" : "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET"
            },
            body: JSON.stringify(
                expectValue.Item
            ),
        });
    })
})

describe("handler get item error test", () => {
    beforeEach(() => {
        ddbMock.reset()
    })

    it("handler error test", async () => {
        const event = {
            "pathParameters": {
                "id": "test"
            }
        };
        const expectValue = "ERR"
        // errを返すときはrejects
        ddbMock.on(GetCommand).rejects(expectValue)
        const result = await getItem(event)
        expect(result).toEqual({
            statusCode: 500,
            headers: {
                "Access-Control-Allow-Headers" : "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "GET"
            },
            body: JSON.stringify(
                {"message": "500err"}
            ),
        });
    })
})
```

## おまけ

### 時間を固定で設定する
```javascript
beforeEach(async () => {
    const fixed = new Date("2024-01-01T00:00:00");
    jest.useFakeTimers().setSystemTime(fixed.getTime());
});
```

### モジュールを雑にモック

```javascript
// fsモジュール全体をモック化
jest.mock("node:fs", () => ({
    ...jest.requireActual("node:fs"),
    writeFileSync: jest.fn(),
    existsSync: jest.fn(),
    readFileSync: jest.fn(),
}));
```
テストコード内で返り値設定
```javascript
// ディレクトリが存在すると仮定
fs.existsSync.mockImplementation(() => true);
fs.readFileSync.mockImplementation(() =>
    JSON.stringify([
        {
            name: "検証",
            Id: "00001",
        }
    ])
);
fs.writeFileSync.mockImplementation(() => {});
```

## 参考URL

- [こんなのがあったのか!? AWS SDK v3 Client mock](https://qiita.com/Kodak_tmo/items/724421fc2c9ca1091428)
- [aws-sdk-client-mockを使ってAWS SDK for JavaScript v3をモックする](https://dev.classmethod.jp/articles/mock-sdk-with-aws-sdk-client-mock/)
- [AWS JavaScript SDK v3 で jest を使う。aws-sdk-client-mock(-jest) の利用記録](https://qiita.com/baku2san/items/0d80f80c4d3a8c892df5)