AWS CDKv2 でスナップショットテストと CI を始めてみよう

2021.12.13

これは CDK Advent Calendar 2021 の13日目のエントリーです。
こんにちは、坂井(ore88ore)です。

CDK v2 GA となりましたねー。今後も積極的にバージョンアップに追従したいけど、既存の機能に影響がないだろうか、システムが正しく動くだろうか・・・などの不安があり、なかなか最新のバージョンに追従できないと思っている方も多いのではないでしょうか。 そういった不安を少しでも和らげるために、簡単に始められるスナップショットテストと CI の導入について紹介させていただきます。

作成するアーキテクチャ

今回作成するアーキテクチャとなります。API Gateway、Lambda、DynamoDB を使ったサーバレス API をテスト対象のリソースとし、CI は GitHub Actions を利用して、プルリクを作成したタイミングでスナップショットテストが実行されるように設定します。また、言語は TypeScript を利用し、テスティングフレームワークは Jest を利用します。

実行環境

  • AWS CDK 2.0.0
  • TypeScript 3.9.10
  • Jest 26.6.3

実装してみる

さっそく実装していきます。まずは、利用するリソースを定義し、そのリソースが定義されているスタックに対してスナップショットテストを実行します。 また、リソースの更新に合わせてスナップショットを更新して、変更に追従します。 最後に、スナップショットテストがプルリク作成時に自動で起動するように GitHub Actions を設定します。

リソースを定義する

Construct を使って、利用するリソースを定義していきます。今回は、DynamoDB, Lambda, API Gateway を定義します。また、サンプルの実装ですので、DynamoDB のテーブルはスタックを削除する際に、一緒に削除されるように RemovalPolicy.DESTROY を設定しています。

lib/cdk-snapshot-test-sample-stack.ts

import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { BillingMode } from "aws-cdk-lib/aws-dynamodb";
import * as apigateway from "aws-cdk-lib/aws-apigateway";

