CDKのインフラテストで本番を3年守った話|AssertionsとSnapshotの使い分け

「インフラにテストって必要?」と思ってた自分が、本番のセキュリティグループ事故をきっかけにCDKテストを本気で整備した3年間の記録。AssertionsとSnapshotの使い分けから実装パターンまで。

CDKのテストって、最初は「え、インフラにテスト書くの?」って思ってませんでしたか?

正直、僕も最初はそうだった。CloudFormationのYAMLを目視でチェックして、ステージング環境にデプロイして確認する、という運用を2年くらいやってた。それが変わったのは、本番環境でセキュリティグループのインバウンドルールが意図せず 0.0.0.0/0 になってたのを、デプロイ後3日後に気づいたときだ。あの冷や汗は忘れられない。

それ以来、うちのチームはCDKのテストを本気で整備し始めて、今では3年以上運用している。その間に見えてきた「本当に効くテスト戦略」を共有したい。

そもそもCDKテストの位置づけを整理する

CDKのテストには大きく2種類ある。Assertions(Fine-grained Assertions)とSnapshotテストだ。最初はこの2つを混同していてハマった。

ざっくり言うと、

  • Assertions: 「このS3バケットに暗号化が設定されているか」など、特定のリソースやプロパティを検証するテスト
  • Snapshot: CloudFormationテンプレート全体を丸ごとスナップショットとして保存し、変更差分を検出するテスト

役割がかなり違う。Assertionsは「意図した設定が守られているか」を継続的に検証するもので、Snapshotは「意図しない変更を検出する」セーフティネットに近い。どちらか一方だけで完結させようとするとキツい。

flowchart LR
    A[CDKコード変更] --> B{テスト実行}
    B --> C[Assertions]
    B --> D[Snapshot]
    C --> E[セキュリティ/設定の正確性を確認]
    D --> F[意図しない差分を検出]
    E --> G{OK?}
    F --> G
    G -->|Pass| H[CDK Synth]
    G -->|Fail| I[修正]
    H --> J[Deploy]

CDK v2の現在(2026年時点ではv2.180台まで来ている)、aws-cdk-lib/assertionsモジュールが標準で含まれているので追加インストールは不要だ。これは地味に嬉しいポイント。

Assertionsテストの実装パターン

実際に動かしているコードを見てもらう方が早い。うちのチームが標準的に書いているパターンを紹介する。

import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { AppStack } from '../lib/app-stack';

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

  beforeEach(() => {
    const app = new cdk.App();
    const stack = new AppStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'ap-northeast-1' },
    });
    template = Template.fromStack(stack);
  });

  test('S3バケットにSSE-S3暗号化が設定されている', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          {
            ServerSideEncryptionByDefault: {
              SSEAlgorithm: 'AES256',
            },
          },
        ],
      },
    });
  });

  test('S3バケットのパブリックアクセスがブロックされている', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
    });
  });

  test('RDSインスタンスが本番サブネットグループを使用している', () => {
    template.hasResourceProperties('AWS::RDS::DBInstance', {
      DBSubnetGroupName: Match.objectLike({
        Ref: Match.stringLikeRegexp('.*DBSubnetGroup.*'),
      }),
      MultiAZ: true,
    });
  });

  test('Lambda関数のメモリが512MB以上に設定されている', () => {
    const lambdas = template.findResources('AWS::Lambda::Function', {
      Properties: {
        MemorySize: Match.anyValue(),
      },
    });

    Object.values(lambdas).forEach((lambda: any) => {
      const memorySize = lambda.Properties.MemorySize;
      // ランタイム確認用Lambdaなど除外
      if (memorySize !== undefined) {
        expect(memorySize).toBeGreaterThanOrEqual(512);
      }
    });
  });

  test('セキュリティグループにSSHのインバウンドが存在しない', () => {
    const sgs = template.findResources('AWS::EC2::SecurityGroup');
    Object.values(sgs).forEach((sg: any) => {
      const ingress = sg.Properties.SecurityGroupIngress || [];
      const sshRule = ingress.find(
        (rule: any) => rule.FromPort === 22 || rule.ToPort === 22
      );
      expect(sshRule).toBeUndefined();
    });
  });

  test('リソース数が想定範囲内である', () => {
    template.resourceCountIs('AWS::Lambda::Function', 3);
    template.resourceCountIs('AWS::S3::Bucket', 2);
  });
});

