⛸️

【aws-sdk-client-mockでDynamoDBモックを作成!】Lambda with TypeScriptをjestでテストする

2024/05/18に公開

この記事で解決すること

  • TypeScriptで書かれた[DynamoDB操作を行うLambda関数]のテストコードを書く流れ
  • テストのためのDynamoDBテーブル・データを用意する方法

前提の補足

例えばsam initを用いてプロジェクトを初期化した際にTypeScript使用を指定するとあらかじめjestを実行できるようにセットアップされています。
今回はそういったセットアップがない状態、0からのテスト環境構築を想定しています。

環境

  • Node.js v20
  • AWS SDK for JavaScript v3
ディレクトリ構成
├── jest.config.js
├── package-lock.json
├── package.json
├── src
│   ├── app.ts
├── tests
│   ├── app.test.ts
│   └── events
│       └── scan-event.ts
└── tsconfig.json

アジェンダっぽいもの

まず結論

jest / ts-jest / aws-sdk-client-mock を使用することで簡単にテストを実行できる

話す流れ

  1. 必要なパッケージおよび設定ファイルについて
  2. テスト対象のコードを見る
  3. テストコードを見る
  4. テストコードの解説
  5. テストコードを書く流れ

話さないこと・考慮していないこと

テストコードのベストプラクティス

必要なパッケージのインストール

こちらのドキュメントを参考に進めます。
https://jestjs.io/ja/docs/getting-started

npm install --save-dev jest

TypeScriptを使用する場合、Babelもしくはts-jestを経由する必要があります。

Babelを使う

npm install --save-dev babel-jest @babel/core @babel/preset-env
npm install --save-dev @babel/preset-typescript

設定ファイル(babel-config.js)を書く

babel.config.js
module.exports = {
    presets: [
        ['@babel/preset-env', {targets: {node: 'current'}}],
        '@babel/preset-typescript',],
};

ts-jestを使う

npm install --save-dev ts-jest

@jest/globalsまたは@types/jestをインストールしてテストコードをTypeScriptで書く

jestで使われる型定義されたAPIを使用するために@jest/globalsをインストールします。

npm install --save-dev @jest/globals

こちらをインストールしないと、jestのための記述で型エラーが発生。
【例:以下のdescribe()など】

import {describe, expect, test} from '@jest/globals';
import {sum} from './sum';

