CDKのテストコードを書く側の人間になる[Unit Test編]

### はじめに

この記事は、CDKでもちゃんとテストを書く側の人間になりたい一人の青年の備忘録です。

テストコードを書くのは通常面倒で、ついつい蔑ろにしがちですが、やはり複雑なシステムを構築するには、簡単に繰り返し可能な自動テストが必要です。幸い、CDKでは、比較的簡単にテストを書けます。

CDKでは、大きく分けて以下の3つの観点のテストがあります。

1. __Unit Tests__ - ひとつひとつはちゃんと動くか?
2. __Deployment Tests__ - デプロイできるか?
3. __Infrasructure Tests__ - まとめて機能するか?

順番としては、まずUnit Testsを書き、続いてDeployment Test、そして最後にInfrastructure Testsを書くというふうに進めていくのがいいでしょう。

まず、この記事では、Unit Testについてまとめていきます！

### Unit Tests

単体テストは、コードの一部または小さな単位をテストするために使用されるものであり、1つのテストは、システム全体のごく一部しか検証できません。システムのすべての個々の部分をカバーするためには多くの単体テストを作成し、システム全体を検証する必要があります。

:::note info
以下のテストコードは、Jestフレームワークを使ったTypeScriptで書いています。
[Jest 公式](https://jestjs.io/ja/ "Jest")
:::
<br>
最初に、簡単な例として、以下ではS3のbucketが適切に作られることをテストしています。

```typescript:test.ts
import { Template } from '@aws-cdk/assertions-alpha';
import { App } from 'aws-cdk-lib';
import { MyTestStack } from '../src/my-test-stack.ts';

describe('S3 Bucket', () => {
  const app = new App();
  const stack = new MyTestStack(app, 'MyTestStack');
  const assert = Template.fromStack(stack);

  test('Has correct properties', () => {
    assert.hasResourceProperties('AWS::S3::Bucket', {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          {
            ServerSideEncryptionByDefault: {
              SSEAlgorithm: 'AES256',
            },
          },
        ],
      },
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
      VersioningConfiguration: {
        Status: 'Enabled',
      },
     });
    });
  });
```

- まず、以下で必要なものを全てimportしています。
```typescript
import { Template } from '@aws-cdk/assertions-alpha';
import { App } from 'aws-cdk-lib';
import { MyTestStack } from '../src/my-test-stack.ts';
```

- 続いて、describe関数でテストグループを作成します。
```typescript
describe('S3 Bucket', () => {

});
```

- 次に、app, stack, assertを作成し、変数として保存します。
```typescript
const app = new App();
const stack = new MyTestStack(app, 'MyTestStack');
const assert = Template.fromStack(stack);
```

- そして以下でテストが定義されます。
```typescript
test('has correct properties', () => {
    assert.hasResourceProperties('AWS::S3::Bucket', {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          {
            ServerSideEncryptionByDefault: {
              SSEAlgorithm: 'AES256',
            },
          },
        ],
      },
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
      VersioningConfiguration: {
        Status: 'Enabled',
      },
    });
})
```

- このテストでは、.hasResourceProperties関数を使用して、S3バケットが存在し、プロパティが設定されているかどうかを確認します。
    - [hasResourceProperties](https://docs.aws.amazon.com/cdk/api/v1/docs/assertions-readme.html "hasResourceProperties")

    - すべてのプロパティは、生成されたプロパティと比較され、一致する場合はテストに合格します。
    - これらはAWS CloudFormationリソースのプロパティで、CloudFormationファイルに書かれているのと同じです。
<br></br>
- ただし、このアプローチはリソースのプロパティのみをチェックし、リソース全体はチェックしません。

    - リソース定義全体をテストしたい場合は、hasResourceDefinition関数を使用できます。

    - これは、UpdateReplacePolicyやDeletionPolicyなど、リソース固有のプロパティ以外のものを検証したい場合に最適です。S3バケット(または状態を含むもの)でテストするのに非常に便利なものです。

```typescript
test('is retained after delete', () => {
    assert.hasResourceDefinition('AWS::S3::Bucket', {
      Properties: {
        BucketEncryption: {
          ServerSideEncryptionConfiguration: [
            {
              ServerSideEncryptionByDefault: {
                SSEAlgorithm: 'AES256',
              },
            },
          ],
        },
        PublicAccessBlockConfiguration: {
          BlockPublicAcls: true,
          BlockPublicPolicy: true,
          IgnorePublicAcls: true,
          RestrictPublicBuckets: true,
        },
        VersioningConfiguration: {
          Status: 'Enabled',
        },
      },
      UpdateReplacePolicy: 'Retain',
      DeletionPolicy: 'Retain',
    });
});
```
- この状態で ___npm run test___ を実行するとテストが開始されます
<br></br>
- 余談ですが、一般的にCDKのテストを書く際はテスト駆動開発の手法をとる必要はありません。テスト=>実装より、実装=>テストのほうがはるかに簡単だからです。
- 例えば、以下のようなコードを書いたとしましょう。

```typescript
new Bucket(this, 'Bucket', {
  blockPublicAccess: {
    blockPublicAcls: true,
    ignorePublicAcls: true,
    restrictPublicBuckets: true,
    blockPublicPolicy: true,
  },
  encryption: BucketEncryption.S3_MANAGED,
  versioned: true,
});
```
- 以前のように新しいテストを作成しますが、プロパティを正しくないものに置き換えると、すべての状況で失敗します。

```typescript
test('is retained after delete', () => {
    assert.hasResourceDefinition('AWS::S3::Bucket', {
      Not: "Valid",
    });
});
```

- 例えば、AWS::S3::Bucketには ___Not___ というプロパティはないため、このテストは以下のようなメッセージを出して失敗します。

```terminal
Error: None of 1 resources matches resource 'AWS::S3::Bucket' with {
  "$objectLike": {
    "Not": "Valid"
  }
}.
- Field Not missing in:
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "PublicAccessBlockConfiguration": {
      "BlockPublicAcls": true,
      "BlockPublicPolicy": true,
      "IgnorePublicAcls": true,
      "RestrictPublicBuckets": true
    }
  },
  "UpdateReplacePolicy": "Retain",
  "DeletionPolicy": "Retain"
}
```

- なんと、あとはこのエラーのコードブロックをコピーしてテストに貼り付けるだけで、簡単にテストコードを書くことができます！素晴らしすぎる涙。

```typescript
test('is retained after delete', () => {
    assert.hasResourceDefinition('AWS::S3::Bucket', {
      "Properties": {
        "PublicAccessBlockConfiguration": {
          "BlockPublicAcls": true,
          "BlockPublicPolicy": true,
          "IgnorePublicAcls": true,
          "RestrictPublicBuckets": true
        },
      },
      "UpdateReplacePolicy": "Retain",
      "DeletionPolicy": "Retain"
    });
})
```

- ただし、このままだと、コードにJSONがあるので、IDE(およびリンター)がエラーを投げる可能性があります。
    - エラーを削除するには、これを標準のTypescript/JavaScriptコードに変換する必要があります。
    - 「eslint」のようなツールがあれば、自動的に修正できます。
<br></br>

- またCDKテストには、生成されたCloudFormationリソースが既知の定義と一致していることを確認するというアプローチもあります。
    - このテストはスナップショットテストと呼ばれます。
    - これらの簡単なスナップショットテストを既存のコードにレトロフィットさせることは、非常に簡単です。
    - 実際、Jestを使用している場合には、簡単にスナップショットテストを実行できます。
    - [Jest スナップショット](https://jestjs.io/docs/snapshot-testing "snapshot")

```typescript
import { Template } from '@aws-cdk/assertions';
import { App } from '@aws-cdk/core';
import { MyStack } from '../src/main';

test('Snapshot', () => {
  const app = new App();
  const stack = new MyStack(app, 'TestStack');
  expect(Template.fromStack(stack)).toMatchSnapshot();
});
```

- この種のテストは非常に徹底的で書きやすいです。
    - テストが最初に実行されると、結果はスナップショットとして取得され、その後のテストは値をスナップショットと比較し、何らかの方法で変更された場合はテストに失敗します。
    - 後でコードを変更し、スナップショットを更新する必要がある場合は、特別な「更新」コマンドを実行できます。

```terminal
# jest cli directlry
jest --updateSnapshot

# projen style
yarn test:update
```

このようにJestは、シンプルなexpect(Template.fromStack(stack)).toMatchSnapshot();で単体テストの書き込みを簡単かつ包括的にします。

一方で、.hasResourcePropertiesと.hasResourceDefinitionを使用して特定のリソースをチェックするテストを作成する場合は、一度にコードの少数をテストするので、期待が明確であると言えます。

これらは、コードの詳細な期待を定義するため、CDKドキュメントでは「きめ細かなアサーション」と呼ばれます。

スタック全体のスナップショットテストを書くと、多くの時間を節約し、スタック全体をカバーできますが、コードの意図も失われ、作成予定のものを理解するためにスナップショットを確認する必要があります。

両者の使い分けに関しては、以下の公式の記事も参考になると思います！
[テスト構成](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/testing.html "test")

これらふたつをうまく組み合わせていいテストを書いていきましょう！


### おわりに

今回の記事では、CDKのUnit Testを概観しました。次回の記事では、Deployment TestsとInfrastructure Testsに関する記事を書いていきます！
ありがとうございました！

### 参考記事、書籍
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/testing.html

https://www.thecdkbook.com/