AWS CDKにおける基本的なテストと実装方法を調べて試した

2019.10.09

AWS CDKがGAされたので好きなサービスはAWS CDKと胸を張って言えるようになりました。
さてAWS DevDay Tokyo 2019でAWS CDKについてのセッションがありました。見ていない方はよろしければセッションレポートを見てください。
セッションの中でAWS CDKにおけるテスト方法について触れていました。今後自分でCDKライブラリを実装していく中でも避けられない内容なのでAWS公式ブログを見つつ実際に試してまとめてみました。

目次

CDKのテストについて

今回記載するテストが指すものはCDKライブラリのテストです。
実際にライブラリが作成したCloudFormationテンプレートを元にCloudFormationスタックを作成するのはCDK CLIの責務です。
なのでCDK CLIから実際に作成するようなE2Eテストについては記載しません。またCDKライブラリのテストは通常3つに分類することができます。

実際にテストを行うにあたってどのようなツールを使い、どのようなテストを行うかについて述べていきます。

@aws-cdk/assert

CDKライブラリのテストを書くためのアサーションライブラリがTypeScriptとJavaScriptには準備されており @aws-cdk/assertで公開されています。 執筆段階では他言語でのライブラリは公開はされていません。

ですので@aws-cdk/assertとテスト用のフレームワークを使用することでテストを書くことができます。
今回はAWSが公開しているConstructライブラリ群でもJestを使用しているのでJestと組み合わせてテストを書いていきます。

Snapshot tests(golden master test)

CDKライブラリが生成するCloudFormationテンプレートと前に生成されたテンプレートが同じであるかをテストします。
ReactのUIコンポーネントに対してテストをかける要領と同じように行います。
なのでJestを使用する場合はこちらのドキュメントでどのようなことをしているのか詳しく確認できます。
Snapshot testsに失敗する場合、すなわちテンプレートが合致しない場合は更新が妥当である場合はスナップショットを更新します。
CDKでは@aws-cdk/assertライブラリのSynthUtilsモジュールにあるtoCloudFormationメソッドを使用してテンプレートの作成をしスナップショットを作成していきます。
またCDKではSnapshot testsをintegration testsとして使用します。

Fine-grained tests(Fine-grained assertions about the template)

Snapshot testsではCloudFormationテンプレート単位でテストを行うため機能単位でテストはしません。
そのため機能単位でのテストが必要になりますね。例えばConstructに新機能を追加した時にSnapshot testsは失敗します。
スナップショットを更新すればもちろん成功するのですが実際にその新機能が正しく動くかどうかの保証はできません。なので機能単位で詳細なテストを行うためにこのテストが必要になります。
機能単位での詳細なテストを行うのがFine-grained assertionsになります。
AWS CDKでは@aws-cdk/assert/jestJestのCustom Matcherを提供しています。
これが提供するtoHaveResourceを使用することでCDK StackがCloudFormationリソースを保持しているかをテストできます。
CloduFormationリソースを元にテストを行うのでリソース仕様を理解して記載する必要があります。

Validation tests

Constructに対して無効な値を渡した時に正しくエラーが発生することと有効な値を渡した時に問題が起きないかを確認します。

Dead Letter Queueでの実装

ざっとテストについて把握したので実際に実装していきましょう。
AWS公式ブログに記載されているDeadLetterQueueの例を実装していきます。
またGitHubにもリポジトリを作ったので合わせてご確認ください。

0. バージョン

Softweare Version
AWS CDK 1.11.0
typescript 3.6.2
jest 24.9.0

1. 初期設定

まずはCDKライブラリを作成する前準備を進めていきます。

$ npx cdk init --language=typescript lib
$ npm install @aws-cdk/aws-sqs @aws-cdk/aws-cloudwatch

2. ライブラリの作成

だいたいブログの通りにコードを書いていきます。

lib/dead-letter-queue.ts

import { IAlarm, Alarm } from '@aws-cdk/aws-cloudwatch'
import { Queue } from '@aws-cdk/aws-sqs'
import { Construct } from '@aws-cdk/core'

export class DeadLetterQueue extends Queue {
  public readonly messagesInQueueAlarm: IAlarm

  constructor(scope: Construct, id: string) {
    super(scope, id)

    this.messagesInQueueAlarm = new Alarm(this, 'Alarm', {
      alarmDescription: 'There are messages in the Dead Letter Queue',
      evaluationPeriods: 1,
      threshold: 1,
      metric: this.metricApproximateNumberOfMessagesVisible(),
    })
  }
}

3. Snapshot tests

バージョン1.11.0ではテスト用のツールもinit時に含まれていました。
なので事前準備なしにテストを書くことができます。
先ほど述べたようにSynthUtilsのtoCloudFormationメソッドでCDK StackをCloudFormationテンプレートに変換します。
その後にjestにスナップショットの生成とテストを任せます。
作成したDeadLetterQueueはStackではなくConstructなのでテスト用にStackを作成してそこに加える形を取っています。

test/dead-letter-queue.test.ts

import { SynthUtils } from '@aws-cdk/assert'
import { Stack } from '@aws-cdk/core'

