ちょっと話題の記事

サーバーレスでもユニットテスト – TypeScript 製 AWS Lambda を Jest でテストする

2019.07.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

最近は Lambda Function を TypeScript で実装することが多く、テストツールとして Jest を選択しました。導入から基本的なテスト、カバレッジ出力までやってみたので、その手順を記録します。

ユニットテストのモチベーション

変更に対する心理的な安全性を手に入れるため、という理由が大きいです。

たとえば API Gateway のバックエンドを Lambda Function で実装する場合。実装だけであれば、可能な限り any 型を使用せず、 interfacetype の有効活用によりデータ型に起因する実行時エラーは大幅に少なくできます。 TypeScript を使うメリットのひとつですね。ではサーバーレスならではの難しいポイントはどこかというと、私の場合 前作った Lambda Function の挙動をすぐ忘れる ということがよくありました。それで、 Lambda Function を修正するとなった場合に、どのインタフェースをどんなふうに修正すればよいんだっけ…と迷子になりがちです。サーバーレスアプリケーションは要件や仕様を固めるために PoC 的に実装することも多く、ビジネス側の変更に追随する状況が多々あります。せっかく interface を修正して全体として動作することを確認したのに、その結果が仕様と一致していなかった…ということは避けたいですね。

そこで、先にテストケースを作っておいて、テスト側で得たい結果を修正、それに合致するよう実装コードを修正する方針だと心理的にありがたいと考えました。ちょうど考え方的にはTDDままですね。サーバーレスのユニットテストでカバーしたほうが良さそうな観点は以下だと考えています。

  • Lambda Function 入出力
  • AWSサービスの呼び出し

まず Jest を導入し、基本的な使い方をみていきます。次にこれらの観点についてどのような書き方をすればよいか記録します。

Jest の導入

Hello World の Lambda Function を用意

テスト対象のサーバーレスアプリケーションを用意しましょう。今回は私が作成したtsasというコマンドラインツールに Hello World の Lambda Function を作る機能がありますのでそれを使います。

$ npm install -g tsas
$ mkdir hello-jest
$ cd hello-jest
$ tsas init

What your serverless application name? [hello-jest]
What your serverless application nameSpace? used for S3 bucket prefix. [ns]
What your default region? [ap-northeast-1]
create to: /Users/wada.yusuke/Downloads/hello-jest
Initializing a new git repository...

$ tree -L 3
.
├── README.md
├── environments
│   └── stg
│       └── variables.json
├── package-lock.json
├── package.json
├── src
│   └── lambda
│       ├── domains
│       ├── handlers
│       └── infrastructures
├── templates
│   ├── dynamodb.yaml
│   └── lambda.yaml
├── tsas.config.json
├── tsconfig.json
└── webpack.config.js

これでひな型ができました。動作させる場合は、Parameter Store へのデータの送信や CloudFormation テンプレートによるデプロイが必要なのですが、今回はユニットテストを行うことが目的ですのでデプロイはしません。

Jest 導入

TypeScript のソースコードに対して、 TypeScript でテストを書きたいという前提に注意してください。

これらを参考に進めていきます。

インストール

npm i jest @types/jest ts-jest -D

  • jest: Jest 本体です
  • @types/jest: Jest の型情報です。テストコードを TypeScript で書くときに利用します
  • ts-jest: これにより TypeScript ソースコードに対するテストが可能になります

設定ファイル

Jest の設定ファイルを作成して記述します。主に、 TypeScript に対して Jest が動くよう設定します。

$ touch jest.config.js

jest.config.js

module.exports = {
    roots: ['<rootDir>/test', '<rootDir>/src'],
    transform: {
        '^.+\\.tsx?$': 'ts-jest',
    },
    testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};

  • roots: Jest がファイルを探索する対象を指定します。 TypeScript としては、src の中にソースコードとテストファイルが含まれている構成を推奨しているようですが、今回は分けてみます
  • transform: JavaScript ではないファイルに対して処理させるモジュールを指定します。今回は .ts.tsxファイルをさきほどインストールした ts-jest に任せます
  • testMatch: テスト対象とするファイルを指定します
  • moduleFileExtensions: Jest の公式ドキュメントにしたがって設定しました

