AWS LambdaのテストでJestの恩恵を享受しまくろう

先月末、@t_wadaさんがLambdaの話をするということでAWS Dev Day Tokyo 2018に行ってきました。すごく良い話だったのでぜひスライドを見て欲しいと思います。

さて、このスライドに刺激を受けNode.jsランタイムでLambdaを書こう!となった際にテストツールは何を使うのが良いのでしょうか?
JavaScriptガチ勢の方であれば使い慣れたテストツールを好みに応じて組み合わせてやっていきましょう!でいいのですが、JavaScriptはそれほど...という方はおそらくまず「mocha?Jasmine?chai?power-assert?karma?え?何?どれ使えばいいの?」と、選択肢の多さや各ツールの守備範囲の違いに驚くのではないかと思います。
そこで、私はLambda(Node.js)のテストにもJestを推したいと思います。

なぜJestが良いのか

JestはFacebook製のテスティングフレームワークなので、「そもそもJestってReact向けのテストツールじゃないの?」という印象をお持ちの方も多いと思います。
実際にReactと相性のいいテスティングフレームワークであることは間違いないのですが、Node.jsのテストを書く際にもそのフルスタックフレームワークっぷりをいかんなく発揮してくれる非常に良い奴です。

良いところ1:Jestだけで完結している

Jestはフルスタックなテスティングフレームワークなので、Jestをインストールすればテストに必要な全ての機能を手に入れることができます。
テストフレームワークにmocha入れて、テストランナーにKarma入れて、アサーションはchaiかな?power-assertかな?と悩まなくてもJest一発で全てがまかなえます。
私たちは npm install -D jest したらすぐにテストを書き始めることができます。

良いところ2:機能が充実している

コードの変更を検知してテストを実行してくれるタスクランナー、見やすく整理されたテスト結果、使いやすいモック、並列実行による高速なテスト実行、カバレッジの算出、これらの全ての機能をJestは備えていますし、これだけでなく様々な機能を備えています。
私たちはJestひとつでこれらの機能を享受することができます。

良いところ3:公式が日本語ドキュメントをサポート

ドキュメントを日本語で読めるのはやはり使い始めるにあたって安心感がありますね。

こんな感じで良いところをあげていけばキリがないので、実際にJestを使ってLambdaのテストを書いていきます。

1.シンプルなLambdaのテストを書く

まずはこんな感じのシンプルなLambdaにテストを書いていきます。

module.exports.getUser = async (event, context) => {
  const { userId } = event.pathParameters;

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

このLambdaはAPI Gateway経由で呼ばれるものだとします。
ではまずはスライド同様contextオブジェクトにはaws-lambda-mock-contextを使用し、リクエストオブジェクトは見よう見まねで手書きします。

API Gateway経由時のリクエストオブジェクトはこんな形になりそうです。

module.exports = {
  resource: "/user/{userId}",
  path: "/user/hoge",
  httpMethod: "GET",
  headers: null,
  multiValueHeaders: null,
  queryStringParameters: null,
  multiValueQueryStringParameters: null,
  pathParameters: {
    userId: "123"
  },
  stageVariables: null,
  requestContext: {
    path: "",
    accountId: "",
    resourceId: "",
    stage: "",
    domainPrefix: "",
    requestId: "",
    identity: {
      cognitoIdentityPoolId: null,
      cognitoIdentityId: null,
      apiKey: "",
      cognitoAuthenticationType: null,
      userArn: "",
      apiKeyId: "",
      userAgent: "",
      accountId: "",
      caller: "",
      sourceIp: "",
      accessKey: "",
      cognitoAuthenticationProvider: null,
      user: ""
    },
    domainName: "",
    resourcePath: "/user/{userId}",
    httpMethod: "GET",
    extendedRequestId: "",
    apiId: ""
  },
  body: null,
  isBase64Encoded: false
};

こいつと aws-lambda-mock-context を使ってテストを書きます。
Jestはデフォルト設定で ___test__ ディレクトリ配下にあるファイルもしくは xxx.spec.js 、 xxx.test.js というファイルを自動的に読み込んでテストを実行してくれます。今回は spec/handler.spec.js というファイルを用意し、以下のようにテストを書きます。

const context = require("aws-lambda-mock-context");
const { getUser } = require("../handler");
const event = require("./event_data");

describe("handler.getUser()", () => {
  describe("When userId is 123", () => {
    it("returns a body with userId 123", async () => {
      const response = await getUser(event, context());
      expect(response.statusCode).toBe(200);
      expect(JSON.parse(response.body)).toEqual({ userId: "123" });
    });
  });
});

あとはJestのパッケージに用意されている jest コマンドを実行すればテストが実行されます。npm scriptに { "test": "jest" } と書いておけば、npm test でポンです。お手軽ですね!
ちなみに jest コマンドに --watch オプションをつけてあげれば、ファイルの変更を検知して自動でテストを再実行するデーモンを起動させることができます。nodemon のようなツールを用意する必要はありません。

2.モックを使ってLambdaのテストをする

1ではシンプルな例にしましたが、実際のLambdaはこんな感じのコードになっていることが多いと思います。

const UserRepository = require("./UserRepository");

module.exports.getUser = async (event, context) => {
  const { userId } = event.pathParameters;

  if (!userId) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: "Bad Request" })
    };
  }

  const userRepository = new UserRepository();
  const user = userRepository.findById(userId);

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

UserRepository は何らかのデータソースにアクセスするモジュールとします。こんな感じのコードにテストを書くとしたら、モックオブジェクトが欲しくなりますね。
Jestなら簡単にモックオブジェクトを作成できます。今回は私が好きなマニュアルモックという方法を使用します。

