AWS CDKのスナップショットテストでアセットを無視する方法

こんにちは。サービスグループの武田です。AWS CDKのスナップショットテストのアセットの差分を無視する方法を試してみました。
2021.03.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

このエントリはAWS CDK v1を対象としています。CDK v2については次のエントリを参照してください。

AWS CDK v2のスナップショットテストでアセットを無視する方法

こんにちは。サービスグループの武田です。

AWS CDKいいですよね(挨拶)。さて皆さんも普段からよく使っているCDKですが、ユニットテストはやっていますか?テストといってもさまざまで、目的によってやるべきことも変わってきます。

私がCDKのテストをする目的は安心してCDKをアップデートするためです。そしてそのために必要なテストは、ずばりスナップショットテストです。スナップショットテストの概要等については加藤のエントリを参照ください。

スナップショットテストをする目的や手法はよいとして、運用をしていると少し気になる点がありました。それがアセット(zipファイルなど)です。具体的にはLambda関数やECRのイメージをCDKでデプロイする場合、コードが変化するとアセットも変わります。CloudFormationとしてはアセットの名前(ハッシュ値)が変わります。そしてアセット名が変わるため、スナップショットも差分が発生し、そのままではテストは失敗します。

さて、スナップショットテストの目的として「安全にCDKをアップデートできる」を据えている場合、アセットの変化によるテストの失敗は本質的でないです。Lambda関数のコードを書き換えるたびに、スナップショットの更新が発生してしまいます。

というわけで、スナップショットの差分を無視する方法を試してみました。

先に結論

スナップショットテストで次のコードを書けばOKです!

const template = SynthUtils.toCloudFormation(stack);
template.Parameters = {};

Object.values(template.Resources).forEach((resource: any) => {
    if (resource?.Properties?.Code) {
    resource.Properties.Code = {};
    }
});

普通にスナップショットテストをしてみる

まずはスナップショットの差分が発生することを確認しましょう。プロジェクトを作成していきます。

$ mkdir cdk-ignore-test-assets && cd $_
$ npx cdk init --language typescript
$ npm install @aws-cdk/aws-lambda-python

次にLambda関数のファイルを作成します。

lib/lambda/hello/index.py

def handler(event, context):
    return "success"

スタックファイルを更新します。

lib/cdk-ignore-test-assets-stack.ts

import { PythonFunction } from '@aws-cdk/aws-lambda-python';
import * as cdk from '@aws-cdk/core';

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

    new PythonFunction(this, 'LambdaFunction',{
      entry: 'lib/lambda/hello',
    });
  }
}

テストファイルも更新します。

test/cdk-ignore-test-assets.test.ts

import '@aws-cdk/assert/jest';

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

import * as cdk from '@aws-cdk/core';
import * as CdkIgnoreTestAssets from '../lib/cdk-ignore-test-assets-stack';

test('Snapshot Test', () => {
    const app = new cdk.App();
    const stack = new CdkIgnoreTestAssets.CdkIgnoreTestAssetsStack(app, 'cdkIgnoreTestAssetsStack');
    const template = SynthUtils.toCloudFormation(stack);
  
    expect(template).toMatchSnapshot();
});

この状態で一度テストを実行します。

$ npm run test

取得したスナップショットを抜粋します。次のような内容が確認できます。

