CDKv2で@aws-cdk/assertionsを試してみる

これは CDK Advent Calendar 2021 の 2日目の記事です。

こんにちは！るね*(@lune_sta)です。
本日(2021年12月2日)、[CDKv2がStableになりました！](https://github.com/aws/aws-cdk/releases/tag/v2.0.0)めでたいですね！！

![Screen Shot 2021-12-02 at 20.07.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91488/d3d2413d-1279-a363-cc33-9e78e26b3804.png)

v1からv2への変更点、移行方法については[公式ドキュメント](https://docs.aws.amazon.com/cdk/latest/guide/work-with-cdk-v2.html)やAWS Blogの
[AWS Cloud Development Kit v2 開発者プレビューのお知らせ](https://aws.amazon.com/jp/blogs/news/announcing-aws-cloud-development-kit-v2-developer-preview/)でも紹介していますので、合わせてご覧ください。

この記事のコードは[GitHub](https://github.com/lune-sta/cdk-assertions-samples)にあります。CDKv2ベースです。

## CDKのテストにまつわる話
CDKではアプリケーションのコードをテストするのと同じように、インフラのコードに対してテストを書けます。以前までは`@aws-cdk/assert`というモジュールを使ったテストが一般的でしたが、このモジュールはTypeScript/JavaScript専用なのがネックでした。
そのため、すべての言語に対応した `@aws-cdk/assertions` というモジュールが新しく開発され、こちらはCDKv1では2020年11月リリースの `v1.132.0` からStableとして利用できるようになっています。もちろんCDKv2でも使用できます。
最近のバージョンでは`cdk init`で作成されるテストの雛形も `@aws-cdk/assert` ではなく `@aws-cdk/assertions` に変わっており、今後はこちらが主流になっていくと思われます。

`@aws-cdk/assertions` の使い方については、AWS Blogの[あらゆる言語でのCDKアプリケーションのテスト](https://aws.amazon.com/jp/blogs/news/testing-cdk-applications-in-any-language/)で紹介していますが、この記事では実際にシンプルなコードに対してテストを書いてみて感触を確かめてみます。


## テスト対象のプログラム
今回は、下記のようなシンプルなコードに対してテストする例を考えてみます。
![Untitled Diagram.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91488/12bea7f8-c61c-d829-bc23-bcf1c31f4b5b.png)

```
    const db = new dynamodb.Table(this, 'Table', {
      partitionKey: {
        name: 'itemId',
        type: dynamodb.AttributeType.STRING,
      },
      tableName: 'items',
      removalPolicy: RemovalPolicy.DESTROY,
    })

    const backend = new lambda.NodejsFunction(this, 'Function', {
      entry: './lambda/index.ts',
    })

    db.grantReadWriteData(backend)

    new apigateway.LambdaRestApi(this, 'Api', {
      handler: backend
    })
```

### テストの始め方
`@aws-cdk/assertions` を使うには、まずテスト対象のStackのインスタンスを生成し、`Template.fromStack()` を使用してCloudformationテンプレートを生成する所から始まります。
`Template.fromString()`や`Template.fromJSON()`なども用意されているので文字列やObjectからテンプレートを生成することもできます。

```
import { Template } from 'aws-cdk-lib/assertions'
import { App } from 'aws-cdk-lib'
import { CdkAssertionsSamplesStack } from '../lib/cdk-assertions-samples-stack'

test('Test name', () => {
    const app = new App();
    const stack = new CdkTestSamplesStack(app, 'CdkTestSamplesStack', {})
    const template = Template.fromStack(stack)
```

### Full Template Match
一番シンプルなアサーションとして紹介されているのが、完成形のテンプレートと比較するFull Template Matchです。

```
    const expected = {
        'Resources': {
            'TableCD117FA1': {
                'Type': 'AWS::DynamoDB::Table',
                'Properties': {
                    'KeySchema': [
                        {
                            'AttributeName': 'itemId',
                            'KeyType': 'HASH'
                        }
                    ],
                    (中略)
    }

    template.templateMatches(expected)
```

`templateMatches()` に限らず、`@aws-cdk/assertions` のマッチ系のメソッドではデフォルトで完全一致ではなく、"オブジェクトライク"な比較が行われます。そのため実際のテンプレートは期待する値の上位集合(superset)であることが許容されます。イメージとしては下記のような感じです。完全一致を行いたい場合は後述するマッチャーを使います。

```
// テスト対象のテンプレート
// {
//   "Resources": {
//     "A": {
//       "Type": "Foo::Bar",
//       "Properties": {...}
//     },
//     "B": {
//       "Type": "BAZ::QUX",
//       "Properties": {...}
//     }
//   }
// }

// これはエラーにならない
const expected = {
   'Resources': {
     'A': {
       'Type': 'Foo::Bar',
       'Properties': {...}
     }
}
template.templateMatches(expected)

// これはエラーになる
const expected = {
   'Resources': {
     'C': {
       'Type': 'QUUX::CORGE',
       'Properties': {...}
     }
}
template.templateMatches(expected)
```

実際にFull Template Matchをする際は、手作業で期待するテンプレートを管理するよりスナップショットテストとして使うことが多いと思います。スナップショットテストは簡単で、TypeScriptでJestを使用する場合は下記のように書くだけでスナップショットの保存と比較が行われます。

```
    // スナップショットとの比較を行う
    expect(template.toJSON()).toMatchSnapshot()
```

テスト対象のテンプレートに意図して変更を加えた場合は、以下のコマンドでスナップショットを更新できます。詳しくは[Snapshot Testing](https://jestjs.io/docs/snapshot-testing)をご覧ください。

```
$ jest --updateSnapshot
```

CDKから生成されるCFnテンプレートは、CDKフレームワークとCDKアプリケーションの2つのコードの影響を受けます。スナップショットテストを使うことで、CDKのバージョンが変化してもデプロイされるリソースに影響が無いことを確認できます。また、CDKアプリケーションのリファクタをする際にも有用です。

### Counting Resources
`resourceCountIs()`を使うと対象の種類のリソースの数を確認できます。

```
    // Functionが1つ作られていることを確認する
    template.resourceCountIs('AWS::Lambda::Function', 1)
```

### Resource Matching & Retrieval
テンプレート全体ではなく、特定のリソースの `Properties` を確認したい場合は `hasResourceProperties()` を使います。
`Properties` だけでなくリソース全体(`UpdateReplacePolicy`や`DependsOn`など)を確認する場合は `hasResource()` を使います。 

```
    // Propertiesが正しいことを確認する
    template.hasResourceProperties('AWS::Lambda::Function', {
        'Handler': 'index.handler'
    });

    // Properties以外を確認したい場合はhasResource()を使う
    template.hasResource('AWS::DynamoDB::Table', {
        'UpdateReplacePolicy': 'Delete'
    })
```

似たようなものに `findResources()` メソッドがあり、アサーションではなくマッチするリソースのセットを取得できます。

```
    // 生成されるIAM Roleをすべて表示する
    console.log(template.findResources('AWS::IAM::Role'))
```

### Output and Mapping sections
OutputやMappingに対しても似たようなメソッドどして `hasOutput()`、`findOutput()`、`hasMapping()`、`findMappings()` が利用できます。その場合、引数として`logicalId`を指定しますが、`*`を指定して全てを対象にすることもできます。
(`logicallId`はランダムに生成されるため、一部を `*` にして `ApiEndpoint*` のような書き方ができると便利そうですが、できませんでした。)

```
const expected = {
  Value: '...'
}

template.hasOutput('*', expected)
```

### Special Matchers
`hasXxx()`、`findXxx()`、`templateMatches()` で使用する期待する値は、今までの例のようなリテラル値以外にもマッチャーを使用できます。

#### Object Matchers
`Match.objectLike()` を使うとテスト対象のオブジェクトがパターンの上位集合(superset)であることを確認できます。これは マッチャーを使わない場合と同じ挙動です。
`Match.objectEquals()` を使うと完全一致のアサートをすることができます。

```
    // Match.objectLike() は部分一致
    template.hasResourceProperties('AWS::DynamoDB::Table', {
        'ProvisionedThroughput': Match.objectLike({
            'ReadCapacityUnits': 5
        })
    })

    // Match.objectEquals() は完全一致
    template.hasResourceProperties('AWS::DynamoDB::Table', {
        'ProvisionedThroughput': Match.objectEquals({
            'ReadCapacityUnits': 5,
            'WriteCapacityUnits': 5
        })
    })
```

#### Presence and Absence
`Match.absent()` を使うと該当する値が存在しないことを確認できます。逆に値が存在することだけを確認したい場合は `Match.anyValue()` を使います。

```
    // 存在しないことを確認する
    template.hasResourceProperties('AWS::DynamoDB::Table', {
        'DummyKey': Match.absent()
    })

    // 存在することを確認する
    template.hasResourceProperties('AWS::DynamoDB::Table', {
        'TableName': Match.anyValue()
    })
```

#### Array Matchers
配列に対しては `Match.arrayWith()` や `Match.arrayEquals()` が使用できます。
今回のテンプレートでは良い例が思いつきませんでしたが、[公式ドキュメント](https://docs.aws.amazon.com/cdk/api/latest/docs/assertions-readme.html#array-matchers)には以下のような例が紹介されていました。

```
// Given a template -
// {
//   "Resources": {
//     "MyBar": {
//       "Type": "Foo::Bar",
//       "Properties": {
//         "Fred": ["Flob", "Cat"]
//       }
//     }
//   }
// }

// The following will NOT throw an assertion error
const expected = {
  Fred: Match.arrayWith(['Flob']),
};
template.hasResourceProperties('Foo::Bar', expected);

// The following will throw an assertion error
const unexpected = Match.objectLike({
  Fred: Match.arrayWith(['Wobble']),
});
template.hasResourceProperties('Foo::Bar', unexpected);
```

#### Not Matcher
`Match.not()`を使うとマッチ結果を反転させることができます。
以下も[公式ドキュメント](https://docs.aws.amazon.com/cdk/api/latest/docs/assertions-readme.html#not-matcher)の例になります。

```
// Given a template -
// {
//   "Resources": {
//     "MyBar": {
//       "Type": "Foo::Bar",
//       "Properties": {
//         "Fred": ["Flob", "Cat"]
//       }
//     }
//   }
// }

// The following will NOT throw an assertion error
const expected = {
  Fred: Match.not(['Flob']),
};
template.hasResourceProperties('Foo::Bar', expected);

// The following will throw an assertion error
const unexpected = Match.objectLike({
  Fred: Match.not(['Flob', 'Cat']),
});
template.hasResourceProperties('Foo::Bar', unexpected);
```

### Serialized JSON
[公式ドキュメント](https://docs.aws.amazon.com/cdk/api/latest/docs/assertions-readme.html#serialized-json)

### Capturing Values
`Capture()` を使用するとマッチした値をキャプチャーできます。

```
    // 値をキャプチャーするにはCapture()を使う
    const capture = new Capture()
    template.hasResourceProperties('AWS::Lambda::Function', {
        Runtime: capture
    })
    console.log(capture.asString())
    // nodejs14.x
```

## テストを書いてみた感想
`@aws-cdk/assertions` を使ったテストは、生成されるCFnテンプレートへのアサーションになるため、CFnを意識しないとうまくテストが書けない所が好みが分かれそうです。たとえば[Aspects](https://docs.aws.amazon.com/cdk/latest/guide/aspects.html)を使うと通常のTypeScriptのコードでStackの内容を確認することもできるので、用途によって使い分けるのがよいと思いました。Aspectsの使い方は[クラスメソッドさんの記事](https://dev.classmethod.jp/articles/aws-cdk-use-aspect/)が分かりやすいです。

マッチャーはかゆい所に手が届かない部分もあって、たとえば`hasResource()`等のメソッドは単一のリソースに対してのみアサートするため、同じ種類のリソースが複数あった時
(すべてのS3のバージョニングがonになっているか？など)の簡単な書き方が分かりませんでした。`@aws-cdk/assertions` はできたばかりのモジュールのため今後の更新にも期待です。

スナップショットテストは手間がほぼかからず、CDKのバージョンアップやリファクタによる意図しないデグレを防ぐことができるため有用だと思います。まずはスナップショットテストから始めるのがよいかなと思いました。