Matchユーティリティが地味に便利で、Match.objectLike()で部分一致、Match.stringLikeRegexp()で正規表現マッチができる。CDKが内部で生成するリソース名はハッシュが付くので、完全一致じゃなく正規表現を使うことが多い。最初はここでよくハマった。

hasResource vs hasResourceProperties、ここは混同しやすい

よく混同するのだが、

  • hasResource: リソース全体(DependsOn, DeletionPolicyなども含む)を検証したいとき
  • hasResourceProperties: Propertiesフィールドだけを検証したいとき(ほとんどのケースはこっち)
// DeletionPolicyを確認したい場合はhasResource
template.hasResource('AWS::RDS::DBInstance', {
  DeletionPolicy: 'Retain',
  Properties: {
    MultiAZ: true,
  },
});

ここを間違えると「テストが通っているのにDeletionPolicyが設定されていない」みたいな事故が起きる。実際うちでも一回やらかした。

Snapshotテストの正しい使い方

Snapshotテストは便利だけど、使い方を間違えると「意味のないテスト」になる。これは正直に言いたい。

import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { AppStack } from '../lib/app-stack';

describe('AppStack Snapshot', () => {
  test('スタック全体のスナップショットが一致する', () => {
    const app = new cdk.App();
    const stack = new AppStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'ap-northeast-1' },
    });
    const template = Template.fromStack(stack);
    expect(template.toJSON()).toMatchSnapshot();
  });
});

これだけ見ると単純なんだけど、問題はスナップショットをむやみに --updateSnapshot で更新してしまう運用にある。

うちのチームで3ヶ月くらい、「Snapshotが失敗した → とりあえずupdate → PRマージ」という流れが暗黙的にできてしまっていた時期があった。それだと意図しないリソース追加や削除も気づかずにマージされてしまう。

xychart-beta
    title "Snapshot更新の頻度とインシデント発生件数(月次)"
    x-axis ["2025-01", "2025-04", "2025-07", "2025-10", "2026-01", "2026-04", "2026-07"]
    y-axis "件数" 0 --> 12
    bar [8, 10, 9, 3, 2, 1, 1]
    line [4, 5, 3, 1, 0, 0, 0]

▲ Snapshot更新(棒グラフ)を意識的に減らしていったら、設定変更起因のインシデント(折れ線)も減った。相関があるとは断言できないけど、少なくともレビューが丁寧になったのは確かだ。

Snapshot更新をレビュー必須にする運用

うちではGitHub ActionsのPRコメントにSnapshot差分を自動出力するようにした。

# .github/workflows/cdk-test.yml
name: CDK Test

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run CDK tests
        run: npm test -- --ci 2>&1 | tee test-output.txt

      - name: Check for snapshot changes
        run: |
          if grep -q 'obsolete snapshots\|snapshot\|written' test-output.txt; then
            echo "SNAPSHOT_CHANGED=true" >> $GITHUB_ENV
          fi

      - name: Comment snapshot warning
        if: env.SNAPSHOT_CHANGED == 'true'
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '⚠️ **Snapshotに変更があります。**\n\n意図した変更かどうかを必ずレビューしてください。CloudFormationリソースの追加・削除・変更が含まれている可能性があります。'
            })

Snapshotが変わったらPRにコメントが飛ぶ、それだけの仕組みなんだけど、「あ、これ意図してない変更だ」という発見が月1〜2回はある。シンプルだけどかなり効いている。

対象インフラのアーキテクチャとテスト対象

参考に、うちのチームが実際に運用しているインフラ構成と、どこにテストを当てているかをまとめておく。

