【AWS CDKテスト入門】SnapshotテストとAssertionテストの使い方について考えてみた

# この記事について
この記事では、AWS Cloud Development Kit (CDK) を使用してインフラストラクチャのコードを書いている開発者が、テスト自動化を導入するための方法を紹介します。特に、SnapshotテストとFine-Grained Assertionテスト(以下Assertionテスト)の2つの主要なテスト手法について実際のコードを記述して、それぞれのテストのメリットやデメリット、テストの使いどころなどについて実装方法と実行結果から考えてみました。

# 対象読者
この記事は以下のような読者を対象としています。
- インフラストラクチャのコードにテストを導入したいと考えている方
- CDKについて基本は知っているが、CDKのテストを書いたことがない方
- これからCDKのテストを書こうと思っているけど、何から始めたらいいかわからない方
- SnapshotテストやAssertionテストに興味がある方

# この記事では触れないこと
この記事では、以下のトピックについては触れません。
- CDKの概要や基本的な使い方、セットアップ手順
- スナップショットテストおよびアサーションテスト以外のテスト手法
- テストの詳細な理論や背景知識

# 動作環境
この記事の内容は以下の環境で動作確認を行っています。
- Node.js 20.15.0
- TypeScript 5.5.2
- AWS CDK v2(2.150.0)

# CDKのテストの種類
cdkのテストにはいくつか種類がありますが、本記事ではSnapshotテストとAssertionテストに焦点を当てて比較します。
SnapshotテストとAssertionテストの手法についての概要は以下になります。

## Snapshotテスト
- 生成されたCloudFormationテンプレートを以前のものと比較し、両者に差分があるかをテストする手法
- 数行のコードで実装できるため、最初に導入を検討する手法
    
## Assertionテスト
- 生成されたCloudFormationのリソースやアウトプットが期待通りに生成されているかテストする手法

2つのテスト手法について、実際にテストを実装しながらどのようなテストが行われるのかをみていきたいと思います。

# 下準備
## ディレクトリ構成
プロジェクトのディレクトリ構成は以下の通りです。

```
cdk_test_study/
|-- bin/
|   |-- cdk_test_study.ts
|-- cdk.out
|-- lib/
|   |-- cdk_test_study-stack.ts
|-- node_modules
|-- test/
|   |-- cdk_test_study.test.ts
|-- cdk.json
|-- package.json
|-- tsconfig.json
```

## テスト対象とするStack
今回は、以下のようにVPCを作成するシンプルなスタックをテスト対象とします。

```bin/cdk_test_study.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CdkTestStudyStack } from '../lib/cdk_test_study-stack';

const app = new cdk.App();
new CdkTestStudyStack(app, 'CdkTestStudyStack', {});
```

```lib/cdk_test_study-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

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

    new ec2.Vpc(this, 'CdkTestStudyVpc', {
      maxAzs: 1,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'CdkTestStudyPrivate',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        }
      ]
    });
  }
}
```

# Snapshotテスト
## 実装
まずは、Snapshotテストを実装していきます。
実装については本当に数行で済み、testディレクトリに以下のファイルを作成します。

```test/cdk_test_study.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkTestStudyStack } from '../lib/cdk_test_study-stack';

test("snapshot", () => {
  const app = new cdk.App();
  const stack = new CdkTestStudyStack(app, "MyTestStack");
  const template = Template.fromStack(stack);
  expect(template.toJSON()).toMatchSnapshot();
});
```
たったこれだけでSnapshotテストを実行できるようになります。

## テスト実行
テストを実行してみます。```npm test```コマンドでテストが実行されます。
Snapshotテストを実装しテストを実行することで、現在の記述で作成されるリソースのsnapshotが__snapshots__に作成されます。テスト内容は以下のように表示され、テストが成功していることがわかります。

```
$ npm test
> cdk_test_study@0.1.0 test
> jest

 PASS  test/cdk_test_study.test.ts (5.625 s)
  ✓ snapshot (409 ms)

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

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        5.792 s
Ran all test suites.
```