テストコード

まずは書いてみます。ちょうどひな型のソースコードにオブジェクトを返すだけの関数があるので、それを public に変えてテストしてみます。

hello-world-use-case.ts

import { DynamodbGreetingTable } from '../../infrastructures/dynamo/dynamodb-greeting-table';

export class HelloWorldUseCase {

    public static async hello(userInfo: User): Promise<GreetingMessage> {
        console.log(userInfo);
        const message = HelloWorldUseCase.createMessage(userInfo);
        await DynamodbGreetingTable.greetingStore(message);
        return message;
    }

    static createMessage(userInfo: User): GreetingMessage {
        return {
            title: `hello, ${userInfo.name}`,
            description: 'my first message.',
        }
    }
}
export interface User {
    name: string;
}
export interface GreetingMessage {
    title: string;
    description: string;
}

次にテストコードです。2つ目のテストはあえて失敗するように書いています。

$ mkdir -p test/lambda/domains/greeting
$ touch test/lambda/domains/greeting/hello-world-use-case.test.ts

hello-world-use-case.test.ts

import { HelloWorldUseCase } from '../../../../src/lambda/domains/greeting/hello-world-use-case';

test('createMessage', () => {
    const expected = {
        title: 'hello, Bob',
        description: 'my first message.',
    };
    expect(HelloWorldUseCase.createMessage({name: 'Bob'})).toEqual(expected);
});

test('createMessage fail', () => {
    const expected = {
        title: 'hello, lambda',
        description: 'my second message.',
    };
    expect(HelloWorldUseCase.createMessage({name: 'Bob'})).toEqual(expected);
});

実行します。

$ npx jest
 FAIL  test/lambda/domains/greeting/hello-world-use-case.test.ts (5.537s)
  ✓ createMessage (4ms)
  ✕ createMessage fail (6ms)

  ● createMessage fail

    expect(received).toEqual(expected) // deep equality

    - Expected
    + Received

      Object {
    -   "description": "my second message.",
    -   "title": "hello, lambda",
    +   "description": "my first message.",
    +   "title": "hello, Bob",
      }

      14 |         description: 'my second message.',
      15 |     };
    > 16 |     expect(HelloWorldUseCase.createMessage({name: 'Bob'})).toEqual(expected);
         |                                                            ^
      17 | });
      18 |

      at Object.<anonymous>.test (test/lambda/domains/greeting/hello-world-use-case.test.ts:16:60)

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

意図通り、ひとつめのテストは成功し、ふたつめのテストは失敗していることが確認できました。導入OKです。

テスト対象の考察

Lambda Function は大きく3つのレイヤに分かれたレイヤ化アーキテクチャと相性がよいと考えています。

  • ハンドラ層: API Gateway などから入力を受け取り、バリデーションやオブジェクトの変換を行う
  • ドメイン層: ユースケースに対するビジネスロジックとインタフェースを定義する
  • インフラストラクチャ層: AWS SDK を利用した AWS サービスとのやりとりや外部APIへのアクセスを行う

この構成を考慮すると、ユニットテストは、以下2つに分けて行うのがよさそうです。

  • Lambda Function の入出力をテスト: ハンドラ + ドメインに対するテスト、インフラストラクチャモック化
  • AWSサービスの呼び出しテスト: インフラストラクチャに対するテスト、AWS SDK、HTTPクライアントモック化

上記の具体的な観点に対して Jest でどのように書いていくか見ていきます。

Lambda Function の入出力をテストする

再度ひな型のソースコードを例にとってみてみましょう。この Lambda Function も、3つの層を意識した構成になっているようです。テストする上でモジュールのモック化が必要になってくるのですが、その際、ソースコードのファイルが分かれていると都合がよいです。ひな型の Lambda Function も次のような形に分割されています。

