AWS CDKに求められるテストとその方法

AWS CDKでスナップショットテストの方法を紹介しています。テストがあれば、CDKがアップデートされても作成されるAWSリソースに変更が無いことを簡単に確認できるようになります。
2019.09.20

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

はじめに

おはようございます、加藤です。AWS CDK(以降、CDK)でのテスト方法を紹介します。 プログラミング初心者かつ、TypeScriptを書いたのはCDKが初めてなので、直したほうが良い所やより良い書き方があれば、プルリクや記事にコメントを頂けると、ものすごく助かります。

kmd2kmd/aws-cdk-ec2_web3tier

なぜテストするか

まず、v1.0.0以降のCDKのアップデート間隔をご覧ください。 (時差とか考えていないので正確では無いです...)

バージョン リリース日 間隔
v1.8.0 2019/9/10 4
v1.7.0 2019/9/6 4
v1.6.1 2019/8/29 2
v1.6.0 2019/8/27 6
v1.5.0 2019/8/21 7
v1.4.0 2019/8/14 12
v1.3.0 2019/8/2 8
v1.2.0 2019/7/26 6
v1.1.0 2019/7/20 8
v1.0.0 2019/7/12 -

ものすごくアップデートペースが早く、1週間前後でアップデートが来ます。 プロダクトでCDKを使用すると考えると、最初v1.0.0で作ったけど完成する頃にはv1.8.0という状況が容易に発生します。

また、以前作ったCDKを将来、参照した際には、はるか昔のバージョンになっている事が考えられます。

CDKのテストを書いておくことで、最新のCDKバージョンにアップデートしても同じ動作をする事が確認できます。 また、変更があったとしてもテスト結果を元に何が変わるかを容易に知ることができます。

どこをどうやってテストするか

最初に、CDKによってAWSリソースが作成される流れを確認します。 作成したCDKアプリケーションはCloudFormationテンプレートを生成します。CDKはcdk deployコマンドでこのテンプレートを元にスタック作成する所までを担当してくれます。 スタックが作成されるとテンプレートに基づいてAWSリソースが作成されます。

テストすべきなのは、CDKアプリケーションによってCloudFormationを生成する箇所です。 以前と同じCloudFormationテンプレートが生成される事をテストする事で、CDKのバージョンが変わっても作成されるAWSリソースに変更が無いことを確認します。

CloudFormationスタックによって、AWSリソースが作成される箇所はAWSの責任範囲なので、今回のテスト範囲には含みません。 「AWSの責任範囲だからといって、テストしないのはどうなんだ」と意見があるだろうし、実際その通りです。 ですが、エンドツーエンドテストで行うべき範囲なので今回は触れません。

CDKのスナップショットテスト

Jestを使い生成されるCloudFormationをスナップショットテストする事でテストを行います。

jest snapshot testing

実際のコードを紹介しながら説明していきます。 コードはGitHubで公開しているので、手元のエディタや全体を確認したい場合にご利用ください。

kmd2kmd/aws-cdk-ec2_web3tier

EC2インスタンス用のIAMロールを作成するスタックを例に説明します。 下記がコードです。コンテキスト(cdk.json)からプロジェクト名と実行時に指定する環境名を読み込み、リソース名に使用しています。

lib/identity-stack.ts

import { Construct, Stack, StackProps } from "@aws-cdk/core";
import { ManagedPolicy, Role, ServicePrincipal } from "@aws-cdk/aws-iam";

export class IdentityStack extends Stack {
  public readonly instanceRole: Role;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const prj: string = this.node.tryGetContext("prj");
    const stage: string = this.node.tryGetContext("stage");

    this.instanceRole = new Role(this, "IamRole", {
      assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
      roleName: `${prj}-${stage}-app`,
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonEC2RoleforSSM"
        )
      ]
    });
  }
}

下記がテストコードです。 テスト様にプロジェクトと環境を設定しスタックを定義しスナップショットテストを行います。

__test__/test.identity.ts

import { App } from "@aws-cdk/core";
// import { InstanceType, UserData, Vpc } from "@aws-cdk/aws-ec2"
import { SynthUtils } from "@aws-cdk/assert";
import { IdentityStack } from "../lib/identity-stack";

describe("identity", () => {
  test("default", () => {
    const app = new App();
    app.node.setContext("prj", "ssprj");
    app.node.setContext("stage", "ssstage");
    const stack = new IdentityStack(app, "TestIdentityStack");
    expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
  });
});

下記が生成されるスナップショットです。