import { DeadLetterQueue } from '../lib/dead-letter-queue'

test('dlq creates an alarm', () => {
    const stack = new Stack()
    new DeadLetterQueue(stack, 'DLQ')
    expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot()
})

1回目のSnapshot testsでまずはスナップショットを作成します。

$ npm run build
$ npm run test

  > jest

   PASS  test/dead-letter-queue.te

これを実行するとtestディレクトリ配下に__snapshots__ディレクトリが生成され、その中にスナップショットが作られます。
中身はこのようになっています。少し見辛いですがCloudFormationテンプレートがオブジェクトとして保存されていることが確認できます。

test/__snapshots__/dead-letter-queue.test.ts.snap

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

exports[`dlq creates an alarm 1`] = `
Object {
  "Resources": Object {
    "DLQ581697C4": Object {
      "Type": "AWS::SQS::Queue",
    },
    "DLQAlarm008FBE3A": Object {
      "Properties": Object {
        "AlarmDescription": "There are messages in the Dead Letter Queue",
        "ComparisonOperator": "GreaterThanOrEqualToThreshold",
        "Dimensions": Array [
          Object {
            "Name": "QueueName",
            "Value": Object {
              "Fn::GetAtt": Array [
                "DLQ581697C4",
                "QueueName",
              ],
            },
          },
        ],
        "EvaluationPeriods": 1,
        "MetricName": "ApproximateNumberOfMessagesVisible",
        "Namespace": "AWS/SQS",
        "Period": 300,
        "Statistic": "Maximum",
        "Threshold": 1,
      },
      "Type": "AWS::CloudWatch::Alarm",
    },
  },
}
`;

スナップショットができたところで再度テストをするとどうなるでしょうか。

$ npm run test

  > jest

  PASS  test/dead-letter-queue.test.ts
    ✓ dlq creates an alarm (52ms)

  Test Suites: 1 passed, 1 total
  Tests:       1 passed, 1 total
  Snapshots:   1 passed, 1 total
  Time:        1.737s, estimated 3s
  Ran all test suites.

当然ですがテストは通ります。
次にライブラリに変更を加えて見ます。変更内容は最長メッセージ保持期間の追加です。

lib/dead-letter-queue.ts

import { IAlarm, Alarm } from '@aws-cdk/aws-cloudwatch'
import { Queue } from '@aws-cdk/aws-sqs'
import { Construct, Duration } from '@aws-cdk/core'

export class DeadLetterQueue extends Queue {
  public readonly messagesInQueueAlarm: IAlarm

  constructor(scope: Construct, id: string) {
    super(scope, id, {
      retentionPeriod: Duration.days(14)
    })

    this.messagesInQueueAlarm = new Alarm(this, 'Alarm', {
      alarmDescription: 'There are messages in the Dead Letter Queue',
      evaluationPeriods: 1,
      threshold: 1,
      metric: this.metricApproximateNumberOfMessagesVisible(),
    })
  }
}

スナップショットとCDKライブラリが生成するCloudFormationテンプレートに差分が生じるためテストが通らなくなります。

