AWS CDK における単体テストの応用 TIPS

AWS CDK における単体テストの応用的な TIPS を紹介します。

目次

目次


AWS CDK における単体テストの使い所

以前、AWS 公式ウェブマガジン「builders.flash」にて、『AWS CDK における単体テストの使い所を学ぶ』という記事を寄稿させていただきました。

aws.amazon.com


こちらの記事では、CDK における単体テストの考え方・使うタイミング・使い方など、基本的な内容を紹介しました。


そしてさらに今回の記事では、CDK の単体テストにおける応用的な TIPS を 2 点紹介します。

  1. 機能フラグの実環境との統一
  2. バンドルのスキップ


1. 機能フラグの実環境との統一

機能フラグとは

AWS CDK では機能フラグを使用して、AWS CDK における破壊的変更を伴う機能変更をオプトイン形式で有効化することができます。これにより、既存の CDK コードの振る舞いを担保しつつ、任意のタイミングで CDK の新しい機能を反映させることができます。(詳細は公式ドキュメントをご覧ください。)


この機能フラグは、cdk.json 内のcontextセクションに設定することができます。

(またそれ以外にも、例えば CDK コード内の AppStack の props の context だったり、cdk deploy --context xx=yy などのコマンドラインの引数でも機能フラグを設定することができます。)

{
  "context": {
    "@aws-cdk/aws-iam:minimizePolicies": true
  }
}


機能フラグは、CDK プロジェクトを作成するとき、つまり cdk init を実行したときに cdk.json に自動的に追加されます。具体的には、その時点で CDK に導入されている機能フラグがデフォルト値とともに設定されます。

新しく機能フラグを伴う機能変更が CDK の新バージョンに追加されても、基本的にはその機能フラグが既存 CDK プロジェクトの cdk.json に自動で追加されたりはしません。つまり、CDK のバージョンアップを行ったとしても、既存の CDK コードの振る舞いは変わりません。(稀にデフォルトで有効になる機能フラグもあります。)

あくまで既存プロジェクトで機能フラグを有効にしたい場合は、自分で明示的に cdk.json に新しい機能フラグを設定することになります。


単体テストにおける機能フラグの振る舞い

そんな機能フラグですが、基本的には cdk.json で設定されることで適用されます。


しかし、CDK における単体テスト、つまりassertionsモジュールのTemplateクラスによるテストでは、cdk.jsonは読まれない仕組みになっています。


というのも、CDK は内部的には CDK CLI と CDK App に分かれており、cdk.json の読み込みは CDK CLI で行われます。しかし、単体テストは CDK CLI を通さずに独自で CDK App を呼びます。これにより、単体テストでは cdk.json を読まずに CDK の合成処理が行われます。

そのため単体テストでは、cdk.jsonで有効にされている機能フラグもすべて無効になってしまいます。

※明示的に有効にしなくてもデフォルトで有効になる機能フラグも一部あります。


機能フラグの違いによって発生するテンプレートの差異

上記の通り、単体テストを行う際には機能フラグがすべて無効になってしまいます。つまり、実際にデプロイされるテンプレートとテストで生成されるテンプレートで、機能フラグによる構成の差異が生じてしまう可能性があります。


例えば cdk.json で、@aws-cdk/aws-iam:minimizePoliciesという機能フラグにtrueが設定されていたとしましょう。

そこで、以下のような CDK コードがあるとします。

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

    const role = new cdk.aws_iam.Role(this, 'Role', {
      assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
    });

    role.addToPrincipalPolicy(
      new cdk.aws_iam.PolicyStatement({
        actions: ['s3:GetObject'],
        resources: ['arn:aws:s3:::my-bucket/*'],
      }),
    );

    role.addToPrincipalPolicy(
      new cdk.aws_iam.PolicyStatement({
        actions: ['s3:PutObject'],
        resources: ['arn:aws:s3:::my-bucket/*'],
      }),
    );
  }
}


@aws-cdk/aws-iam:minimizePoliciestrueの場合、同じリソースに対する異なる複数のアクションが定義されたり、同じステートメントが複数回生成された場合、CDK が自動的にそれらを統合してくれます。