__tests__/__snapshots__/test.identity.ts.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`identity default 1`] = `
Object {
  "Resources": Object {
    "IamRoleA750FF82": Object {
      "Properties": Object {
        "AssumeRolePolicyDocument": Object {
          "Statement": Array [
            Object {
              "Action": "sts:AssumeRole",
              "Effect": "Allow",
              "Principal": Object {
                "Service": Object {
                  "Fn::Join": Array [
                    "",
                    Array [
                      "ec2.",
                      Object {
                        "Ref": "AWS::URLSuffix",
                      },
                    ],
                  ],
                },
              },
            },
          ],
          "Version": "2012-10-17",
        },
        "ManagedPolicyArns": Array [
          Object {
            "Fn::Join": Array [
              "",
              Array [
                "arn:",
                Object {
                  "Ref": "AWS::Partition",
                },
                ":iam::aws:policy/service-role/AmazonEC2RoleforSSM",
              ],
            ],
          },
        ],
        "RoleName": "ssprj-ssstage-app",
      },
      "Type": "AWS::IAM::Role",
    },
  },
}
`;

わざと変更してテストを実行しました。下記のように変更が検知されます。 もし変更が正当なものである場合は、npx jest --updateSnapshotでスナップショットを更新します。

意図的にAppを変更した場合のテスト結果

 FAIL  __tests__/test.identity.ts
  identity
    ✕ default (77ms)

  ● identity › default

    expect(received).toMatchSnapshot()

    Snapshot name: `identity default 1`

    - Snapshot
    + Received

    @@ -36,11 +36,11 @@
                      ":iam::aws:policy/service-role/AmazonEC2RoleforSSM",
                    ],
                  ],
                },
              ],
    -         "RoleName": "ssprj-ssstage-app",
    +         "RoleName": "ssprj-ssstage-app-devio",
            },
            "Type": "AWS::IAM::Role",
          },
        },
      }

      10 |     app.node.setContext("stage", "ssstage");
      11 |     const stack = new IdentityStack(app, "TestIdentityStack");
    > 12 |     expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
         |                                                ^
      13 |   });
      14 | });
      15 | 

      at Object.test (__tests__/test.identity.ts:12:48)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        1.483s, estimated 4s
Ran all test suites matching /__tests__\/test.identity.ts/i.

npm run testでテストが行える様にpackage.jsonを設定しています。

package.json

{
  "name": "web-3tier",
  "version": "0.1.0",
  "bin": {
    "web-3tier": "bin/web-3tier.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "lint": "eslint --fix \"./**/*[^d].ts\"",
    "test": "jest"
  },
  "pre-commit": [
    "build",
    "test"
  ],
  "devDependencies": {
    "@aws-cdk/assert": "1.8.0",
    "@types/jest": "24.0.18",
    "@types/node": "8.10.51",
    "@typescript-eslint/eslint-plugin": "2.3.0",
    "@typescript-eslint/parser": "2.3.0",
    "eslint": "6.4.0",
    "eslint-config-prettier": "6.3.0",
    "eslint-plugin-prettier": "3.1.1",
    "pre-commit": "1.2.2",
    "prettier": "1.18.2",
    "ts-jest": "24.1.0",
    "ts-node": "8.4.1"
  },
  "dependencies": {
    "@aws-cdk/aws-autoscaling": "1.8.0",
    "@aws-cdk/aws-ec2": "1.8.0",
    "@aws-cdk/aws-elasticloadbalancingv2": "1.8.0",
    "@aws-cdk/aws-rds": "1.8.0",
    "@aws-cdk/core": "1.8.0",
    "aws-cdk": "1.8.0",
    "npm": "6.11.3",
    "source-map-support": "0.5.13",
    "typescript": "3.6.3"
  },
  "jest": {
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ],
    "globals": {
      "ts-jest": {
        "tsConfig": "tsconfig.json"
      }
    },
    "testMatch": [
      "**/__tests__/*.+(ts|tsx|js)"
    ],
    "preset": "ts-jest"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.ts": [
      "eslint --fix \"./**/*[^d].ts\"",
      "git add"
    ]
  }
}

CDK更新の自動化(npmパッケージ更新の自動化)

テストがあれば、Renovateを使って、npmパッケージの更新を自動化できます。

Renovateの使い方は下記のブログが参考になりました。

Renovateはnpmパッケージに更新があった際に、自動でプルリクエストを発行してくれます。 自動マージも可能で、テストが通った場合は自動マージに設定しておけば、下図のような感じでガシガシパッケージをアップデートしてくれます。

CDKはCloudFormationを生成するアプリケーションという性質上、パッケージの脆弱性にあまり神経質になる必要はないのですが、アップデートするに越したことはないので、嬉しいです!

あとがき

CDKはとても便利で活発にアップデートされているので、テストをしっかり書いて追従して行きましょう!!

参考

この勉強会に参加して、はてなさんの発表を聞いていなければ、このブログは書けていませんでした。 開催してくれたAWSJさん、発表してくれたはてなさんありがとう!! AWS Cloud Development Kit -CDK- Meetupではてなから新サービス事例などを紹介します - Hatena Developer Blog