次に、リソースを変更したときどうなるか確かめてみます。
VPCのcidrを変更して再度テストを実行してみます。
テストを実行すると、失敗することがわかります。これは、Snapshotと現在の記述内容との間で差分が発生しているためです。

```test/cdk_test_study.test.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

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

    new ec2.Vpc(this, 'CdkTestStudyVpc', {
      // vpcのcidrを明示的に指定
      ipAddresses: ec2.IpAddresses.cidr("10.10.0.0/16"),
      maxAzs: 1,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'CdkTestStudyPrivate',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        }
      ]
    });
  }
}
```

```
$ npm test

> cdk_test_study@0.1.0 test
> jest

 FAIL  test/cdk_test_study.test.ts (5.403 s)
  ✕ snapshot (391 ms)

  ● snapshot

    expect(received).toMatchSnapshot()

    Snapshot name: `snapshot 1`

    - Snapshot  - 2
    + Received  + 2

    @@ -40,11 +40,11 @@
                  {
                    "Fn::GetAZs": "",
                  },
                ],
              },
    -         "CidrBlock": "10.0.0.0/24",
    +         "CidrBlock": "10.10.0.0/24",
              "MapPublicIpOnLaunch": false,
              "Tags": [
                {
                  "Key": "aws-cdk:subnet-name",
                  "Value": "CdkTestStudyPrivate",
    @@ -64,11 +64,11 @@
            },
            "Type": "AWS::EC2::Subnet",
          },
          "CdkTestStudyVpcD7D81CBC": {
            "Properties": {
    -         "CidrBlock": "10.0.0.0/16",
    +         "CidrBlock": "10.10.0.0/16",
              "EnableDnsHostnames": true,
              "EnableDnsSupport": true,
              "InstanceTenancy": "default",
              "Tags": [
                {

       7 |   const stack = new CdkTestStudyStack(app, "MyTestStack");
       8 |   const template = Template.fromStack(stack);
    >  9 |   expect(template.toJSON()).toMatchSnapshot();
         |                             ^
      10 | });
      11 |

      at Object.<anonymous> (test/cdk_test_study.test.ts:9:29)

 › 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:        5.554 s, estimated 6 s
Ran all test suites.
```

表示される差分が意図した変更である場合は、Snapshotを更新する必要があります。
`npm test -- -u`を実行すると、Snapshotが更新されテストは成功します。

```
$ npm test -- -u

> cdk_test_study@0.1.0 test
> jest -u

 PASS  test/cdk_test_study.test.ts
  CdkTestStudyStack
    ✓ snapshot (4 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        4.62 s, estimated 5 s
Ran all test suites.
```

## 感想
### メリット
- 数行のコードのみでテスト導入ができる
わずか数行で、変更差分が確認できるテストを導入できるのはメリットかなと思いました。
実装もシンプルで、CDKのプロジェクトの内容に関わらず同じ実装となるため、理解しやすいと感じました。

### デメリット
- 意図した変更であってもtestに失敗してしまう
変更内容が意図したものであってもSnapshotを更新する必要があるため、導入タイミングには工夫が必要そうだなと感じました。構築の初期段階で導入すると毎回スナップショットを更新する手間が発生するため、更新頻度が少なくなるタイミングで導入すると効果的かなと思います。

### まとめると
- 数行で実装できるので、簡単に導入できる
- コードを変更した場合毎回テストが失敗するので、運用に工夫が必要かも
- cdkのリファクタリングをする前に実装しておくと良さそう

# Assertionテスト
## 実装
次に、Assertionテストを実装していきます。
cdkのv2だと```aws-cdk-lib/assertions```を使って実装していきます。

## Assertionテストの書き方
Assertionテストはassertionsモジュールのメソッドを使い実装しています。
assertionsメソッドにはいくつか種類がありますが、本記事では以下の3つを使ってみます。
- resourceCountIs
テンプレート内にある特定のタイプのリソースの数を確認することができます。