ハンドラ層

import 'source-map-support/register';
import { GreetingMessage, HelloWorldUseCase, User } from '../../domains/greeting/hello-world-use-case';

export async function handler(event: User): Promise<GreetingMessage> {
    return HelloWorldUseCase.hello(event);
}

ドメイン(ユースケース)層

import { DynamodbGreetingTable } from '../../infrastructures/dynamo/dynamodb-greeting-table';

export class HelloWorldUseCase {

    public static async hello(userInfo: User): Promise<GreetingMessage> {
        console.log(userInfo);
        const message = HelloWorldUseCase.createMessage(userInfo);
        await DynamodbGreetingTable.greetingStore(message);
        return message;
    }

    private static createMessage(userInfo: User): GreetingMessage {
        return {
            title: `hello, ${userInfo.name}`,
            description: 'my first message.',
        }
    }
}

export interface User {
    name: string;
}

export interface GreetingMessage {
    title: string;
    description: string;
}

インフラ層

import { UpdateItemInput } from 'aws-sdk/clients/dynamodb';
import * as uuid from 'uuid';
import * as AWS from 'aws-sdk';
import { GreetingMessage } from '../../domains/greeting/hello-world-use-case';

const EnvironmentVariableSample = process.env.GREETING_TABLE_NAME!;
const Region = process.env.REGION!;

// アプリケーションではこのファイル内でしか使っていませんが、
// Jestのテストでモック化するため export しています
export const DYNAMO = new AWS.DynamoDB(
    {
        apiVersion: '2012-08-10',
        region: Region
    }
);

export class DynamodbGreetingTable {

    public static async greetingStore(greeting: GreetingMessage): Promise<void> {

        const params: UpdateItemInput = {
            TableName: EnvironmentVariableSample,
            Key: {greetingId: {S: uuid.v4()}},
            UpdateExpression: [
                'set title = :title',
                'description = :description'
            ].join(', '),
            ExpressionAttributeValues: {
                ':title': {S: greeting.title},
                ':description': {S: greeting.description}
            }
        };

        await DYNAMO.updateItem(params).promise()
    }

}

この Lambda Function は DynamoDB にデータを渡してテーブルに書き込み、書き込んだ内容を返します。入出力のテストとしては、入力値に対してインフラストラクチャ層のメソッドを意図どおり呼び出していることとと、戻り値のデータが意図どおりであることを確認すればよさそうです。さっそくテストを書いていきます。

$ mkdir -p test/lambda/handlers/api-gw
$ touch test/lambda/handlers/api-gw/api-gw-greeting.test.ts

api-gw-greeting.test.ts

import { User } from '../../../../src/lambda/domains/greeting/hello-world-use-case';
import { handler } from '../../../../src/lambda/handlers/api-gw/api-gw-greeting';
import { DynamodbGreetingTable } from '../../../../src/lambda/infrastructures/dynamo/dynamodb-greeting-table';

// jest.mock で対象のファイルをモック化します
jest.mock('../../../../src/lambda/infrastructures/dynamo/dynamodb-greeting-table');

describe('greeting Input/Output', (): void => {

    test('hello usecase', async () => {
        const inputEvent: User = {
            name: 'Emily'
        };

        // モック化したモジュールに対して、呼び出される関数ものについては戻り値を定義します
        const greetingStoreMock = (DynamodbGreetingTable.greetingStore as jest.Mock).mockResolvedValue(null);

        // 入力値とモックが準備できたら、 Lambda Function を実行します
        const response = await handler(inputEvent);

        const expected = {
            title: 'hello, Emily',
            description: 'my first message.',
        };

        // モック化した関数が1回だけコールされたことをテストします
        expect(greetingStoreMock.mock.calls.length).toBe(1);

        // 1回目の呼び出しのひとつめのパラメータが期待どおりに渡されていることをテストします
        // 結果的に、ユースケース内のオブジェクト変換処理のテストにもなっています
        expect(greetingStoreMock.mock.calls[0][0]).toEqual(expected);

        // レスポンスが期待どおりであることをテストします
        expect(response).toEqual(expected);
    });

});