まずは UserRepository.js と同じディレクトリ階層に __mocks__ というディレクトリを用意し、ここにモックしたりモジュールと同名のファイルを用意します。今回の場合本物の UserRespository.js をルートディレクトリ直下に配置しているので、 ${ROOT_DIR}/__mocks__/UserRepository.js となります。
あとはテストコードに jest.mock("../UserRepository"); との行を追加すれば、これだけでテスト中の全ての UserRepository は __mocks__ 配下のファイルを読み込みに行くようになります。

この時点では __mocks__/UserRepository.js はただの空ファイルなので、UserRepositoryはモックとしての動作を行いません。モックとしての動作を行ってもらうために以下のようなコードを書いてあげます。

const mockFindById = jest.fn().mockReturnValue({
  id: "123",
  name: "mock user",
  age: 25
});

const mockUserRepository = jest.fn().mockImplementation(() => ({
  findById: mockFindById
}));

module.exports = mockUserRepository;

これで、呼び出されたUserRepositoryはコンストラクタを持ち、インスタンスメソッドに findById() を持ち、それは jest.fn().mockReturnValue() で指定された値を返す、という動きをするようになります。

このモックを使用して書いたテストがこちらです。

const _ = require("lodash");
const context = require("aws-lambda-mock-context");
const { getUser } = require("../handler");
const eventData = require("./event_data");

jest.mock("../UserRepository");

describe("handler.getUser", () => {
  describe("When userId is 123", () => {
    it("returns 200 and a body with user", async () => {
      const event = _.cloneDeep(eventData);
      const response = await getUser(event, context());
      expect(response.statusCode).toBe(200);
      expect(JSON.parse(response.body)).toEqual({
        user: expect.objectContaining({
          id: "123"
        })
      });
    });
  });

  describe("When userId is blank", () => {
    it("returns 400 with a message", async () => {
      const event = _.cloneDeep(eventData);
      event.pathParameters.userId = "";
      const response = await getUser(event, context());
      expect(response.statusCode).toBe(400);
      expect(JSON.parse(response.body)).toEqual({ message: "Bad Request" });
    });
  });
});

これで、テスト実行時にテスト対象のLambdaは本物のUserRepositoryではなく __mocks__ 配下にあるUserRepositoryを見に行くのでテストが通ります。
objectContaining() といった便利マッチャも存在しています。今回のように、テスト対象はモックだからオブジェクトが全体一致するかまでは確認する必要はないけど期待した値が含まれているオブジェクトかどうかは見たい、という場合に便利です。
また本筋とは若干ずれますが、リクエストパラメータの値をテストによって変えるにあたって、lodash の .cloneDeep() を使用しています。オブジェクトの参照は各テストで共有されるので、テストごとに書き換えるとオブジェクトの値が期待した値にならないからです。

さらに、モックオブジェクトがテスト中にどのように使用されたかをテストするコードも追加してみます。
今回の例だと、エラー時に UserRepository が本当に呼ばれていないかは確認しておきたい気がしますね。そんな場合はテストコード側でも UserRepository を読み込んであげると解決します。
コードは以下のようになります。

const _ = require("lodash");
const context = require("aws-lambda-mock-context");
const { getUser } = require("../handler");
const eventData = require("./event_data");
const mockUserRepository = require("../UserRepository");

jest.mock("../UserRepository");

beforeEach(() => {
  mockUserRepository.mockClear();
});

describe("handler.getUser", () => {
  describe("When userId is 123", () => {
    it("returns 200 and a body with user", async () => {
      const event = _.cloneDeep(eventData);
      const response = await getUser(event, context());
      expect(response.statusCode).toBe(200);
      expect(JSON.parse(response.body)).toEqual({
        user: expect.objectContaining({
          id: "123"
        })
      });
    });
  });

  describe("When userId is blank", () => {
    it("returns 400 with a message", async () => {
      const event = _.cloneDeep(eventData);
      event.pathParameters.userId = "";
      const response = await getUser(event, context());
      expect(response.statusCode).toBe(400);
      expect(JSON.parse(response.body)).toEqual({ message: "Bad Request" });
      expect(mockUserRepository).not.toBeCalled();
    });
  });
});

新たにエラー時に UserRepository が呼ばれていないことを確認するテストを追加しました。テストコード側でモックしたモジュールを読み込んであげるだけで、テスト対象のコードと実行状態を共有することができるので、このテストは通ります。

気をつけなければならないのは、 UserRepository の状態はテストごとに手動でリセットしてあげなければならないことです。
今回の例だと beforeEach() でリセットを行っていますが、これを行っていない場合、成功時に UserRepository が呼ばれ、その状態が残ったままエラー時のテストを行うので UserRepository が呼ばれたという判定になるためテストが落ちます。

このように、モックを使用した様々なテストを行えるようになりました。この方法は自前のモジュール以外にも使用することができます。
例えば、axios のモックを作成したい場合は、ルートディレクトリに __mocks__/axios.js を用意すると上記と同じ方法でモックを作成することができます。
HTTPクライアント等の外部モジュールを気軽にモックできるのはとても良いですね。
実際にプロダクションに上がっていくLambdaのコードはAWSの各種サービスにリクエストを送るようなものが多いので、お手軽なモック作成機構が用意されているのは本当に助かります。

終わりに

冒頭のスライドでも言及されていましたが、テストを書くことだけでなく、テストを元に自信をもってコードをリファクタし、より良いものにしていくことが大切です。
今回例にあげたコードもまだまだLambdaとの接触面が多いので、書いたテストコードを元により良い設計にしていく必要があります。
安全にきれいなコードを手に入れるためにも、まずは導入しやすいツールを選んで、どんどんテストを書いていきましょう!

この記事が気に入ったらサポートをしてみませんか?