つまり、上記の CDK コードからは以下のように、1 つのAction配列にs3:GetObjects3:PutObjectが記述されます。

  "RoleDefaultPolicy5FFB7DAB": {
   "Type": "AWS::IAM::Policy",
   "Properties": {
    "PolicyDocument": {
     "Statement": [
      {
       "Action": [
        "s3:GetObject",
        "s3:PutObject"
       ],
       "Effect": "Allow",
       "Resource": "arn:aws:s3:::my-bucket/*"
      }
     ],
      ...


一方で、単体テストで、以下のようなスナップショットテストを記述したとしましょう。

const getTemplate = (): Template => {
  const app = new App();
  const stack = new CdkSampleStack(app, 'CdkSampleStack');
  return Template.fromStack(stack);
};

test('Snapshot', () => {
  const template = getTemplate();
  expect(template.toJSON()).toMatchSnapshot();
});


このテストによって生成されるテンプレートは以下のようになります。先ほどのテンプレートと違って、s3:GetObjects3:PutObjectそれぞれ違うステートメントとして記述されています。

    "RoleDefaultPolicy5FFB7DAB": {
      "Properties": {
        "PolicyDocument": {
          "Statement": [
            {
              "Action": "s3:GetObject",
              "Effect": "Allow",
              "Resource": "arn:aws:s3:::my-bucket/*",
            },
            {
              "Action": "s3:PutObject",
              "Effect": "Allow",
              "Resource": "arn:aws:s3:::my-bucket/*",
            },
          ],
          ...
      },
      "Type": "AWS::IAM::Policy",
    },


これ自体はそこまで大きな違いではないかもしれません。しかし、根本的に処理の内容が変わるような機能フラグもあるため、実際にデプロイした結果思わぬ違いが発生してしまう可能性もあるでしょう。


機能フラグを統一する方法

ここでは、実際にデプロイされるテンプレートとテストで生成されるテンプレートで機能フラグを統一する方法をご紹介します。


実環境とテストで機能フラグを揃えたい場合、App などの props に明示的に cdk.json から読み込んだ context を渡す必要があります。

そのため、以下のgetContextのように、実際の cdk.json を読み込んでcontextを返す関数を作成します。そして、それをAppの props のcontextに渡します。


// 実際の cdk.json を読み込んで`context`を返す関数
const getContext = (): Record<string, any> => {
  const cdkJsonPath = path.join(__dirname, '..', 'cdk.json');
  const cdkJson = JSON.parse(fs.readFileSync(cdkJsonPath, 'utf-8'));
  return cdkJson.context || {};
};

const getTemplate = (): Template => {
  const app = new App({
    context: {
      ...getContext(), // ここでそれを呼ぶ
    },
  });
  const stack = new CdkSampleStack(app, 'CdkSampleStack');
  return Template.fromStack(stack);
};


これにより、生成されるテンプレートは以下のように実際にデプロイされるものと同じ結果になります。これで、テストの信頼性がより向上するでしょう。

      "RoleDefaultPolicy5FFB7DAB": {
        "Properties": {
          "PolicyDocument": {
            "Statement": [
              {
-               "Action": "s3:GetObject",
-               "Effect": "Allow",
-               "Resource": "arn:aws:s3:::my-bucket/*",
-             },
-             {
-               "Action": "s3:PutObject",
+               "Action": [
+                 "s3:GetObject",
+                 "s3:PutObject",
+               ],
                "Effect": "Allow",
                "Resource": "arn:aws:s3:::my-bucket/*",
              },
            ],


2. バンドルのスキップ

NodejsFunction のバンドルについて

例えば、NodejsFunctionという、TypeScript で記述したコードを自動的に JavaScript にバンドルして Node.js ランタイムの Lambda 関数を作成するコンストラクトがあります。


このコンストラクトでは、CDK プロジェクト環境のnode_modulesに esbuild が入っている場合(つまり、package.jsondevDependenciesに esbuild が入っている場合)に esbuild でバンドルします。

※esbuild が入っていない場合、もしくはbundlingforceDockerBundlingプロパティがtrueの場合は、Docker によるバンドルが実行されます。しかし、esbuild の方が軽量のため、esbuild を使う人が多いのではないでしょうか。

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

    new NodejsFunction(this, 'NodejsWithoutDocker', {
      entry: path.join(__dirname, 'lambda', 'index.ts'),
      handler: 'handler',
      runtime: Runtime.NODEJS_22_X,
    });
  }
}


バンドルをスキップする方法

CDK での単体テストでは、上記のような Lambda コードのバンドルも行われます。

しかし、もしアプリケーションでのテストなどですでに Lambda コードのバンドルテストを行っているような場合、CDK でのテストではバンドル処理を省いてテスト時間を短縮したい人もいるのではないでしょうか。


実は、以下のように context のBUNDLING_STACKSに空の配列を指定することで、すべてのスタックでバンドルをスキップすることができます。

const getTemplate = (): Template => {
  const app = new App({
    context: {
      [BUNDLING_STACKS]: [], // これ
    },
  });
  const stack = new CdkSampleStack(app, 'CdkSampleStack');
  return Template.fromStack(stack);
};

test('Snapshot', () => {
  const template = getTemplate();
  expect(template.toJSON()).toMatchSnapshot();
});


しかし、esbuild でなく Docker でバンドルしているケースでは、バンドルがスキップされません。この場合は、esbuild を使うと良いでしょう。

  • esbuild をインストールしていない
  • bundling.forceDockerBundlingtrueを指定している
  • Code.fromDockerBuild()を使用している
export class CdkSampleStack2 extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new NodejsFunction(this, 'NodejsWithDocker', {
      entry: path.join(__dirname, 'lambda', 'index.ts'),
      handler: 'handler',
      runtime: Runtime.NODEJS_22_X,
      bundling: {
        forceDockerBundling: true,
      },
    });

    new Function(this, 'FromDockerBuild', {
      code: Code.fromDockerBuild(path.join(__dirname, '..')),
      handler: 'handler',
      runtime: Runtime.NODEJS_22_X,
    });
  }
}


イメージアセットのビルドは単体テストでは走らない

上記のケースは、Lambda コードをファイルアセットとして S3 にアップロードし、それを使用する Lambda の話でした。(runtimeには、それぞれのコードに対応するランタイムを指定しています。)


一方で、Docker Lambda や ECS などのためにイメージアセット(Docker イメージ)を使用する場合、そもそも単体テストでは Docker ビルドは実行されません。

そのため、このような場合ではBUNDLING_STACKSを指定する必要はありません。

  • Code.fromAssetImage()
  • DockerImageFunction
  • DockerImageAsset
export class CdkSampleStack2 extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new Function(this, 'FromAssetImage', {
      code: Code.fromAssetImage(path.join(__dirname, '..')),
      handler: Handler.FROM_IMAGE,
      runtime: Runtime.FROM_IMAGE,
    });

    new DockerImageFunction(this, 'DockerImageFunction', {
      code: DockerImageCode.fromImageAsset(path.join(__dirname, '..')),
    });

    new DockerImageAsset(this, 'DockerImageAsset', {
      directory: path.join(__dirname, '..'),
    });
  }
}


その理由を簡単に説明します。

Lambda コードのバンドルはそのコンストラクトの中、つまり CDK App の中でバンドルが走ります。一方、Docker イメージなどのイメージアセットのビルドは CDK App ではなく CDK CLI で走ります。

また、CDK の単体テストCDK CLI を通さず、直接 CDK App を呼んでいます。

そのため、CDK CLI で行われるイメージアセットのビルドは単体テストでは行われないのです。


参考

上記で挙げた 2 点の TIPS は、以下の登壇の派生になります。ご興味があればぜひご覧ください。

AWS CDK の仕組み

2025/07/12 開催「AWS CDK Conference Japan 2025」での登壇資料です。

speakerdeck.com


AWS CDK 実践的アプローチ N 選

2025/06/25,26 開催「AWS Summit Japan 2025」での登壇資料です。

speakerdeck.com


最後に

AWS CDK における単体テストの応用的な TIPS を紹介しました。

最初に紹介した基本的な TIPS の記事をベースに、今回の応用的な TIPS もぜひ参考にしてみてください。