$ npm run build
$ npm run test

  > jest

  FAIL  test/dead-letter-queue.test.ts
    ✕ dlq creates an alarm (55ms)

    ● dlq creates an alarm

      expect(received).toMatchSnapshot()

      Snapshot name: `dlq creates an alarm 1`

      - Snapshot
      + Received

      @@ -1,8 +1,11 @@
        Object {
          "Resources": Object {
            "DLQ581697C4": Object {
      +       "Properties": Object {
      +         "MessageRetentionPeriod": 1209600,
      +       },
              "Type": "AWS::SQS::Queue",
            },
            "DLQAlarm008FBE3A": Object {
              "Properties": Object {
                "AlarmDescription": "There are messages in the Dead Letter Queue",

        7 |     const stack = new Stack()
        8 |     new DeadLetterQueue(stack, 'DLQ')
      >  9 |     expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot()
          |                                                ^
        10 | })

        at Object.<anonymous> (test/dead-letter-queue.test.ts:9:48)

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

  Test Suites: 1 failed, 1 total
  Tests:       1 failed, 1 total
  Snapshots:   1 failed, 1 total
  Time:        1.792s, estimated 2s

追加したMessageRetentionPeriodでエラーが発生していますね。
この変更は私が意図して行なったものなのでスナップショットを再生成します。

$ npm run test -- -u

  > jest "-u"

  PASS  test/dead-letter-queue.test.ts
    ✓ dlq creates an alarm (57ms)

  › 1 snapshot updated.
  Snapshot Summary
  › 1 snapshot updated from 1 test suite.

  Test Suites: 1 passed, 1 total
  Tests:       1 passed, 1 total
  Snapshots:   1 updated, 1 total
  Time:        1.833s, estimated 2s
  Ran all test suites.

スナップショットがアップデートされてテストに通るようになりました。
差分を確認すると下記のようになっており変更が確認できますね。

$ git diff test/__snapshots__/dead-letter-queue.test.ts.snap

  diff --git a/test/__snapshots__/dead-letter-queue.test.ts.snap b/test/__snapshots__/dead-letter-queue.test.ts.snap
  index 597bd0e..e56f61f 100644
  --- a/test/__snapshots__/dead-letter-queue.test.ts.snap
  +++ b/test/__snapshots__/dead-letter-queue.test.ts.snap
  @@ -4,6 +4,9 @@ exports[`dlq creates an alarm 1`] = `
  Object {
    "Resources": Object {
      "DLQ581697C4": Object {
  +      "Properties": Object {
  +        "MessageRetentionPeriod": 1209600,
  +      },
        "Type": "AWS::SQS::Queue",
      },
      "DLQAlarm008FBE3A": Object {

4. Fine-grained tests

@aws-cdk/assert/jestモジュールを使用することでConstructsの一部分に対してテストを書くことができます。
先頭行でAWS CDK用のCustom matcherを入れることでCloudFormationリソース仕様を元にテストを行えるようになります。
作成されるAWSリソースの該当プロパティが正しいかどうかをテストしていきます。

test/dead-letter-queue.test.ts

import '@aws-cdk/assert/jest'
import { SynthUtils } from '@aws-cdk/assert'
import { Stack } from '@aws-cdk/core'

import { DeadLetterQueue } from '../lib/dead-letter-queue'

test('dlq fine-grained', () => {
  const stack = new Stack()
  new DeadLetterQueue(stack, 'DLQ')
  expect(stack).toHaveResource('AWS::CloudWatch::Alarm', {
    MetricName: 'ApproximateNumberOfMessagesVisible',
    Namespace: 'AWS/SQS',
    Dimensions: [
      {
        Name: 'QueueName',
        Value: { "Fn::GetAtt": [ "DLQ581697C4", "QueueName" ] }
      }
    ]
  })
})

test('dlq creates an alarm', () => {
    const stack = new Stack()
    new DeadLetterQueue(stack, 'DLQ')
    expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot()
})

5. Validation tests

Constructライブラリを使用するユーザがライブラリに対して渡す値によって作成するStackの中身を変えることはよくあります。
その場合にはpropsを通じてインスタンス作成時に値を渡せば良いですね。

なのでまずはpropsを定義して、最長メッセージ保持期間をユーザが設定できるように変更を加えます。

lib/dead-letter-queue.ts

import { IAlarm, Alarm } from '@aws-cdk/aws-cloudwatch'
import { Queue } from '@aws-cdk/aws-sqs'
import { Construct, Duration } from '@aws-cdk/core'
import { stringLiteral } from '@babel/types'

export interface DeadLetterQueueProps {
  retentionDays?: number;
}

export class DeadLetterQueue extends Queue {
  public readonly messagesInQueueAlarm: IAlarm

  constructor(scope: Construct, id: string, props: DeadLetterQueueProps = {}) {
    if (props.retentionDays !== undefined && props.retentionDays > 14) {
      throw new Error('retentionDays may not exceed 14 days')
    }
    super(scope, id, {
      retentionPeriod: Duration.days(props.retentionDays || 14)
    })

    this.messagesInQueueAlarm = new Alarm(this, 'Alarm', {
      alarmDescription: 'There are messages in the Dead Letter Queue',
      evaluationPeriods: 1,
      threshold: 1,
      metric: this.metricApproximateNumberOfMessagesVisible(),
    })
  }
}

次にpropsを通じて値を渡した時、予期した通りの動作になっているかを確認します。
値を渡した時にCloudFormaitonリソースが値を持っていることと、渡してはいけない値(今回の場合はretentionDaysに15以上を入れること)を入れた場合にエラーが出ることを確認します。

test/dead-letter-queue.test.ts

~~~
test('retention period can be configured', () => {
  const stack = new Stack();
  new DeadLetterQueue(stack, 'DLQ', {
    retentionDays: 7
  })
  expect(stack).toHaveResource('AWS::SQS::Queue', {
    MessageRetentionPeriod: 604800,
  })
})

test('configurable retention period cannot exceed 14 days', () => {
  const stack = new Stack()
  expect(() => {
    new DeadLetterQueue(stack, 'DLQ', {retentionDays: 15})
  }).toThrowError(/retentionDays may not exceed 14 days/)
})

今までのテストファイルに追記する形で今回はテストを書いているので一旦スナップショットを更新します。

$ npm run build
$ npm run test -- -u

次に実際にテストを行います。

$ npm run test

  > jest

  PASS  test/dead-letter-queue.test.ts
    ✓ dlq fine-grained (56ms)
    ✓ dlq creates an alarm (18ms)
    ✓ retention period can be configured (17ms)
    ✓ configurable retention period cannot exceed 14 days (1ms)

  Test Suites: 1 passed, 1 total
  Tests:       4 passed, 4 total
  Snapshots:   1 passed, 1 total
  Time:        1.805s, estimated 2s

問題なさそうですね。

最後に

実際に実装しながら確認したので非常に理解が深まりました。
AWS CDKでテストを書く環境が揃っているので非常にとっつきやすかったです。

またAWS公式がConstructライブラリを公開しているので実際にどのような構成で行なっているかについてはそちらを確認すると良いと思います。

参考資料