export class CdkSnapshotTestSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // DynamoDB
    const sampleTable = new dynamodb.Table(this, "sampleTable", {
      partitionKey: { name: "id", type: dynamodb.AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // Lambda
    const sampleLambda = new NodejsFunction(this, "sampleLambda", {
      entry: "lib/sample-lambda.handler.ts",
      environment: {
        TABLE_NAME: sampleTable.tableName,
      },
    });
    sampleTable.grantReadData(sampleLambda);

    // API Gateway
    const sampleApi = new apigateway.RestApi(this, "sample-api");
    sampleApi.root.addMethod(
      "GET",
      new apigateway.LambdaIntegration(sampleLambda)
    );
  }
}

Lambda 関数の実装です。DyanamoDB のテーブルを参照し、その中身を返すだけのシンプルな実装となります。

lib/sample-lambda.handler.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { DocumentClient } from "aws-sdk/clients/dynamodb";

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const client = new DocumentClient({ region: "ap-northeast-1" });
  const res = await client
    .scan({
      TableName: process.env.TABLE_NAME as string,
    })
    .promise();
  const responseBody = {
    samples: res.Items ?? [],
  };

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

定義したリソースをデプロイする

定義したリソースが想定した動きとなるか、実際にデプロイして試してみます。

cdk deploy

デプロイ後、デプロイされたリソースを CloudFormation で確認すると、必要なリソースが作成されていることを確認できます。今回はリソースの定義に L2 Construct を利用しているので、必要なリソースが一式作成されていますね。

続いて作成されているリソースが正しく動くか確かめてみます。今回は、API Gateway のエンドポイントを叩くと、DynamoDB のテーブルのデータが返却されることが期待値となります。API Gateway のエンドポイントは、API Gateway をマネコンで表示して、確認することもできますし、デフォルト設定だと CloudFormation の出力でも確認することができます。

適当なデータを DynamoDB のテーブルに作成し、エンドポイントを叩いてみます。

aws dynamodb put-item \
--table-name [テーブル名] \
--item '{"id": {"S": "id1"}, "name": {"S": "Sample Name"}}'

curl -X GET https://XXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/prod/ | jq
{
  "samples": [
    {
      "id": "id1",
      "name": "Sample Name"
    }
  ]
}

想定している動きになっていることを確認できましたので、リソース定義は良さそうですね。

スナップショットテストを書く

続いて、定義したリソースに対して、スナップショットテストを作成していきます。スナップショットテストは、事前に記録されたスナップショット(今回は CloudFormation のテンプレートにあたる)に対して、テスト実行時の実装から作成した CloudFormation のテンプレートを検証(比較)します。ここで、差分がなければテスト結果OK、差分があればテスト結果NG となります。

今回は Jest を使ってテストを実行しますので、Jest の書き方に沿って、実装してみます。

test/cdk-snapshot-test-sample.test.ts

import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { CdkSnapshotTestSampleStack } from "../lib/cdk-snapshot-test-sample-stack";

test("snapshot test", () => {
  const app = new cdk.App();
  const stack = new CdkSnapshotTestSampleStack(app, "MyTestStack");
  // スタックからテンプレート(JSON)を生成
  const template = Template.fromStack(stack).toJSON();

  // 生成したテンプレートとスナップショットが同じか検証
  expect(template).toMatchSnapshot();
});

今回リソースを定義したスタックからテンプレートを作成する際は、すべての言語がサポートされている aws-cdk-lib/assertions モジュールを利用して、作成しています。こちらのモジュールは、比較的新しいモジュールで、以前は @aws-cdk/assert モジュールの SynthUtils.toCloudFormation ファンクションを利用してテンプレートを生成していましたが、公式のドキュメントでも aws-cdk-lib/assertions モジュールを利用したサンプルとなっていました。

スタックからテンプレートを生成して、スナップショットとマッチするかを検証しています。なお、初回実行時は、比較対象のスナップショットが存在しないので、スナップショットが __snapshots__ ディレクトリに作成されます。

以下のコマンドを実行して、初回テストを実行してみます。

npm test
...
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
...

初回テスト(スナップショットがない)なので、スナップショットが作成されました。

tree test     
test
├── __snapshots__
│   └── cdk-snapshot-test-sample.test.ts.snap
├── cdk-snapshot-test-sample.test.d.ts
├── cdk-snapshot-test-sample.test.js
└── cdk-snapshot-test-sample.test.ts

今度は、スナップショットがある状態でテストを実行してみます。

npm test
...
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
...

先程作成したスナップショットとスタックのテンプレートが同じ状態ですので、テストが成功していることがわかります。 これで今回作成したリソースに対してのスナップショットテストを実装することができました。

スナップショットを更新する

続いて、スナップショットを更新してみます。一度作成したリソースが一切変更されず、CDK のバージョンも更新しないというのであれば、スナップショットも変わらないのですが、そういったシステムはなかなかないのでは?と思います。 システムがグロースしていく中で、リソースも追加・変更・削除されるので、その際に以前保存したスナップショットを更新する必要があります。

今回は、以下のような変更を実施する想定で、スナップショットを更新していきます。

  • CloudFormation の出力に DynamoDB のテーブル名を追加
  • Lambda ハンドラの実装を変更

CloudFormation の出力に DynamoDB のテーブル名を追加

まずはスタックの実装を修正していきます。sampleTableName というキーで sampleTable のテーブル名を CloudFormation のスタックに出力します。

lib/cdk-snapshot-test-sample-stack.ts

...
export class CdkSnapshotTestSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // DynamoDB
    const sampleTable = new dynamodb.Table(this, "sampleTable", {
      partitionKey: { name: "id", type: dynamodb.AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.DESTROY,
    });
    new CfnOutput(this, "sampleTableName", {
      value: sampleTable.tableName,
      exportName: "sampleTableName",
    });
...

スタックの実装を変更後、スナップショットテストを実行してみます。

npm test
...
+     "sampleTableName": Object {
+       "Export": Object {
+         "Name": "sampleTableName",
+       },
+       "Value": Object {
+         "Ref": "sampleTable0D61001F",
+       },
+     },
...
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
...

テスト結果がNG(fail)になりました。これは、スタックの実装に DynamoDB のテーブル名を出力する処理を追加したことにより、CloudFormation のテンプレートが変わったということになります。 差分を確認すると、まさに該当の部分が追加されていることを確認できますね。

今回発生した差分は意図した差分なので、以下のコマンドを実行してスナップショットを更新します。 スナップショットを更新する場合は、テスト実行コマンドに -u オプションを渡すだけです。

npm test -- -u
...
Tests:       1 passed, 1 total
Snapshots:   1 updated, 1 total
...

これで、スナップショットが更新されました。

Lambda ハンドラの実装を変更

続いて、Lambda ハンドラの実装を変更してみます。今回の変更パターンは、開発を進める中で頻繁に発生する変更だと思います。 今回は、レスポンスに取得したデータの件数(count)を追加してみました。

lib/sample-lambda.handler.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { DocumentClient } from "aws-sdk/clients/dynamodb";

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const client = new DocumentClient({ region: "ap-northeast-1" });
  const res = await client
    .scan({
      TableName: process.env.TABLE_NAME as string,
    })
    .promise();
  const responseBody = {
    count: res.Count ?? 0,
    samples: res.Items ?? [],
  };

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

実装を変更後、スナップショットテストを実行してみます。

npm test
...
-           "S3Key": "7d0fcb85fc55e294e37baf771901cd1d50a9b2e86773455c0e972bda57470b51.zip",
+           "S3Key": "502fe3cdded7255e17bbbe937da93c73023d57cea8e22bd4f5064c4c044f98e0.zip",
...
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total

テスト結果がNG(fail)になりました。「そうそう、Lambda の実装を変更したから、スナップショットも変わるんだよね!」と思うのですが、この差分が毎回発生するとどうでしょうか?実装の変更のたびに、テスト結果がNGとなり、毎回スナップショットを更新しなくてはなりません。これだと、リソースの設定が変わったことを検出したいのに、本質的ではない差分が発生することになってしまいます。

そもそも、今回発生している差分は、Lambda のハンドラなどをバンドルしたアセットのファイル名が変わっている事によって、差分が発生しています。アセットのファイル名は、 ソースのハッシュ値が使われているようなので、Lambda ハンドラのコードを変更すると、ファイル名が変わってしまいます。

この差分を発生させないようにするのに、Jest の Snapshot Serializer を使って、ハッシュ値が出力される箇所(比較したくない箇所)を固定文字列に置き換えるように設定します。

まずは、こちらを参考に serializer を作成し、作成した serializer をテスト実行時に利用されるように Jest の設定を変更します。serializer の置換対象は、利用するリソースなどにより調整が必要となる部分となります。今回の実装では対象となる部分だけ置換されるように実装しています。

test/snapshot-serializer.ts

module.exports = {
  test: (val: unknown) => typeof val === "string",
  print: (val: unknown) => {
    const newVal = (val as string).replace(
      /([A-Fa-f0-9]{64})(\.zip)/,
      "[HASH REMOVED]"
    );
    return `"${newVal}"`;
  },
};

jest.config.js

module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/test'],
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  },
  snapshotSerializers: ['<rootDir>/test/snapshot-serializer.ts']
};