Object {
  "Parameters": Object {
    "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7ArtifactHash20764DA7": Object {
      "Description": "Artifact hash for asset \\"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\\"",
      "Type": "String",
    },
    "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3Bucket8509EBF9": Object {
      "Description": "S3 bucket for asset \\"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\\"",
      "Type": "String",
    },
    "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3VersionKey53249B77": Object {
      "Description": "S3 key for asset version \\"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\\"",
      "Type": "String",
    },
  },
  "Resources": Object {
    "LambdaFunctionBF21E41F": Object {
      "DependsOn": Array [
        "LambdaFunctionServiceRoleC555A460",
      ],
      "Properties": Object {
        "Code": Object {
          "S3Bucket": Object {
            "Ref": "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3Bucket8509EBF9",
          },
          "S3Key": Object {

はい、ParametersCodeの部分にアセット名がバッチリ入っています。Lambdaのコードを修正することで、テストが失敗することも確認してみましょう。

diff --git a/lib/lambda/hello/index.py b/lib/lambda/hello/index.py
index a2c6b78..f87f532 100644
--- a/lib/lambda/hello/index.py
+++ b/lib/lambda/hello/index.py
@@ -1,2 +1,2 @@
 def handler(event, context):
-    return "success"
+    return "success!!"

テストを実行します。

$ npm run test

> cdk-ignore-test-assets@0.1.0 test
> jest


 RUNS  test/cdk-ignore-test-assets.test.ts

 FAIL  test/cdk-ignore-test-assets.test.ts (5.211 s)
  ✕ Snapshot Test (1694 ms)

  ● Snapshot Test

    expect(received).toMatchSnapshot()

    Snapshot name: `Snapshot Test 1`

    - Snapshot  - 9
    + Received  + 9

    @@ -1,17 +1,17 @@
      Object {
        "Parameters": Object {
    -     "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7ArtifactHash20764DA7": Object {
    -       "Description": "Artifact hash for asset \"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\"",
    +     "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4ArtifactHash8AF75EE0": Object {
    +       "Description": "Artifact hash for asset \"c43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4\"",
            "Type": "String",
          },
    -     "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3Bucket8509EBF9": Object {
    -       "Description": "S3 bucket for asset \"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\"",
    +     "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4S3BucketED4B6130": Object {
    +       "Description": "S3 bucket for asset \"c43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4\"",
            "Type": "String",
          },
    -     "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3VersionKey53249B77": Object {
    -       "Description": "S3 key for asset version \"055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7\"",
    +     "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4S3VersionKey68728449": Object {
    +       "Description": "S3 key for asset version \"c43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4\"",
            "Type": "String",
          },
        },
        "Resources": Object {
          "LambdaFunctionBF21E41F": Object {
    @@ -19,11 +19,11 @@
              "LambdaFunctionServiceRoleC555A460",
            ],
            "Properties": Object {
              "Code": Object {
                "S3Bucket": Object {
    -             "Ref": "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3Bucket8509EBF9",
    +             "Ref": "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4S3BucketED4B6130",
                },
                "S3Key": Object {
                  "Fn::Join": Array [
                    "",
                    Array [
    @@ -32,11 +32,11 @@
                          0,
                          Object {
                            "Fn::Split": Array [
                              "||",
                              Object {
    -                           "Ref": "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3VersionKey53249B77",
    +                           "Ref": "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4S3VersionKey68728449",
                              },
                            ],
                          },
                        ],
                      },
    @@ -45,11 +45,11 @@
                          1,
                          Object {
                            "Fn::Split": Array [
                              "||",
                              Object {
    -                           "Ref": "AssetParameters055f0cea5d34e324276f5c5185a355379cdc231d655f5b367b41199712df56d7S3VersionKey53249B77",
    +                           "Ref": "AssetParametersc43a8b3794280a28f8b8d5d1039dbf7eb604c22f2752e4b627a81bd75d89ede4S3VersionKey68728449",
                              },
                            ],
                          },
                        ],
                      },

      11 |     const template = SynthUtils.toCloudFormation(stack);
      12 |
    > 13 |     expect(template).toMatchSnapshot();
         |                      ^
      14 | });
      15 |

      at Object.<anonymous>.test (test/cdk-ignore-test-assets.test.ts:13:22)

 › 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.91 s
Ran all test suites.

失敗しましたね。

アセットを無視するスナップショットテストをやってみる

それではアセットで差分が出ることは確認できましたので、このエントリの目的である、それを無視するスナップショットテストをやってみましょう。やり方は簡単で、変化する部分を固定値で上書きすればよいです。次のようにテストコードを書き換えます。

diff --git a/test/cdk-ignore-test-assets.test.ts b/test/cdk-ignore-test-assets.test.ts
index d78554a..a3f3722 100644
--- a/test/cdk-ignore-test-assets.test.ts
+++ b/test/cdk-ignore-test-assets.test.ts
@@ -9,6 +9,13 @@ test('Snapshot Test', () => {
     const app = new cdk.App();
     const stack = new CdkIgnoreTestAssets.CdkIgnoreTestAssetsStack(app, 'cdkIgnoreTestAssetsStack');
     const template = SynthUtils.toCloudFormation(stack);
+    template.Parameters = {};
+
+    Object.values(template.Resources).forEach((resource: any) => {
+      if (resource?.Properties?.Code) {
+        resource.Properties.Code = {};
+      }
+    });

     expect(template).toMatchSnapshot();
 });

先ほど見たように、アセット名はParameterResources.Properties.Codeに宣言されていますので、それを固定値(ここでは{})で上書きします。また今回はLambda関数が1つですが、複数あるのが普通ですので、forEachですべて書き換えてあげます。

一度スナップショットは削除して、テストを実行してみます。

$ rm test/__snapshots__/cdk-ignore-test-assets.test.ts.snap
$ npm run test

スナップショットを確認してみると、Object {}に置き換わっていることが確認できます。

exports[`Snapshot Test 1`] = `
Object {
  "Parameters": Object {},
  "Resources": Object {
    "LambdaFunctionBF21E41F": Object {
      "DependsOn": Array [
        "LambdaFunctionServiceRoleC555A460",
      ],
      "Properties": Object {
        "Code": Object {},

この状態で、先ほどと同じようにLambda関数のコードを書き換えて、再度テストしてみましょう。

$ npm run test

> cdk-ignore-test-assets@0.1.0 test
> jest


 RUNS  test/cdk-ignore-test-assets.test.ts
 PASS  test/cdk-ignore-test-assets.test.ts
  ✓ Snapshot Test (1079 ms)

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

アセットが変わってもテストをパスしました!

まとめ

アセットも含めてAWSの構成ではあるのですが、本質ではない部分で差分を検知されるのは、それはそれで手間になります。テストの目的に合わせて柔軟に対応していきましょう。