これで、インフラストラクチャをモックにして入出力をテストする方法がわかりました。

AWSサービスの呼び出しをテストする

次にインフラストラクチャをテストします。観点は、「受け取ったパラメータをもとに、外部サービスを意図とおりに呼べているか」になりそうです。サーバーレスではなく、EC2上などにデータベースへアクセスするタイプのフレームワークを使って開発する場合は、ダミーサービスをたてテストする手法も効果的です。サーバーレスにおいても localstack などを使ってダミーサービスを用意したほうがよのではないか、と考えましたが、以下理由によりユニットテストの範囲では考えなくてよいという意見です。

  • localstack はすべての AWSサービスに対応しているわけではない。対応していないサービスを利用する場合は自前でダミーサービスを用意するかモック化する必要がある。ユニットテストにそこまでのコストをかけたくない
  • ダミーサービスは必ずしも実際のサービスと同等の挙動とは限らない。気付いたらダミーサービスをがんばってデバッグしていた…という事態は避けたい

このことから、実際のサービスを使ってのテストはE2Eテストに任せる方針がよいと考えています。これを踏まえてユニットテストを書いていきましょう。ひな型の Lambda Function は DynamoDB にアクセスしていましたので、ここをテストしてみます。

$ mkdir -p test/lambda/infrastructures/dynamo
$ touch test/lambda/infrastructures/dynamo/dynamodb-greeting-table.test.ts

dynamodb-greeting-table.test.ts

// ① - ダミーの環境変数を用意します
process.env.GREETING_TABLE_NAME = 'local-greeting';
process.env.REGION = 'local';

import { GreetingMessage } from '../../../../src/lambda/domains/greeting/hello-world-use-case';
import { DYNAMO, DynamodbGreetingTable, } from '../../../../src/lambda/infrastructures/dynamo/dynamodb-greeting-table';


describe('greeting table service call', (): void => {

    test('greetingStore', async () => {

        // ② - DynamoDB のSDKをモック化します
        DYNAMO.updateItem = jest.fn().mockReturnValue({
            promise: jest.fn().mockResolvedValue(null)
        });

        const parameterInput: GreetingMessage = {
            title: 'hello, Emily',
            description: 'my first message.',
        };

        // インフラのコード(保存処理)を実行します
        await DynamodbGreetingTable.greetingStore(parameterInput);

        // モック化した関数が1回だけコールされたことをテストします
        expect(DYNAMO.updateItem).toHaveBeenCalledTimes(1);

        // よびだしでのパラメータが期待どおりに渡されていることをテストします
        expect(DYNAMO.updateItem).toHaveBeenCalledWith({
            TableName: 'local-greeting',
            Key: {greetingId: {S: expect.any(String)}}, // ③ - uuid は型をチェックします
            UpdateExpression: [
                'set title = :title',
                'description = :description'
            ].join(', '),
            ExpressionAttributeValues: {
                ':title': {S: parameterInput.title},
                ':description': {S: parameterInput.description}
            }
        });
    });
});

いくつかピックアップします。

  • (1): 環境変数の設定: テスト対象のコードが環境変数を関数の外で読み出している場合、ファイルがロードされた瞬間に環境変数がセットされます。ですのでインポートする前に環境変数をセットしています
  • (2): SDKをモック化: Lambda Function 入出力のテストではインフラストラクチャファイル全体をモックにしましたが、AWS SDK は利用する関数ピンポイントでモックにしてみました。こういう書き方もあります
  • (3): ランダム値のテスト: uuid など期待値が事前に設定できない場合は、expect.any(String) のように書くことで型のみチェックできます

テスト実行可能です。

