AWS CDK Assertionsモジュールを活用した10のテストパターン (Jest)

# はじめに

本記事は、[AWS CDK Advent Calendar 2022](https://qiita.com/advent-calendar/2022/cdk)の23日目になります。  
最近、CDKのテストを書くことが多いので、[Asseritonモジュール](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html)の主な使い方について、まとめてみました。  

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

CDKでは便利なコンストラクトが多数提供されており、CloudFormation(CFn)を直接作成するよりも設定を抽象化できるのが利点の1つです。一方、CDKで生成されるCFnテンプレートに**どのようなリソースが含まれているか把握しづらく**、悩ましいという声もよく聞きます。  
CDKには、CFnテンプレートの定義を確認できるテスト用のモジュールがあるので、それを活用することでハイレベルコンストラクト利用時でも、**セキュリティなどの要件を満たしながら開発可能**です。

私のチームでは本モジュールを使用して、CFnテンプレートのアサーションテストを積極的に書いています。暗黙知なのか、CDKでのテストコードはそれほどサンプルが多くないので、ご参考になれば幸いです！

# 前提

前提が長めなので、スキップしたい方は[本題](#awsリソースのプロパティチェック)からお読みください！

## 予備知識

[CDK Intro Workshop](https://cdkworkshop.com/ja/)でテストコードの実装フローを学べます。  
CDKでのテスト未経験の方は、以下をご参照ください。

https://cdkworkshop.com/ja/20-typescript/70-advanced-topics/100-construct-testing.html

また、各テストコードでは、CFnのテンプレートを併記しています。  
CFnの記法は[AWS CloudFormation ユーザーガイド テンプレート](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-whatis-concepts.html#w2aab5c15b7)をご参照ください。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-whatis-concepts.html#w2aab5c15b7

## 環境

- CDK v2.54.0
- Jest v27.5.1

## テスト対象のプロダクトコード

本記事では、以下リソースを保有するスタックについて、テストします。  

- S3バケット
- Lambda関数
- Configルール
- カスタムConstruct（Configルールのタグ付け用カスタムリソース）

<details><summary>プロダクトコードサンプル</summary>

```ts:./lib/sampleStack.ts
import { Duration, Stack, StackProps, Tags, RemovalPolicy } from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as config from "aws-cdk-lib/aws-config";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import { AttachConfigTag } from "./constructs/manageConfigTags";

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

    /** S3バケット **/
    const accessLogsBucket = new s3.Bucket(this, "AccessLogsBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.KMS_MANAGED,
      enforceSSL: true,
      lifecycleRules: [
        {
          expiration: Duration.days(365),
        },
      ],
    });

    const sampleBucket = new s3.Bucket(this, "SampleBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.KMS_MANAGED,
      lifecycleRules: [
        {
          expiration: Duration.days(30),
        },
      ],
      serverAccessLogsBucket: accessLogsBucket,
      enforceSSL: true,
      autoDeleteObjects: true,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    /** Lambda関数 **/
    const sampleFunction = new NodejsFunction(this, "SampleFunction", {
      functionName: "SampleFunction",
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      entry: "./lambda/sampleFunction.ts",
      timeout: Duration.seconds(30),
      environment: {
        TARGET_S3_BUCKET: sampleBucket.bucketName,
      },
    });
    sampleBucket.grantReadWrite(sampleFunction);
    Tags.of(sampleFunction).add("HogeKey", "hogehoge");
    Tags.of(sampleFunction).add("Test", "true");

    /** Configルール **/
    const s3KmsRule = new config.ManagedRule(
      this,
      "S3DefaultEncryptionKmsRule",
      {
        identifier: config.ManagedRuleIdentifiers.S3_DEFAULT_ENCRYPTION_KMS,
        configRuleName: "S3DefaultEncryptionKmsRule",
      }
    );

    /** カスタムConstruct（Configルールのタグ付け用カスタムリソース） **/
    new AttachConfigTag(this, "S3DefaultEncryptionKmsRuleTag", {
      configArn: `arn:aws:config:${Stack.of(this).region}:${
        Stack.of(this).account
      }:config-rule/S3DefaultEncryptionKmsRule`,
      // 上記はCase4のserializedJson紹介用であり、デプロイ順が考慮されないので以下推奨
      // configArn: s3KmsRule.configRuleArn,
      configTags: [
        {
          Key: "Test",
          Value: "true",
        },
      ],
    });
  }
}
```

本筋ではないため、上記カスタムConstruct「AttachConfigTag」の詳細は割愛します。
</details>

Assertionモジュール紹介用なので、各リソースの関連にあまり脈絡がないのはご容赦ください…。

## テストコードの共通部

テストの準備作業として、[beforeAll](https://jestjs.io/ja/docs/api#beforeallfn-timeout)でスタックとテンプレートを生成しています。  

```ts:./test/sampleStack.test.ts
import { Template, Match, Capture } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { SampleStack } from "../lib/sampleStack";

describe("SampleStackのテスト", () => {
  let template: Template;

  // テストの確認対象となるスタック・テンプレートを生成
  beforeAll(() => {
    const app = new cdk.App();
    const stack = new SampleStack(app, "SampleStack", {
      env: { account: "012345678901", region: "ap-northeast-1" },
    });
    template = Template.fromStack(stack);
  });

  // 各テストコードで期待値を記述
  /** 省略 **/
});
```

::: note warn
元々、[beforeEach](https://jestjs.io/ja/docs/api#beforeeachfn-timeout)を使用していたのですが、執筆時点ではテスト時に[NodejsFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html)のbundlingを止める機能がなく、テスト時間爆増の問題があったため、[beforeAll](https://jestjs.io/ja/docs/api#beforeallfn-timeout)に変更しました。bundlingのissueについては以下をご参照ください。  
[(assertions): argument to skip bundling of assets #18125](https://github.com/aws/aws-cdk/issues/18125)
:::

# AWSリソースのプロパティチェック

前置きが長くなりましたが、ここからが本題です。以下では、[公式のリファレンス](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html#special-matchers)で紹介されている各テスト用クラスを、AWSリソースに置き換えて紹介します。（公式ではAWSリソースの例ではなく、CFnに存在しない仮タイプのサンプルが掲載されています。）

## Case1. 特定リソースのプロパティを確認したい

シンプルにリソースのプロパティを確認するパターンです。  
以下は、Lambda関数のタイムアウト値を確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "FunctionName": "SampleFunction",
    "Timeout": 30,
    /** 省略 **/
  },
}
```

```ts: テストコード
  test("Sample関数のタイムアウト値が30秒である", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      FunctionName: "SampleFunction",
      Timeout: 30,
    });
  });
```

上記の[Template.hasResourceProperties](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#haswbrresourcewbrpropertiestype-props)を使用することで、指定したCFnリソースタイプのプロパティを確認できます。CDKのアサーションテストでは、まずhasResourcePropertiesを使っていくのが基本です。  

::: note info
リソース毎に異なる設定値を与えられるのが一般的ですので、**リソース名と合わせて、プロパティを且つ条件でチェックする**のがポイントです。  
上記の場合、AWS::Lambda::Functionが2種以上生成されていても、FunctionNameを併せて指定することで**テストしたいLambda関数を絞り込み**できます。  
:::

## Case2. プロパティ中の配列における何れかの要素を確認したい

特定のプロパティが配列になっており、配列から何れかの要素を確認したいパターンです。  

以下は、Lambda関数に複数付与されたタグの中から、Testタグの値を確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "FunctionName": "SampleFunction",
    "Tags": [
      {
        "Key": "HogeKey",
        "Value": "hogehoge"
      },
      {
        "Key": "Test",
        "Value": "true"
      }
    ],
    /** 省略 **/
  },
}
```

```ts: テストコード
test("Sample関数のTestタグにtrueが付与されている", () => {
  template.hasResourceProperties("AWS::Lambda::Function", {
    FunctionName: "SampleFunction",
    Tags: Match.arrayWith([
      {
        Key: "Test",
        Value: "true",
      },
    ]),
  });
});
```

上記の[Match.arrayWith](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Match.html#static-arraywbrwithpattern)を使用することで、任意の要素を確認できます。

## Case3. プロパティ中のオブジェクトにおける何れかのKeyを確認したい

特定のプロパティがオブジェクトになっており、オブジェクトから何れかのKeyを確認したいパターンです。  

以下は、ConfigルールのSourceから、SourceIdentifierの値を確認する例です。  

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::Config::ConfigRule",
  "Properties": {
    "Source": {
      "Owner": "AWS",
      "SourceIdentifier": "S3_DEFAULT_ENCRYPTION_KMS"
    },
    "ConfigRuleName": "S3DefaultEncryptionKmsRule"
  }
}
```

```ts: テストコード
test("S3DefaultEncryptionKmsのConfigルールが作成される", () => {
  template.hasResourceProperties("AWS::Config::ConfigRule", {
    ConfigRuleName: "S3DefaultEncryptionKmsRule",
    Source: Match.objectLike({
      SourceIdentifier: "S3_DEFAULT_ENCRYPTION_KMS",
    }),
  });
});
```

※Owner Keyも確認すべきケースかもしれませんが、あくまでobjectLikeのサンプルなのでご容赦ください。

上記の[Match.objectLike](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Match.html#static-objectwbrlikepattern)を使用することで、任意のObject Keyを確認できます。  
なお、[Case2](#case2-プロパティ中の配列における何れかの要素を確認したい)の[Match.arrayWith](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Match.html#static-arraywbrwithpattern)と組み合わせて、[以下](#arraywithとobjectlikeの組み合わせ例)のように確認することもできます。

### arrayWithとobjectLikeの組み合わせ例

以下は、S3バケットポリシーでSSL通信が強制されているか確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::S3::BucketPolicy",
  "Properties": {
  "Bucket": {
    "Ref": "SampleBucket0123ABCD"
  },
  "PolicyDocument": {
    "Statement": [
      {
        "Action": "s3:*",
        "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
        },
        "Effect": "Deny",
        "Principal": {
          "AWS": "*"
        },
        /** 省略 **/
      },
      {
        "Action": [
          "s3:DeleteObject*",
          "s3:GetBucket*",
          "s3:List*"
       ],
       "Effect": "Allow",
       /** 省略 **/
      },
    ],
    "Version": "2012-10-17"
  }
}
```

```ts: テストコード
test("SampleバケットでSSL通信が強制されている", () => {
  template.hasResourceProperties("AWS::S3::BucketPolicy", {
    Bucket: {
      Ref: Match.stringLikeRegexp("SampleBucket"),
    },
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Principal: {
            AWS: "*",
          },
          Effect: "Deny",
          Action: "s3:*",
          Condition: {
            Bool: {
              "aws:SecureTransport": "false",
            },
          },
        }),
      ]),
    },
  });
});
```

上記の[Match.stringLikeRegexp](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Match.html#static-stringwbrlikewbrregexppattern)では、対象のKeyを正規表現で確認することができます。(本例ではBucketのRef Keyに、Construct IDの"SampleBucket"が含まれているか確認しています。)

## Case4. 文字列としてエンコードされたJSONを確認したい

CFnテンプレートでは、文字列としてJSONをエンコードする場合があります。文字列のままだとテストしづらいため、テストコード上ではKey-Value形式で確認したいパターンです。  

以下は、Configルールに付与するタグの値を確認する例です。 

::: note warn
※2022年12月時点のCFnでは、[Configルールのプロパティ](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-config-configrule.html#aws-resource-config-configrule-properties)にTagsがないため、[AwsCustomResource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html)でタグを付与しています。  
:::

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Resources": {
    "Type": "Custom::AWS",
    "Properties": {
      "Create": "{\"service\":\"ConfigService\",\"action\":\"tagResource\",\"parameters\":{\"ResourceArn\":\"arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule\",\"Tags\":[{\"Key\":\"Test\",\"Value\":\"true\"}]},\"physicalResourceId\":{\"id\":\"xxx\"}}",
      "Update": /** 省略 **/
    }
  }
}
```

```ts: テストコード
test("スタック作成時、ConfigルールにTestタグが付与される", () => {
  template.hasResourceProperties("Custom::AWS", {
    Create: Match.serializedJson({
      service: "ConfigService",
      action: "tagResource",
      parameters: {
        ResourceArn: "arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule",
        Tags: [
          {
            Key: "Test",
            Value: "true",
          }
        ],
      },
      physicalResourceId: Match.anyValue(),
    }),
  });
});
```

上記の[Match.serializedJson](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Match.html#static-serializedwbrjsonpattern)を使用することで、Key-Value形式でアサーションテストを書けます。  

::: note warn
注意点として、以下のように文字列中のJSONで他リソースを動的に参照するパターンでは、serializedJsonで整形されません。
:::

```json: ResourceArnを動的参照するCFnテンプレート
{
  "Create": {
    "Fn::Join": [
      "",
      [
        "{\"service\":\"ConfigService\",\"action\":\"tagResource\",\"parameters\":{\"ResourceArn\":\"",
        {
          "Fn::GetAtt": [
            "S3DefaultEncryptionKmsRule0123ABC",
            "Arn"
          ]
        },
        "\",\"Tags\":[{\"Key\":\"Test\",\"Value\":\"true\"}]},\"physicalResourceId\":{\"id\":\"xxx\"}}"
      ]
    ]
  }
}
```

実際のCFnテンプレートは上記のようになることが大半です。  
CDKプロダクトコードの記述上、serializedJsonを活用するには**動的なリソース間参照を使えない**ため、本ケースの出番はやや少ない印象です。  

## Case5. テンプレートの一部を抽出してプロパティをチェックしたい

テンプレートの中から、条件に一致するリソースを抽出して、プロパティを確認したいパターンです。テストコードはネストが深くなりやすいので、ネスト数を減らしたいときに活用できます。

以下は、Lambda関数の環境変数を確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "Environment": {
     "Variables": {
        "TARGET_S3_BUCKET": {
          "Ref": "SampleBucket0123ABC"
        },
        "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1"
      }
    },
    /** 省略 **/
  },
}
```

```ts: テストコード
test("Sample関数の環境変数でS3バケットが参照されている", () => {
  const envS3bucket = new Capture();
  template.hasResourceProperties("AWS::Lambda::Function", {
    FunctionName: "SampleFunction",
    Environment: {
      Variables: {
        TARGET_S3_BUCKET: envS3bucket
      }
    }
  });

  expect(envS3bucket.asObject()).toEqual({
    Ref: expect.stringContaining("SampleBucket") as string,
  });
});
```

上記の[Capture](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Capture.html)を使用することで、抽出したプロパティを確認できます。  

::: note warn
注意点として、Captureしたプロパティの確認にMatchクラスは使えないため、Jestの[Expect](https://jestjs.io/ja/docs/expect)でアサーションする必要があります。
:::

```ts: 失敗例のテストコード
  expect(envCapture.asObject()).toEqual({
    Ref: Match.stringLikeRegexp("SampleBucket"),
  });
```

```txt: 失敗例のテスト結果
✕ Sample関数の環境変数でS3バケットが参照されている (7 ms)

  expect(received).toEqual(expected) // deep equality

  - Expected  - 4
  + Received  + 1

    Object {
  -   "Ref": StringLikeRegexpMatch {
  -     "name": "stringLikeRegexp",
  -     "pattern": "SampleBucket",
  -   },
  +   "Ref": "SampleBucket7F6F8160",
    }
```

なお、Captureを使うテストパターンは、[CDK Intro Workshop](https://cdkworkshop.com/ja/20-typescript/70-advanced-topics/100-construct-testing/1000-assertion-test.html#lambda-%e9%96%a2%e6%95%b0%e3%81%ae%e3%81%9f%e3%82%81%e3%81%ae%e3%83%86%e3%82%b9%e3%83%88%e3%81%ae%e4%bd%9c%e6%88%90)でも紹介されていました。

## Case6. 特定のリソースタイプでプロパティを一括確認したい

テンプレートの特定リソースタイプで、プロパティが全て同じか確認したいパターンです。  

::: note info
Case1～5までは、テンプレート内で1つでも条件に一致するテンプレートがあれば、テストが通過します。一方、本ケースの場合は**1つでも一致しないリソースがある**と、テストが失敗します。  
個人的には、CDKの[Aspects](https://docs.aws.amazon.com/cdk/v2/guide/aspects.html)や[CFN Guard](https://github.com/aws-cloudformation/cloudformation-guard)に近いイメージを持っています。 
::: 

以下は、全S3バケットのブロックパブリックアクセスを確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    "PublicAccessBlockConfiguration": {
      "BlockPublicAcls": true,
      "BlockPublicPolicy": true,
      "IgnorePublicAcls": true,
      "RestrictPublicBuckets": true
    },
    /** 省略 **/
  },
},
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "PublicAccessBlockConfiguration": {
      "BlockPublicAcls": true,
      "BlockPublicPolicy": true,
      "IgnorePublicAcls": true,
      "RestrictPublicBuckets": true
    },
    /** 省略 **/
  },
}
```

```ts: テストコード
test("全てのバケットでブロックパブリックアクセスが有効化されている", () => {
  template.allResourcesProperties("AWS::S3::Bucket", {
    PublicAccessBlockConfiguration: {
      BlockPublicAcls: true,
      BlockPublicPolicy: true,
      IgnorePublicAcls: true,
      RestrictPublicBuckets: true,
    },
  });
});
```

上記の[Template.allResourcesProperties](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#allwbrresourceswbrpropertiestype-props)を使用することで、プロパティを一括確認できます。  

## Case7. リソースでプロパティ以外の要素を確認したい

各リソースで、プロパティ以外の要素も確認したいパターンです。  
例えば、スタック削除時のリソース保持を決めるDeletionPolicyはProperties外で保持されているため、本パターンを適用できます。

以下は、S3バケットのDeletionPolicyを確認する例です。  

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    /** 省略 **/
  },
  "DeletionPolicy": "Retain",
}
```

```ts: テストコード
test("スタック削除時にアクセスログバケットが保持される", () => {
  template.hasResource("AWS::S3::Bucket", {
    Properties: {
      AccessControl: "LogDeliveryWrite"
    },
    DeletionPolicy: "Retain",
  });
});
```

上記の[Template.hasResource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#haswbrresourcetype-props)を使用することで、Properties以外の要素も確認できます。  
Case6のように一括でチェックしたい場合は、以下のように[Template.allResources](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#allwbrresourcestype-props)で実現できます。

```ts: テストコード
test("スタック削除時に保持されるカスタムリソースが存在しない", () => {
  template.allResources("Custom::AWS", {
    DeletionPolicy: Match.not("Retain"),
  });
});
```

# AWSリソース数チェック

以下では、テンプレートで定義されているリソース数を確認します。  

## Case8. リソースタイプ毎のリソース数を確認したい

テンプレートで定義されたリソースタイプ毎のリソース数を確認したいパターンです。  
様々なリソースを作成するL2・L3コンストラクトなど、CDKのプロダクトコードで明記していないリソースも含めて把握できます。

以下は、Lambda関数の数を確認する例です。  

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
// NodeJSFucntionコンストラクトで作成
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
},
// s3.BucketコンストラクトのautoDeleteObjects指定により作成
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
},
// AwsCustomResourceコンストラクトで作成（カスタムConstruct内で定義）
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
}
```

```ts: テストコード
test("Lambda関数が3つ存在する", () => {
  template.resourceCountIs("AWS::Lambda::Function", 3);
});
```

上記の[Template.resourceCountIs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#resourcewbrcountwbristype-count)を使用することで、リソース数を確認できます。  

::: note info
今回のケースだと、Lambda関数を意図的に定義しているのはNodejsFunctionのみですが、**他のコンストラクトでもLambda関数が作成されている**ことが分かります。
:::

## Case9. 設定準拠しているリソース数を確認したい

指定したプロパティで生成されるリソース数を確認したいパターンです。 

::: note info
活用方法は様々ですが、リソース数を1で指定すると**設定重複したリソースが存在しないか**、把握できます。
:::

以下は、アクセスログ用S3バケットの数を確認する例です。

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    /** 省略 **/
  },
}
```

```ts: テストコード
test("アクセスログ用S3バケットが1つ存在する", () => {
  template.resourcePropertiesCountIs("AWS::S3::Bucket", {
    AccessControl: "LogDeliveryWrite",
  }, 1);
});
```

上記の[Template.resourcePropertiesCountIs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#resourcewbrpropertieswbrcountwbristype-props-count)を使用することで、指定したプロパティで生成されるリソース数を確認できます。

# テンプレート抽出

以下では、Templateのfindメソッドを使用し、テンプレートから抽出したオブジェクトに対して、アサーションテストを実行します。  
findResourcesのみを紹介しておりますが、その他にもCfnOutputを抽出する[findOutputs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#findwbroutputslogicalid-props)メソッドなどが用意されているので、詳細はリファレンスをご参照ください。

## Case10. リソース名ではなく、Construct IDでプロパティをチェックしたい

以下の[CDKベストプラクティス](https://aws.amazon.com/jp/blogs/news/best-practices-for-developing-cloud-applications-with-aws-cdk/)に従ってリソース名を指定しない場合、[Case1](#case1-特定リソースのプロパティを確認したい)のようにリソースを絞れないため、テストを書き辛いです。  

> 自動で生成されるリソース名を使用し、物理的な名前を使用しない

::: note warn
例えば、S3バケットのライフサイクルを確認する以下例では、テンプレートにバケット名が出力されないため、どのバケットが期待通りなのか分かりません。
:::

```ts: 確認対象（CDKで出力想定のCFnテンプレート）
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "LifecycleConfiguration": {
      "Rules": [
        {
          "ExpirationInDays": 30,
          "Status": "Enabled"
        }
      ]
    },
    // 開発者がプロダクトコードでBucketNameを記述しない場合、テンプレートに出力されない
    /** 省略 **/
  },
}
```

```ts: テストコード
test("何れかのバケットでライフサイクルルールが30日である", () => {
  template.hasResourceProperties("AWS::S3::Bucket", {
    LifecycleConfiguration: {
      Rules: [{
        ExpirationInDays: 30,
        Status: "Enabled"
      }]
    },
    // テストでバケット名を指定できないので、どのバケットが一致しているのか分からない
  });
});
```

本ケースは、条件に一致する特定のリソースタイプを全て抽出し、**期待するConstruct IDが存在するか**確認するパターンになります。  

以下は、ライフサイクルが30日のS3バケットを抽出し、論理ID一覧に"SampleBucket"のConstructIDを含むか確認する例です。

```ts: テストコード
test("Sampleバケットのライフサイクルルールが30日である", () => {
  // 1. テンプレートからライフサイクルが30日のS3バケットを抽出
  const extractedTemplate = template.findResources("AWS::S3::Bucket", {
    Properties: {
      LifecycleConfiguration: {
        Rules: [{
          ExpirationInDays: 30,
          Status: "Enabled"
        }]
      },
    },
  });

  // 2. 抽出したテンプレートから論理IDの一覧を出力
  const extractedLogicalIds = Object.keys(extractedTemplate);
  // （論理ID一覧の例) [ 'SampleBucket0123ABCD', ... ]

  // 3. 期待値として、"SampleBucket"のConstruct IDを含む論理IDを定義
  const expectedLogicalId = expect.stringMatching("SampleBucket");
  
  // 4. 2の論理ID一覧に、3の期待値が含まれているか確認
  expect(extractedLogicalIds).toEqual(
    expect.arrayContaining([expectedLogicalId])
  );
});
```

::: note info
論理IDは「"CDKで記述したConstruct ID" + "自動付与のサフィックス"」で構成されるため、Construct IDでテストするには一工夫必要です。

上記では、まず[Template.findResources](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions.Template.html#findwbrresourcestype-props)を使って、テンプレートからプロパティ条件に一致するリソースを抽出しています。抽出後は、Jestのメソッドを使って一致するConstruct IDが存在するか確認しています。Jest部分の詳細は[expect.stringMatching](https://jestjs.io/ja/docs/expect#expectstringmatchingstring--regexp)のサンプルもご参照ください。
:::

なお、ベストプラクティスに反する形ではありますが、私のチームでは環境識別子を含める独自の命名規則クラスで、**物理名を原則指定**しています。この辺りの話は以下が参考になりましたので、ご参照ください。

- [AWS CDK Conference Japan で「それでも俺はAWS CDKが作るリソースに物理名を付けたい」というタイトルで登壇しました #jawsug #cdkconf](https://dev.classmethod.jp/articles/aws-cdk-conference-japan-resource-name-can-be-a-physical-name/)
- [「それでも俺はAWS CDKが作るリソースに物理名を付けたい」アーキテクトが辿り着いた、リソース名のベタープラクティス](https://logmi.jp/tech/articles/326696)

# おわりに

具体的な10のテストパターンと共に、CDK Asseritonモジュールの使い方を紹介しました。  
本記事の作成中にも、Asseritonモジュールの新たな発見があり、自身にとっても良い学びとなりました。「こんな使い方があるよ！」や「こっちの方がよくない？」というご意見、ウェルカムですのでぜひコメントなどいただければと思います。

「いいね」を押していただけると、今後の執筆モチベーションに繋がりますので、何卒よろしくお願いします…！

# （付録）テストコードサンプルの全量

本記事で紹介したテストコードの全量 + α を以下に掲載します。

<details><summary>テストコードサンプル</summary>

```ts:./test/sampleStack.test.ts
import { Template, Match, Capture } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { SampleStack } from "../lib/sampleStack";

describe("SampleStackのテスト", () => {
  let template: Template;

  beforeAll(() => {
    const app = new cdk.App();
    const stack = new SampleStack(app, "SampleStack", {
      env: { account: "012345678901", region: "ap-northeast-1" },
    });
    template = Template.fromStack(stack);
  });

  describe("リソース数の確認", () => {
    test("S3バケットが2つ存在する", () => {
      template.resourceCountIs("AWS::S3::Bucket", 2);
    });

    test("アクセスログ用S3バケットが1つ存在する", () => {
      template.resourcePropertiesCountIs("AWS::S3::Bucket", {
        AccessControl: "LogDeliveryWrite",
      }, 1);
    });

    test("Lambda関数が3つ存在する", () => {
      template.resourceCountIs("AWS::Lambda::Function", 3);
    });

    test("ConfigRuleが1つ存在する", () => {
      template.resourceCountIs("AWS::Config::ConfigRule", 1);
    });

    test("カスタムリソースが1つ存在する", () => {
      template.resourceCountIs("Custom::AWS", 1);
    });
  });

  describe("Lambdaのプロパティチェック", () => {
    test("Sample関数のタイムアウト値が30秒である", () => {
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Timeout: 30,
      });
    });

    test("Sample関数のTestタグにtrueが付与されている", () => {
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Tags: Match.arrayWith([
          {
            Key: "Test",
            Value: "true",
          },
        ]),
      });
    });

    test("Sample関数の環境変数でS3バケットが参照されている", () => {
      const envS3bucket = new Capture();
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Environment: {
          Variables: {
            TARGET_S3_BUCKET: envS3bucket,
          },
        },
      });

      expect(envS3bucket.asObject()).toEqual({
        Ref: expect.stringContaining("SampleBucket") as string,
      });
    });
  });

  describe("S3のプロパティチェック", () => {
    test("全てのバケットでブロックパブリックアクセスが有効化されている", () => {
      template.allResourcesProperties("AWS::S3::Bucket", {
        PublicAccessBlockConfiguration: {
          BlockPublicAcls: true,
          BlockPublicPolicy: true,
          IgnorePublicAcls: true,
          RestrictPublicBuckets: true,
        },
      });
    });

    test("全てのバケットでSSE-KMS暗号化が有効化されている", () => {
      template.allResourcesProperties("AWS::S3::Bucket", {
        BucketEncryption: {
          ServerSideEncryptionConfiguration: [
            {
              ServerSideEncryptionByDefault: {
                SSEAlgorithm: "aws:kms",
              },
            },
          ],
        },
      });
    });

    test("スタック削除時にアクセスログバケットが保持される", () => {
      template.hasResource("AWS::S3::Bucket", {
        Properties: {
          AccessControl: "LogDeliveryWrite",
        },
        DeletionPolicy: "Retain",
      });
    });

    test("何れかのバケットでライフサイクルルールが30日である", () => {
      template.hasResourceProperties("AWS::S3::Bucket", {
        LifecycleConfiguration: {
          Rules: [
            {
              ExpirationInDays: 30,
              Status: "Enabled",
            },
          ],
        },
      });
    });

    test("Sampleバケットのライフサイクルルールが30日である", () => {
      const extractedTemplate = template.findResources("AWS::S3::Bucket", {
        Properties: {
          LifecycleConfiguration: {
            Rules: [
              {
                ExpirationInDays: 30,
                Status: "Enabled",
              },
            ],
          },
        },
      });
      const extractedLogicalIds = Object.keys(extractedTemplate);
      const expectedLogicalId = expect.stringMatching("SampleBucket") as string;
      expect(extractedLogicalIds).toEqual(
        expect.arrayContaining([expectedLogicalId])
      );
    });

    test("SampleバケットでSSL通信が強制されている", () => {
      template.hasResourceProperties("AWS::S3::BucketPolicy", {
        Bucket: {
          Ref: Match.stringLikeRegexp("SampleBucket"),
        },
        PolicyDocument: {
          Statement: Match.arrayWith([
            Match.objectLike({
              Principal: {
                AWS: "*",
              },
              Effect: "Deny",
              Action: "s3:*",
              Condition: {
                Bool: {
                  "aws:SecureTransport": "false",
                },
              },
            }),
          ]),
        },
      });
    });
  });

  describe("Configのプロパティチェック", () => {
    test("S3DefaultEncryptionKmsのConfigルールが作成される", () => {
      template.hasResourceProperties("AWS::Config::ConfigRule", {
        ConfigRuleName: "S3DefaultEncryptionKmsRule",
        Source: Match.objectLike({
          SourceIdentifier: "S3_DEFAULT_ENCRYPTION_KMS",
        }),
      });
    });
  });

  describe("カスタムリソースのプロパティチェック", () => {
    test("スタック作成時、ConfigルールにTestタグが付与される", () => {
      template.hasResourceProperties("Custom::AWS", {
        Create: Match.serializedJson({
          service: "ConfigService",
          action: "tagResource",
          parameters: {
            ResourceArn:
              "arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule",
            Tags: [
              {
                Key: "Test",
                Value: "true",
              },
            ],
          },
          physicalResourceId: Match.anyValue(),
        }),
      });
    });

    test("スタック削除時に保持されるカスタムリソースが存在しない", () => {
      template.allResources("Custom::AWS", {
        DeletionPolicy: Match.not("Retain"),
      });
    });
  });
});
```

</details>