- hasResourceProperties
指定のリソースが特定のプロパティを持っているかを確認することができます。

- resourcePropertiesCountIs
上記2つメソッドを合体させたようなもので、指定されたプロパティを持つ特定のタイプのリソースの数を確認することができます。

これらのメソッドを使い、以下のようにAssertionテストを実装してみました。

```test/cdk_test_study.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkTestStudyStack } from '../lib/cdk_test_study-stack';

describe('CdkTestStudyStack', () => {
  let template: Template;

  beforeAll(() => {
    const app = new cdk.App();
    const stack = new CdkTestStudyStack(app, "MyTestStack");
    template = Template.fromStack(stack);
  });

  // snapshot
  test('snapshot', () => {
    expect(template.toJSON()).toMatchSnapshot();
  })

  // assertion test
  // VPC
  test('VPCが1つ存在すること', () => {
    template.resourceCountIs("AWS::EC2::VPC", 1);
  });

  test('VPCのCIDRが10.0.0.0/16であること', () => {
    template.hasResourceProperties("AWS::EC2::VPC", 
      {
        CidrBlock: "10.0.0.0/16",
      }
    );
  });
  // Subnet
  test("Private Subnetが2つ存在すること", () => {
    template.resourcePropertiesCountIs("AWS::EC2::Subnet", {
      MapPublicIpOnLaunch: false,
    }, 2);
  });
})
```

## テスト実行
同じく```npm test```コマンドでテストを実行してみます。

```
$ npm test      

> cdk_test_study@0.1.0 test
> jest

 PASS  test/cdk_test_study.test.ts
  CdkTestStudyStack
    ✓ snapshot (4 ms)
    ✓ VPCが1つ存在すること (2 ms)
    ✓ VPCのCIDRが10.0.0.0/16であること (4 ms)
    ✓ Private Subnetが2つ存在する (1 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   1 passed, 1 total
Time:        3.728 s, estimated 5 s
Ran all test suites.
```

テストが成功していることを確認できました。続いて、testが失敗した際の挙動を確認するために、テストコードを以下のように変更してみます。

```test/cdk_test_study.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkTestStudyStack } from '../lib/cdk_test_study-stack';

describe('CdkTestStudyStack', () => {
  let template: Template;

  beforeAll(() => {
    const app = new cdk.App();
    const stack = new CdkTestStudyStack(app, "MyTestStack");
    template = Template.fromStack(stack);
  });

  // snapshot
  test('snapshot', () => {
    expect(template.toJSON()).toMatchSnapshot();
  })

  // assertion test
  // VPC

  // testが失敗するようにテストコードを変更
  test('VPCが2つ存在すること', () => {
    template.resourceCountIs("AWS::EC2::VPC", 2);
  });

  test('VPCのCIDRが10.0.0.0/16であること', () => {
    template.hasResourceProperties("AWS::EC2::VPC", 
      {
        CidrBlock: "10.0.0.0/16",
      }
    );
  });
  // Subnet
  test("Private Subnetが2つ存在すること", () => {
    template.resourcePropertiesCountIs("AWS::EC2::Subnet", {
      MapPublicIpOnLaunch: false,
    }, 2);
  });
})
```

実行結果は以下のようになります。
実行結果からどこのテストがどのように失敗しているかがわかります。