$ npx jest test/lambda/infrastructures/dynamo/dynamodb-greeting-table.test.ts
 PASS  test/lambda/infrastructures/dynamo/dynamodb-greeting-table.test.ts (5.813s)
  greeting table service call
    ✓ greetingStore (6ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        7.395s

呼び出し回数やパラメータが異なる場合は怒ってくれます。

呼び出し回数が異なる https://devio2023-media.developers.io/wp-content/uploads/2019/07/invalid_call_number.png

渡しているパラメータの型が異なる https://devio2023-media.developers.io/wp-content/uploads/2019/07/invalid_type.png

SDK をモック化して、インフラストラクチャ層をテストできました。

カバレッジを出力する

せっかく機能があるのでカバレッジ出力までやってみましょう。HTMLやJSONでも出力できますが、ここでの目的を 開発者が自分の書いたテストコードによるカバレッジを確認する にとどめます。コンソールへのテキスト出力ができればOKです。jest.config.js を編集して、テスト実行時のコマンドにオプションをつけることで実現できます。

jest.config.js

module.exports = {
    roots: ['<rootDir>/test', '<rootDir>/src'],
    transform: {
        '^.+\\.tsx?$': 'ts-jest',
    },
    testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
    collectCoverage: true,
    collectCoverageFrom: [
        '**/*.{ts,tsx}',
        '!**/node_modules/**',
        '!**/tests/**',
    ],
    coverageReporters: ['text'],
};

node_modulestestフォルダを除外した.ts, tsxファイルをカバレッジ収集対象にする、という内容です。この上で以下を実行します。

$ npx jest --coverage

 PASS  test/lambda/infrastructures/dynamo/dynamodb-greeting-table.test.ts (5.919s)
 PASS  test/lambda/handlers/api-gw/api-gw-greeting.test.ts (11.107s)
  ● Console

    console.log src/lambda/domains/greeting/hello-world-use-case.ts:184
      { name: 'Emily' }

-----------------------------|----------|----------|----------|----------|-------------------|
| File                          | % Stmts    | % Branch   | % Funcs    | % Lines    | Uncovered Line #s   |
| ----------------------------- | ---------- | ---------- | ---------- | ---------- | ------------------- |
| All files                     | 100        | 100        | 100        | 100        |                     |
| domains/greeting              | 100        | 100        | 100        | 100        |                     |
| hello-world-use-case.ts       | 100        | 100        | 100        | 100        |                     |
| handlers/api-gw               | 100        | 100        | 100        | 100        |                     |
| api-gw-greeting.ts            | 100        | 100        | 100        | 100        |                     |
| infrastructures/dynamo        | 100        | 100        | 100        | 100        |                     |
| dynamodb-greeting-table.ts    | 100        | 100        | 100        | 100        |                     |
| ----------------------------- | ---------- | ---------- | ---------- | ---------- | ------------------- |

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        12.6s, estim

実装がシンプルなのも手伝って今回はカバレッジ100%でしたね。どの程度網羅しているかで、ユニットテストの質を見極める材料になりそうです。

まとめ

シンプルな Lambda Function に対して、Jestでユニットテストを書く例を書きました。これで、 Lambda Function を修正したいとなった場合、要件や仕様に対してまずテストコードを修正することで(心理的)安全に本体を修正できます。Jestにはまだまだたくさん機能がありますし、いろいろなテストがかけそうですね。カバレッジも出力できるのでユニットテストを行う分には困らないのではないでしょうか。

サーバーレスのテストについてさまざまな議論がありますが、私は次のような意見を持っています。

  • サーバーレスアプリケーションはほぼ確実に外部サービスとのやりとりを行うことになるが、ユニットテストにおいては外部サービスをテストツールでモック化する方向に統一するのがよい。あくまでロジックのテストに集中するため
  • 外部サービスとのやりとりの確認はE2Eテストに任せる
  • テスト対象は、Lambda Function の入出力観点と、外部サービスとのやりとりの観点に分けるとわかりやすい

今度はサーバーレスのE2Eテストも Jest で書いてまとめてみます。

参考