serializer を設定後、再度スナップショットテストを実行してみます。

npm test
...
-           "S3Key": "7d0fcb85fc55e294e37baf771901cd1d50a9b2e86773455c0e972bda57470b51.zip",
+           "S3Key": "[HASH REMOVED]",
...
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total

テスト結果がNG(fail)になりましたが、生成したテンプレートのハッシュ値が固定文字列である [HASH REMOVED] に置換されていることがわかります。 この状態で、スナップショットを更新することで、それ以降は、Lambda ハンドラのソースを更新しただけの場合は、テスト結果がNGとならなくなります。

CI を設定する

ここまでで、CDK で定義したリソースに対して、スナップショットテストを実行し、実装を変更した際に CloudFormation テンプレートに変更があるかを確認することができるようになりました。これだけでも、十分なのですが、テストすること自体を忘れることありませんか?

私は、よく忘れてそのまま commit, push しちゃうことがあります。 そういったことを防ぎ、より気づきやすくするために自動でテストが実行されるように、GitHub Actions で CI を設定していきます。

設定については、GitHub にアクセスして web 上でも設定できますし、yml ファイルを ./.github/workflows に配置しても設定することができます。今回は、master に push した時、master に対してプルリクを作成したタイミングでテストが実行されるように設定してみました。

github/workflows/ci.yml

name: CI
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'
      - run: npm ci
      - run: npm run build
      - run: npm test

設定した内容を確認するために、テスト結果がNGとなるプルリクを作成してみます。作成時にテスト結果がNGとなった場合は、このような結果となります。わかりやすいですよね。これで、テストが通っていることを確認してからマージすることができそうですね。

さいごに

ユニットテストの仕組み(Jest)を使って、スナップショットテストを自動実行する例を紹介させていただきました。ユニットテストって導入が大変だ・・・さらに CI 設定までやるのは更に大変だ・・・ と思っている方でも、比較的簡単に導入することができると思いますので、まずはちょっとずつでも始めてみてはいかがでしょうか。 スナップショットテストを始めて、積極的にバージョンアップに追従しましょう!

本記事を通して、どなたかの始めてみるきっかけとなれば幸いです。

参考