```
$ npm test

> cdk_test_study@0.1.0 test
> jest

 FAIL  test/cdk_test_study.test.ts
  CdkTestStudyStack
    ✓ snapshot (4 ms)
    ✕ VPCが1つ存在すること (1 ms)
    ✓ VPCのCIDRが10.0.0.0/16であること (4 ms)
    ✓ Private Subnetが2つ存在する (1 ms)

  ● CdkTestStudyStack › VPCが1つ存在すること

    Expected 2 resources of type AWS::EC2::VPC but found 1

      21 |   // VPC
      22 |   test('VPCが1つ存在すること', () => {
    > 23 |     template.resourceCountIs("AWS::EC2::VPC", 2);
         |              ^
      24 |   });
      25 |
      26 |   test('VPCのCIDRが10.0.0.0/16であること', () => {

      at Template.resourceCountIs (node_modules/aws-cdk-lib/assertions/lib/template.js:1:2656)
      at Object.<anonymous> (test/cdk_test_study.test.ts:23:14)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 3 passed, 4 total
Snapshots:   1 passed, 1 total
Time:        4.545 s
Ran all test suites.
```

また、cdkで定義しているリソースを変更し、deployする前にtestを走らせるとどのような挙動になるかも確認してみます。
VPCに作成するSubnetの数を1から2に変更し、cdk deployを実行する前にtestを実行してみます。

```test/cdk_test_study.test.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

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

    new ec2.Vpc(this, 'CdkTestStudyVpc', {
      // VPCのSubnetを1→2に変更する
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'CdkTestStudyPrivate',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        }
      ]
    });
  }
}
```

```
$ npm test

> cdk_test_study@0.1.0 test
> jest

 FAIL  test/cdk_test_study.test.ts
  CdkTestStudyStack
    ✕ snapshot (7 ms)
    ✓ VPCが1つ存在すること (1 ms)
    ✓ VPCのCIDRが10.0.0.0/16であること (6 ms)
    ✕ Private Subnetが1つ存在する (1 ms)

  ● CdkTestStudyStack › snapshot

    expect(received).toMatchSnapshot()

    Snapshot name: `CdkTestStudyStack snapshot 1`

    - Snapshot  -  0
    + Received  + 57

    @@ -62,10 +62,67 @@
                "Ref": "CdkTestStudyVpcD7D81CBC",
              },
            },
            "Type": "AWS::EC2::Subnet",
          },
    +     "CdkTestStudyVpcCdkTestStudyPrivateSubnet2RouteTableAssociation9690DDA4": {
    +       "Properties": {
    +         "RouteTableId": {
    +           "Ref": "CdkTestStudyVpcCdkTestStudyPrivateSubnet2RouteTableEAA6E089",
    +         },
    +         "SubnetId": {
    +           "Ref": "CdkTestStudyVpcCdkTestStudyPrivateSubnet2SubnetEF7C3034",
    +         },
    +       },
    +       "Type": "AWS::EC2::SubnetRouteTableAssociation",
    +     },
    +     "CdkTestStudyVpcCdkTestStudyPrivateSubnet2RouteTableEAA6E089": {
    +       "Properties": {
    +         "Tags": [
    +           {
    +             "Key": "Name",
    +             "Value": "MyTestStack/CdkTestStudyVpc/CdkTestStudyPrivateSubnet2",
    +           },
    +         ],
    +         "VpcId": {
    +           "Ref": "CdkTestStudyVpcD7D81CBC",
    +         },
    +       },
    +       "Type": "AWS::EC2::RouteTable",
    +     },
    +     "CdkTestStudyVpcCdkTestStudyPrivateSubnet2SubnetEF7C3034": {
    +       "Properties": {
    +         "AvailabilityZone": {
    +           "Fn::Select": [
    +             1,
    +             {
    +               "Fn::GetAZs": "",
    +             },
    +           ],
    +         },
    +         "CidrBlock": "10.0.1.0/24",
    +         "MapPublicIpOnLaunch": false,
    +         "Tags": [
    +           {
    +             "Key": "aws-cdk:subnet-name",
    +             "Value": "CdkTestStudyPrivate",
    +           },
    +           {
    +             "Key": "aws-cdk:subnet-type",
    +             "Value": "Isolated",
    +           },
    +           {
    +             "Key": "Name",
    +             "Value": "MyTestStack/CdkTestStudyVpc/CdkTestStudyPrivateSubnet2",
    +           },
    +         ],
    +         "VpcId": {
    +           "Ref": "CdkTestStudyVpcD7D81CBC",
    +         },
    +       },
    +       "Type": "AWS::EC2::Subnet",
    +     },
          "CdkTestStudyVpcD7D81CBC": {
            "Properties": {
              "CidrBlock": "10.0.0.0/16",
              "EnableDnsHostnames": true,
              "EnableDnsSupport": true,

      16 |   // snapshot
      17 |   test('snapshot', () => {
    > 18 |     expect(template.toJSON()).toMatchSnapshot();
         |                               ^
      19 |   })
      20 |
      21 |   // VPC

      at Object.<anonymous> (test/cdk_test_study.test.ts:18:31)

  ● CdkTestStudyStack › Private Subnetが2つ存在する

    Expected 1 resources of type AWS::EC2::Subnet but found 2

      48 |   // 1つのテストにまとめる場合
      49 |   test("Private Subnetが1つ存在する", () => {
    > 50 |     template.resourcePropertiesCountIs("AWS::EC2::Subnet", {
         |              ^
      51 |       MapPublicIpOnLaunch: false,
      52 |     }, 1);
      53 |   });

      at Template.resourcePropertiesCountIs (node_modules/aws-cdk-lib/assertions/lib/template.js:1:2886)
      at Object.<anonymous> (test/cdk_test_study.test.ts:50:14)

 › 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:       2 failed, 2 passed, 4 total
Snapshots:   1 failed, 1 total
Time:        4.697 s, estimated 5 s
Ran all test suites.
```