graph TB
    subgraph Internet
        User[ユーザー]
        CF[CloudFront]
    end

    subgraph AWS_Account[AWSアカウント ap-northeast-1]
        subgraph VPC[VPC 10.0.0.0/16]
            subgraph PublicSubnet[パブリックサブネット]
                ALB[Application Load Balancer]
                NAT[NAT Gateway]
            end

            subgraph AZ_A[AZ: ap-northeast-1a]
                subgraph PrivateSubnet_A[プライベートサブネット-A]
                    ECS_A[ECS Fargate Task]
                    Lambda_A[Lambda Function]
                end
                subgraph DataSubnet_A[データサブネット-A]
                    RDS_Primary[RDS Aurora Primary]
                end
            end

            subgraph AZ_C[AZ: ap-northeast-1c]
                subgraph PrivateSubnet_C[プライベートサブネット-C]
                    ECS_C[ECS Fargate Task]
                    Lambda_C[Lambda Function]
                end
                subgraph DataSubnet_C[データサブネット-C]
                    RDS_Replica[RDS Aurora Replica]
                end
            end
        end

        S3_Assets[S3: 静的アセット]
        S3_Logs[S3: ログバケット]
        SQS[SQS Queue]
        SSM[SSM Parameter Store]
        KMS[KMS Key]
        CWLogs[CloudWatch Logs]
    end

    User --> CF
    CF --> S3_Assets
    CF --> ALB
    ALB --> ECS_A
    ALB --> ECS_C
    ECS_A --> RDS_Primary
    ECS_C --> RDS_Primary
    ECS_A --> SQS
    SQS --> Lambda_A
    Lambda_A --> RDS_Primary
    Lambda_C --> RDS_Replica
    ECS_A --> SSM
    RDS_Primary --> RDS_Replica
    Lambda_A --> CWLogs
    ECS_A --> CWLogs
    S3_Logs -.-> CWLogs
    KMS -.-> S3_Assets
    KMS -.-> S3_Logs
    NAT --> Internet

この構成で、テスト対象の優先度をまとめるとこうなる。

テスト対象テスト種別優先度理由
S3バケット暗号化Assertions🔴 高データ漏洩リスク直結
セキュリティグループ設定Assertions🔴 高ネットワーク境界
RDS MultiAZ設定Assertions🔴 高可用性要件
KMSキーのローテーションAssertions🔴 高コンプライアンス要件
IAMポリシーの最小権限Assertions🟡 中権限昇格リスク
Lambda環境変数Assertions🟡 中シークレット混入防止
スタック全体の構成Snapshot🟡 中意図しない変更検出
リソース数Assertions🟢 低誤削除検出

個人的には、優先度「高」の4項目だけでも最初に揃えておくと、夜中に冷や汗をかく確率がぐっと下がると思っている。

cdk-nagとの組み合わせが2026年の標準になっている

AssertionsとSnapshotだけでカバーしきれないのが、「CDKのベストプラクティス違反」の検出だ。CDK Aspects・Nag本番導入3ヶ月で気づいた、自動セキュリティ検証の現実でも詳しく書いたけど、cdk-nagとの組み合わせが今は標準になってきている。

import * as cdk from 'aws-cdk-lib';
import { Template, Annotations, Match } from 'aws-cdk-lib/assertions';
import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag';
import { AppStack } from '../lib/app-stack';

describe('CDK Nag + Assertions 統合テスト', () => {
  let app: cdk.App;
  let stack: AppStack;
  let template: Template;

  beforeEach(() => {
    app = new cdk.App();
    stack = new AppStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'ap-northeast-1' },
    });

    // cdk-nagのチェックを適用
    cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));
    template = Template.fromStack(stack);
  });

  test('AwsSolutionsのエラーが存在しない', () => {
    // 意図的に抑制したルール以外のエラーがないことを確認
    const errors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-.*')
    );
    expect(errors).toHaveLength(0);
  });

  test('S3ライフサイクルポリシーが設定されている', () => {
    template.hasResourceProperties('AWS::S3::Bucket', {
      LifecycleConfiguration: {
        Rules: Match.arrayWith([
          Match.objectLike({
            Status: 'Enabled',
          }),
        ]),
      },
    });
  });
});

