AWSサーバーレス単体テスト実践ができた

## はじめに

[株式会社ACCESS Advent Calendar 2022](https://qiita.com/advent-calendar/2022/access) 17日目の記事です。

入社してから、基本的にはAWSでサーバーレス（APIGateway + Lambda）でのサーバー開発をしてきました。
最近AWS Lambdaとサーバーレス関連のテスト方法を調べていました。ようやく個人的に安心できる単体テスト・結合テストが実践できたと思っています。この辺りをまとめた記事になります。

## 背景

AWS Lambdaのhandlerのテストで、AWSサービス（例えばDynamoDB）との連携部分をモックして単体テストを実装していました。モックすることでhandlerの多くの部分はテストできていると思えていたのですが、モックした部分のコードがテストできていなくて大丈夫かなと思うようになりました。

テストが足りていなくて不安ならテストを追加すればいいので、参考資料のようなテストを書こうと思いましたが、

1. そもそも内容がわからなかった
2. コード例を見つけられなかった

ためうまく実践できず、今まではE2EテストやAPIテストで対応していました。

ようやく参考資料の内容が理解でき、個人的に安心できるテストを作成できるようになったので、コードを含めて記録を残すことがこの記事の目標になります。

## 現状の課題

記事では、handlerと単体テストのコードを扱います。cdkの設定やLambdaでのnpmパッケージ利用設定は以下のリポジトリで公開しています。

https://github.com/aShunyaOtsuki/serverless-test

簡単のために、フラグによって送信するかの判定をする以下のLambdaのhandlerをテスト対象にします。

```ts:lambda/alert/index.ts
import { APIGatewayProxyEventV2 } from "aws-lambda";
import { IUserTable, UserTable } from "common/UserTable";
import { INotifyClient, NotifyClient } from "common/NotifyClient";

export const main = async (
  userId: string,
  client: { userTable: IUserTable; notifyClient: INotifyClient }
): Promise<void> => {
  const { userTable, notifyClient } = client;

  // NOTE: レコードのフラグによって、特定の機能を実行する。今回のアプリケーションの主な機能。
  const userRecord = await userTable.getItem(userId);
  if (userRecord.isNotifyAlert) {
    await notifyClient.notifyMessage({
      mailAddress: userRecord.mailAddress,
      content: "alert",
    });
  }
};

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<string> => {
  console.log(event);
  try {
    const userTable = UserTable();
    const notifyClient = NotifyClient();

    const userId = event.rawPath.replace("/", "");
    await main(userId, { userTable, notifyClient });
    return "SUCCESS";
  } catch (e) {
    console.error(e);
    return "ERROR";
  }
};
```

全て言葉で説明すると、以下のようになります。
1. Lambda内でDynamoDBからUserRecordを取得する。
1. isNotifyAlertフラグを確認する。
1. アラート通知が可能であれば、SQSへメッセージを送信する。（`notifyClient.notifyMessage`の部分）

![test1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/825263/b1a05340-2f37-0e3b-15a2-bb5d693be8cf.png)

単体テストでは、`userTable`と`notifyClient`をダミーにすることで、isNofityAlertフラグでの処理分岐をテストしています。

```ts:test/alert/index.test.ts
import { main } from "../../../lambda/alert";
import { IUserTable } from "common/UserTable";
import { INotifyClient, NotifyMessage } from "common/NotifyClient";

const fn = jest.fn();

const dummyUserRecords = [
  { id: "test1", mailAddress: "test1-mailAddress", isNotifyAlert: true },
  { id: "test2", mailAddress: "test2-mailAddress", isNotifyAlert: false },
];
const dummyUserDB: IUserTable = {
  getItem: async (id: string) => {
    return dummyUserRecords.find((record) => record.id === id)!;
  },
};
const dummyNotifyClient: INotifyClient = {
  notifyMessage: async (message: NotifyMessage) => {
    fn(message);
  },
};

describe("alert api", () => {
  beforeEach(() => {
    fn.mockReset();
  });
  test.each([
    ["test1", 1],
    ["test2", 0],
  ])("norify alert", async (userId, expected) => {
    await main(userId, {
      userTable: dummyUserDB, // NOTE: ダミーのDBへ接続しています。
      notifyClient: dummyNotifyClient,
    });
    expect(fn).toBeCalledTimes(expected);
  });
});
```

実際にDynamoDBを操作できるのかどうかは、APIかLambdaのテストを通して確認していました。最終的にはIAM等の設定を確認する必要があるので、APIテストは必須です。しかしDynamoDB操作部分にもテストがあれば個人的安心感が高まるはずで、どうすればいいのか困っていました。

![test2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/825263/676c3d5f-fd26-a912-7d55-acd1defbee6a.png)

## 今回の手段

解決策としては、

1. DB操作のみを実行するtestLambdaを作成する。
1. testLambdaを実行する。

という方法で安心できました。
（参考資料を個人的に解釈した結果です。逆転の発想だったので「すごいなぁ」と思いました。）

![test4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/825263/ee5bedd8-7457-6810-8944-186ebc6073ea.png)

```ts:lambda/testHandler/db.ts
import { UserTable, UserRecord } from "common/UserTable";

const testRecord = (record: UserRecord) => {
  return (
    record.id === "test-id" &&
    record.isNotifyAlert === true &&
    record.mailAddress === "test-mailAddress"
  );
};

export const handler = async () => {
  const userTable = UserTable();
  const record = await userTable.getItem("test-id");
  const isSuccess = testRecord(record);
  // 通知先はどこでもOK。今回はクライアントに返している。
  return isSuccess ? "Success" : "Failure";
};
```

今後のために、どのような検討をしていたかまとめておきます。

* LocalStack: https://localstack.cloud/
* DynamoDBローカル: https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.html
* Dockerで何かする。
* dynalite: https://github.com/mhart/dynalite
* jest-dynalite: https://github.com/freshollie/jest-dynalite
* jest-dynamodb: https://github.com/shelfio/jest-dynamodb

いずれの方法も、単体テストのモックをなるべくDynamoDBに近づけることでそれなりに安心しようという方針でした。今回の気づきは、DynamoDBとの結合テストをLambdaで実施することによって、単体テストでのモック方法がDynamoDBとは異なっても安心感は減らないという点でした。

## 今後の改善点

- CIで対応できればいいのですが、例えばDynamoDBにGSIを追加した時にはテスト失敗したままデプロイorテスト追加せずにデプロイが必要になりそうなので、回避策検討中です。
- `yarn cdk deploy`を実行した時にtestLambdaが自動で実行されて欲しいのですが、いいLambdaトリガーを調査中です。

## 参考資料

１. 2021/12/5 2021年版、サーバーレスのテスト手法を考える
この記事が、今回の不安を感じた原因であり安心できる方法を見つけられたきっかけでもあります。

https://qiita.com/advent-calendar/2021/lambda

２. 今読んでいる本です。パターンが良さそうだったので参考にしています。

https://www.shoeisha.co.jp/book/detail/9784798150727

---

明日は @aqua_ix さんです！