SnapshotテストとAssertionテスト（Private Subnetが1つ存在する）が落ちています。
このことから、実際のリソースに対してテストが行われるわけではなく、今のコードから生成されたCloudFormation Templateに対してテストしていることがわかります。

## 感想
### メリット
- アプリケーションのテストのように実装することができる
具体的なリソースやプロパティについてテストを書くことができ、アプリケーションコードのテストのように実装することができます。設計段階からどのようなプロパティを持つリソースを作成するかを記述することで、TDD（テスト駆動開発）のようなこともできそうだなと感じました。

### デメリット
- 冗長なテストや自明なテストになりやすい
リソースの数やプロパティの簡単なテストのみの実装だと、自明なテストとなってしまいます。そのためどの範囲までテストを記述するかを考えながら実装していく必要があります。また、詳細なテストを必要以上に書くと、テストのメンテナンスが困難となる場合があるので、そこは注意する必要があるかと思います。

### まとめると
- アプリケーション開発に慣れている人は好みそう
- どこまでテストを書くか決めるのが難しい（このあたりはテストのあり方みたいなところを勉強する必要がありそう）

# この記事のまとめ
この記事では、AWS CDKを使用したインフラストラクチャのコードに対するSnapshotテストとAssertionテストについて、実装してメリットデメリットについて考えてみました。
Snapshotテストは、簡単に実装でき変更差分を明確に示すのに役立ちますが、意図的な変更でもテストが失敗するため、実装するタイミングや運用方法を少し工夫する必要があります。一方、Assertionテストは、アプリケーションのテストのように実装することができTDDとの相性が良いですが、どの程度までテストを書くべきか悩むことがあります。
プロジェクトへの導入の流れとしては、
1. Snapshotテストを導入する
2. 簡単なAssertionテストを導入する
3. 開発や運用を進めていく中で、Assertionテストを充実させていく

という流れになるかなと思います。Assertionテストを使ってTDDっぽく開発を進める方法とか個人的気になりポイントです。

次の記事では、Assertionテストで使用するメソッドについて、もう少し深掘りしたいと思います。

# 参考
https://pages.awscloud.com/rs/112-TZM-766/images/CDK%E3%81%A7%E3%82%82%E3%83%86%E3%82%B9%E3%83%88%E3%81%8C%E3%81%97%E3%81%9F%E3%81%84.pdf

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html