describe('sum module', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

[参考]:https://jestjs.io/ja/docs/getting-started#type-definitions

または、@types/jestをインストールするとインポートすることなく型が解決されます。

npm install --save-dev @types/jest
// describe等のimport文は不要になる
import {sum} from './sum';

describe('sum module', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

Babel VS ts-jest

Babelはあくまでコンパイラとしての役割です。Jestもまたテストコードの型チェックを行わないません。
型チェックを行いたい場合はts-jestを使用します。(もしくはTypeScriptコンパイラであるtscを別途で使用する。
[参考]:https://jestjs.io/ja/docs/getting-started#babel-経由で

設定ファイル(jest.config.js)

最低限、以下の記述をします。

jest.config.js
module.exports = {
    "roots": [
        "<rootDir>/tests/",
    ],
    "transform": {
        "^.+\\.ts": ["ts-jest", {
            tsconfig: "tsconfig.json",
        }],
    },
    "testMatch": [
        "<rootDir>/tests/**/*.test.ts",
    ],
}

roots

jestがテストファイルを検索するルートディレクトリを指定。

transform

ファイルの変換方法を指定。
^.+\\.ts.tsで終わる全てのファイルをマッチさせ、ts-jestによりtsconfig.jsonファイルを用いてTypeScriptコンパイルするという方法を指定しています。

testMatch

どのファイルをテストファイルとして扱うか指定。

package.jsonにscriptsを追加

npm run testで実行できるようにpackage.jsonも記述します。

package.json
    "scripts": {
        "test": "jest"
    },

テストコードを書く準備

ここまではjestの設定でした。
早速テストコードを書いてみます。
※今回はts-jestを用いて進めていきます

テスト対象のコード

DynamoDBのテーブルからデータをscanしてそのまま返すlambda関数を例として作成します。

src/app.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { DynamoDBClient, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, ScanCommand, ScanCommandInput, ScanCommandOutput } from "@aws-sdk/lib-dynamodb";

const options: DynamoDBClientConfig = {
    endpoint: process.env.DYNAMODB_ENDPOINT
}

const docClient = DynamoDBDocumentClient.from(new DynamoDBClient(options));

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    const params: ScanCommandInput = {
        TableName : process.env.TABLE_NAME
    };
    const command = new ScanCommand(params);
    const result: ScanCommandOutput = await docClient.send(command);

    return {
        statusCode: 200,
        body: JSON.stringify(result.Items),
    };
}

ここで私が疑問に思ったことと答え(DynamoDBのモック)

DynamoDB処理を行う関数をテストしたいが、テストのためのデータをどのように用意するのだろう?どこにどうテーブルを用意するのだろう?モックの使い方は?と思いました。

Lambda関数でDynamoDB操作を行うためにAWS SDKを使用します。SDKを用いた操作をテストするために、aws-sdk-client-mockというパッケージがあります。

インストールする

npm install --save-dev aws-sdk-client-mock

参考

aws-sdk-client-mockはAWS SDK For JavaScript v3のためのオープンソースライブラリです。
https://github.com/m-radzikowski/aws-sdk-client-mock
AWS公式ブログで紹介されていました!
https://aws.amazon.com/jp/blogs/developer/mocking-modular-aws-sdk-for-javascript-v3-in-unit-tests/

jestとaws-sdk-client-mockを用いてテストコードを書く

まずはテストコード

app.test.ts
import { mockClient } from 'aws-sdk-client-mock';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
import { scanEvent } from './events/scan-event';
import { handler } from '../src/app';
import { APIGatewayProxyResult } from 'aws-lambda';

const ddbMock = mockClient(DynamoDBDocumentClient);

const items = JSON.parse(`{
    "Items": [
        {
            "id": "1",
            "name": "John",
            "age": 30
        },
        {
            "id": "2",
            "name": "Jane",
            "age": 25
        }
    ]
}`);

beforeAll(() => {
    process.env.TABLE_NAME = 'hoge-table';
});

beforeEach(() => {
    ddbMock.reset();
});

describe('handlerのテスト', () => {
    it('hoge tableをscanする', async () => {
        ddbMock.on(ScanCommand, { TableName: 'hoge-table' }).resolves({ Items: items });

        const response = await handler(scanEvent) as APIGatewayProxyResult;
        expect(response.statusCode).toBe(200);
        expect(JSON.parse(response.body)).toEqual(items);
    });
});

解説

aws-sdk-client-mockmockClient を用いてモックを初期化する

この一行でモック構築完了!

const ddbMock = mockClient(DynamoDBDocumentClient);

itemsにモックデータを定義

テストで使用するデータを定義します。

items
const items = JSON.parse(`{
    "Items": [
        {
            "id": "1",
            "name": "John",
            "age": 30
        },
        {
            "id": "2",
            "name": "Jane",
            "age": 25
        }
    ]
}`);

テスト前の処理

beforeAll()は全てのテストの前に一度だけ実行されます。
ここでは、テスト対象のコード内で使用している環境変数設定のために使用。

beforeAll
beforeAll(() => {
    process.env.TABLE_NAME = 'hoge-table';
});

beforeEach()は各テスト前に実行されます。モックをリセット。

beforeEach
beforeEach(() => {
    ddbMock.reset();
});

テストスイート定義

describe()でテストスイートを定義。グループ化しています。
it()で各テストケースを定義。

describe('handlerのテスト', () => {
    it('hoge tableをscanする', async () => {
        ddbMock.on(ScanCommand, { TableName: 'hoge-table' }).resolves({ Items: items });

        const response = await handler(scanEvent) as APIGatewayProxyResult;
        expect(response.statusCode).toBe(200)
        expect(JSON.parse(response.body)).toEqual(items);
    });
});

一行ずつ見ていきましょう。

モック設定
ddbMock.on(ScanCommand, { TableName: 'hoge-table' }).resolves({ Items: items });

ここではまずモックを設定しています。ScanCommandがhoge-tableに対して実行された時に、上記で定義したitemsが返されるように設定。

関数の実行
const response = await handler(scanEvent) as APIGatewayProxyResult;

テスト対象コードのhandler関数を呼び出し、引数scanEventには実際に渡すイベントを指定。
今回は別ファイルで定義したjsonデータを使用しています。(ローカル実行時のログを参考に作成)

event.jsonの定義
scan-event.ts
import { APIGatewayProxyEvent } from "aws-lambda"

export const scanEvent: APIGatewayProxyEvent = {
    "body": null,
    "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate, br",
        "Connection": "keep-alive",
        "Host": "127.0.0.1:3000",
        "User-Agent": "",
        "X-Forwarded-Port": "3000",
        "X-Forwarded-Proto": "http"
    },
    "httpMethod": "GET",
    "isBase64Encoded": false,
    "multiValueHeaders": {
        "Accept": ["*/*"],
        "Accept-Encoding": ["gzip, deflate, br"],
        "Connection": ["keep-alive"],
        "Host": ["127.0.0.1:3000"],
        "X-Forwarded-Port": ["3000"],
        "X-Forwarded-Proto": ["http"]
    },
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "queryStringParameters": null,
    "requestContext": {
        "accountId": "123456789012",
        "apiId": "1234567890",
        "domainName": "127.0.0.1:3000",
        "extendedRequestId": null,
        "httpMethod": "GET",
        "identity": {
            "accountId": null,
        "apiKey": null,
        "caller": null,
        "cognitoAuthenticationProvider": null,
        "cognitoAuthenticationType": null,
        "cognitoIdentityPoolId": null,
        "sourceIp": "127.0.0.1",
        "user": null,
        "userAgent": "Custom User Agent String",
        "userArn": null
        },
        "protocol": "HTTP/1.1",
        "requestId": "64a55bae-4d11-43f9-96f4-e21ae01b6b2d",
        "resourceId": "123456",
        "resourcePath": "/hoge",
        "stage": "Prod"
    },
    "resource": "/hoge",
    "stageVariables": null
} as any
テスト結果の検証
expect(response.statusCode).toBe(200)
expect(JSON.parse(response.body)).toEqual(items);

expect()で結果を検証します。

テストの実行結果

$ npm run test

> test
> jest

 PASS  tests/app.test.ts
  handlerのテスト
    ✓ hoge tableをscanする (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.025 s
Ran all test suites.

テストコードの流れをまとめてみる

テストのためにDynamoDBテーブルおよびデータを用意することなく、aws-sdk-client-mockを使用してDynamoDB操作を行うLambda関数のテストができました。

構築したモッククライアントに対して対象の操作(ScanCommand)が行われた時の結果を設定。
テスト対象の関数を呼び出し・実行。この関数内ではScanCommandを実行しており、モックに設定した結果が返却されます。

  1. DynamoDB操作時の挙動をモックに対して設定
  2. 関数を実行
  3. 結果の検証

という流れです。
今回は簡単なScan操作のみテストだったのでモック設定のためのデータ=期待されるデータ(items)となりましたが、実際にさらに複雑な関数をテストする場合は別となるケースも多々あると考えられます。

  • モック設定のためのデータ
  • 期待されるデータ

私はテストコードの書き方を調査している際は理解に苦しみましたが、この二つを切り離して考えることで他のテストコードにも展開できるよう流れを理解することができました。

まとめ

サーバーレス構成でのテストコードを初めて書きました。(まだまだテストコード自体書き慣れていませんが・・・)
AWS上はもちろんローカル (※dynamodb-local)にデータを用意することも難しく、AWSリソースへの操作をどうテストするのだろう?と悩みましたが、aws-sdk-client-mockで簡単にモック構築ができてとても便利でした。
モックに設定するデータやイベントデータの用意さえできればテスト用サーバーやコンテナなどを準備する必要がなく、サーバーレス開発らしく手軽にテストも実行できます。
本格的にテストコードを書いていくにあたって懸念点やベストプラクティスは何か?といった問題も出てくるので、さらに学んでいきたいです。

GitHubで編集を提案
Fusic 技術ブログ

Discussion