ただ正直なところ、cdk-nagのチェックを全部通すのは現実的じゃないケースも多い。「この環境ではVPCフローログ不要」「このバケットはパブリックアクセスが必要」みたいなケースでNagSuppressionsを使って意図的に抑制するんだけど、その抑制自体をコードレビューでちゃんと見ることの方が本質的に大事だと感じている。

テストの実行速度が気になる人へ

CDKのテストはスタック数が増えてくるとsynthの時間がジワジワと効いてくる。うちも30スタックくらいになったときにテスト全体で4分かかるようになって、CI待ちがストレスになった。

その対処として、Jestの--testPathPatternで対象を絞る運用と、変更のあったスタックのみテストする仕組みを入れた。

// package.json
{
  "scripts": {
    "test": "jest",
    "test:changed": "jest --testPathPattern=$(git diff --name-only HEAD | grep '\\.test\\.ts' | tr '\n' '|' | sed 's/|$//')",
    "test:snapshot:update": "jest --updateSnapshot",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "roots": ["<rootDir>/test"],
    "testMatch": ["**/*.test.ts"],
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "maxWorkers": "50%"
  }
}

maxWorkers: "50%" でCPUの半分を使うようにしたら、シングルスレッドより体感で40%くらい速くなった。CI環境でのリソース競合が気になる場合は数値を下げて調整するといい。

実際のテスト実行結果のイメージはこんな感じ:

 PASS  test/app-stack.test.ts (8.234s)
  AppStack Assertions
    ✓ S3バケットにSSE-S3暗号化が設定されている (45ms)
    ✓ S3バケットのパブリックアクセスがブロックされている (12ms)
    ✓ RDSインスタンスが本番サブネットグループを使用している (38ms)
    ✓ Lambda関数のメモリが512MB以上に設定されている (89ms)
    ✓ セキュリティグループにSSHのインバウンドが存在しない (156ms)
    ✓ リソース数が想定範囲内である (8ms)
  AppStack Snapshot
    ✓ スタック全体のスナップショットが一致する (1204ms)

Test Suites: 1 passed, 1 passed total
Tests:       7 passed, 7 passed total
Time:        8.901s

8秒台で全テストが通るこの速度感は快適で、PRのたびにサクッと確認できる。

なお、CDK Pipelinesを使ったマルチアカウントのCI/CD設計についてはCDK Pipelines、マルチアカウント運用で見えてきた落とし穴と本当の使い方にまとめているので、組み合わせて参照してほしい。

まとめ

3年間CDKテストを本番運用してきて、本当に効いた知見をまとめるとこうなる。

  1. AssertionsとSnapshotは役割が違う。 Assertionsは「意図した設定の継続保証」、Snapshotは「意図しない変更の検出」。両方必要で、どちらかに偏ると死角が生まれる

  2. Snapshot更新はレビュー必須にする。 CI/CDのPRコメントで自動通知する仕組みを入れると、無意識のスナップショット更新が格段に減る

  3. cdk-nagと組み合わせるのが2026年の標準。 AwsSolutionsChecksでカバーできるベストプラクティス違反は広いが、全部通すのは現実的でないので抑制ルールのレビューを丁寧にやることが大事

  4. セキュリティに直結するプロパティはAssertionsで必ず検証する。 S3の暗号化・パブリックアクセスブロック、SGのインバウンドルール、RDSのMultiAZはテストがないと「なんとなく大丈夫だろう」で見落とされがち

  5. テスト速度はJestのmaxWorkers設定と--testPathPatternで改善できる。 スタック数が増えてきたら早めに手を打った方がいい

次のアクションとしては、まだCDKテストを書いていないリポジトリがあれば、まずS3バケットの暗号化とパブリックアクセスブロックだけのAssertionsから始めてみてほしい。「インフラのテストを書く」という文化への第一歩として、体感的にも導入しやすいはずだ。

CDK Migrate で200個のAWSリソースを一元化した話のように、既存リソースのCDK移行と並行してテストを整備するケースにも、今回紹介したパターンはそのまま使えるので参考にしてほしい。

皆さんのチームではどんなテスト戦略を取っていますか?特に「Snapshotの更新をどうコントロールしているか」は各チームで工夫が違いそうで、ぜひ聞いてみたい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事