CDK素人がCDKのテスト方法について考えたのでまとめてみた

明日のCDKのテストどうする? いや、まだノー勉だわ
2021.11.08

はじめに

おはようございます、もきゅりんです。

長いお付き合いのある CloudFormation との関係が倦怠期に入ってきたため、CDK に手を出しております。

(きっと、またCFnとはラブラブになります)

CDK といえば、避けられないトピックとなるのが、テスト方法です。

皆さん、どのようにテストしてますでしょうか。

自分は AWS のインフラ構築は比較的よく行っていると思ってますが、アプリ開発における単体テストや結合テストとかはゼロ知識です。

そんな自分ですが、これまでインフラ環境構築をしてきた中で、自分だったらどのへんをチェックすべくテストするかなぁと考えてみたのが本稿です。 *1

テストをまともに導入した経験もなく、CDK とのお付き合いもまだ浅いため、おかしな点、考慮不足点はいくらでもあるかと思いますが、ゆるくご指摘頂けたら幸いです。

加えて、今回紹介する進め方では実環境での運用をしていませんので、どんな問題が発生するか、その対応策などを検討しきれてませんので、あしからず。

(自分が CDK を使ったテストする際の) 結論

前提として、構築リソースを過不足なく、すべてが想定されている通りに構築されているかどうかのテストはできない、と割り切っています。

そして、テスト自体のコストも小さくはないため、何でもかんでもテストをやれば良い、ではなく、できるだけテストのコストとテストの効果・影響をバランスしてテスト内容を決めていきたいと考えております。

  • チーム(複数人)で行っている場合、テスト内容について事前に議論して決定し、展開する。構築がひとまず完了した後も、より良いテスト方法について議論する。次回以降の利用時やテンプレート更新に対するテスト方法を改善する。
  • 環境構築におけるテストは、 まず Fine-grained assertions の利用を検討する。 Check number of resources でリソース数、Check existence of a resource で、後から修正できないようなパラメータや特に重要と考えられるパラメータ(上で記載した議論の論点) *2 のテストを行う。一通りテスト完了したら、スナップショットを生成しておく。
  • Props で渡す値によるロジックが含まれるコンストラクトがある場合、想定通りに動くか検証テストする。
  • 一度構築に利用したテンプレートを更新するときには、更新されるリソースをざっと確認できるスナップショットテストをベースに、Fine-grained assertions のテストで補完的にチェックする。
  • Verify (parts of) a template の NO_REPLACES は、テンプレート更新時にリソース置換がないかを確認するのに利用するのに良さそう。(ただ、自分の環境ではうまく環境変数の取得ができず、テストがうまくいかない...)

[参考] CDK テストのブログなど

テストの種類や基本的な使い方は、下記ドキュメントおよびブログを参照下さい。

テスト方法についての個人的な見解

スナップショット

事前に記録したスナップショットに対して、更新したテンプレートをテストし、受け入れるかどうかを判断します。

テンプレート上の設定項目が更新、追加されると必ず失敗します。変更が頻繁にあり、毎回意図した変更として受け入れが繰り返されるとなると、テストとしての意味が失われてくる気がします。

ドキュメントには、「リファクタリングの際の強力な援護です。」(リファクタリングによって内容が損なわれていないか担保されるため) とあり、また、「目的の結果を反映するようにスナップショットを変更し、テストに合格するまでコードを調整します」ともあります。

後者においては、確かにテンプレートを最終的な要件を満たすように手動で調整し、それに合格するようにコードを更新していくのもありですが、そもそもテンプレートを手動で調整となると、別途、改めて CFnテンプレートを作ってるようなもので、その記述をミスると本末転倒ですし、CDKのテストをするために、必要なリソースのCFnテンプレートを書く、だと、もうそのCFnテンプレート使えばよくないかしら?という気持ちです。

ひとまず完成したテンプレートのリファクタリング時における援護ツール、たまに更新される際のテンプレート全体のテストとしての利用を中心に考えると良いと思いました。

Fine-grained assertions

テンプレート全体、または各リソースタイプに対して局所的なテストなどの設定ができます。 構築当初は、頻繁にテンプレートの変更が発生することを考えると、スナップショットテストに比べて、テストの扱いやすさ、テストの意義から、このテストが中心になってくる、と考えました。

現在、下表のようなテストが設定できます。

テスト方法 概要 個人的利用度
Verify (parts of) a template テンプレート内容を入力し、マッチタイプを完全一致(EXACT), 置換なし(NO_REPLACES), 新規追加は許容するが更新なし(SUPERSET) のそれぞれでテストできます
Check existence of a resource リソースタイプに対して指定したプロパティがあるかどうかをテストします
Capturing values from a match 上記の応用的な使い方と考えます。リソースタイプに対して指定したプロパティから値を取得して、その値をテストで用います -
Check number of resources リソースタイプに対して指定したプロパティの数をテストします
Check existence of an output 出力内容に対して指定したプロパティがあるかどうかをテストします -

自分は、以下のような使い方を考えています。

  1. セキュリティ観点からチェックしておくべきパラメータ、後から変更が難しい、またはサービス稼働に影響があるパラメータは必ず Check existence of a resource する。必要であれば、 Capturing values from a match でテストする。
  2. 特定のリソースに対して、 Check number of resources でテストする。
  3. 更新時に必要に応じて、 Verify (parts of) a template の NO_REPLACES は便利そう。

検証テスト

Construct に渡される値に対して想定通りの挙動がされるかどうかをテストします。渡される何らかの値によるロジックが含まれている Construct があっても、必ずしも利用が必須になるわけではなく、テスト導入を検討するべきものだと考えました。

では、例えば、構築しようと考えている想定環境が下図だとして、上記を具体的に対応してみようと思います。

想定する環境

simple_aws_architecture

Fine-grained assertions のテスト対象

まず、 Fine-grained assertions でのテストを検討します。

例えば、以下のような点が検討されると想定します。(今回のテストでは、★の箇所をテストします。)

  • ネットワーク
    • VPCおよびサブネットCidrは指定した通りか (★)
    • サブネットの数は正しいか (★)
    • Nat gateway の数は正しいか
    • VPC Flow Logs は設定通りか
    • prefix リストは指定された拠点のものか
  • セキュリティグループ
    • セキュリティグループにssh接続等、 Ingressルールに any がないこと (★)
  • EC2
    • 指定のOSバージョンのAMIかどうか
    • 本番環境で t2, t3系を使っていないかどうか
    • 削除保護が有効かどうか
  • S3
    • S3バケットの公開設定がないこと (★)
    • S3バケットがサーバーサイド暗号化されていること (★)
  • RDS
    • RDSのパラメータグループおよびオプショングループは作成しているか (★)
    • Publicアクセスが無効か (★)
    • Performance Insights を (直近7日間で) 有効化しているか
    • Multi-AZかどうか
    • 削除保護が有効かどうか

以下は、書き方の一例です。

ネットワーク

VPCのCidr をチェックし、/24 のサブネットが7つできているかをテストします。

  expect(stack).to(
    haveResource('AWS::EC2::VPC', {
      CidrBlock: '10.100.0.0/16',
    })
  );
  expect(stack).to(
    countResourcesLike('AWS::EC2::Subnet', 7, {
      CidrBlock: stringLike('10.100.*.0/24'),
    })
  );

セキュリティグループ

ポート22のセキュリティ Ingress で any がないかをテストします。

  expect(stack).to(
    countResourcesLike('AWS::EC2::SecurityGroup', 0, {
      SecurityGroupIngress: arrayWith(
        objectLike({ CidrIp: '0.0.0.0/0', ToPort: 22, FromPort: 22 })
      ),
    })
  );

S3

パブリックブロックアクセスがされているかどうか、 AWSマネージドキーによるサーバサイド暗号化がされているかどうかをテストしています。

  expect(stack).to(
    haveResource('AWS::S3::Bucket', {
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
    })
  );
  expect(stack).to(
    haveResource('AWS::S3::Bucket', {
      BucketEncryption: objectLike({
        ServerSideEncryptionConfiguration: arrayWith(
          deepObjectLike({
            ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' },
          })
        ),
      }),
    })
  );

RDS

パラメータグループ、オプショングループを作成しているかどうか、パブリックアクセスが無効かどうかをテストします。

  expect(stack).to(
    haveResource('AWS::RDS::OptionGroup', { OptionConfigurations: anything() })
  );
  expect(stack).to(
    haveResource('AWS::RDS::DBParameterGroup', { Parameters: anything() })
  );
  expect(stack).to(
    haveResource('AWS::RDS::DBInstance', {
      PubliclyAccessible: exactValue(false),
    })
  );

スナップショットテストする

Fine-grained assertions テストをパスして、構築が一通り完了した後日、関係者からSSH接続する拠点が増えたので更新を依頼されたとします。

更新する前に、テストファイルを作成して対象のスナップショットを作成します。

import { SynthUtils } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import { NetworkStack, SecurityGroupStack  } from '../lib';

test('SecurityGroupStack', () => {
  const app = new cdk.App({ context: { SYSNAME: 'demoqrin', ENVTYPE: 'dev' } });

  const sysName: string = app.node.tryGetContext('SYSNAME');
  const envType: string = app.node.tryGetContext('ENVTYPE');

  const env = { account: process.env.ACCOUNT_ID, region: process.env.REGION};

  const netWorkStack = new NetworkStack(app, `${sysName}-${envType}-network`, {
    env: env,
  });
  const stack = new SecurityGroupStack(app, `${sysName}-${envType}-sg`, {
    vpc: netWorkStack.vpc,
    env: env,
  });
  expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
npm run build && npm test

テンプレートを更新した後で、テスト実施した結果です。

    - Snapshot  - 0
    + Received  + 7

    @@ -41,10 +41,17 @@
                  "Description": "Allow SSH Access",
                  "FromPort": 22,
                  "IpProtocol": "tcp",
                  "ToPort": 22,
                },
    +           Object {
    +             "CidrIp": "xx.xx.xxx.xxx/32",
    +             "Description": "Allow SSH Access",
    +             "FromPort": 22,
    +             "IpProtocol": "tcp",
    +             "ToPort": 22,
    +           },
    +
(省略)
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, 4 passed, 5 total
Tests:       1 failed, 4 passed, 5 total
Snapshots:   1 failed, 1 total
Time:        9.375 s, estimated 11 s

スナップショット以外の Fine-grained assertions テストはもちろんパスしていて、更新内容にも問題なさそうなので、スナップショットを更新してデプロイします。

スナップショットの使い方自体は スナップショットテスト · Jest を参照するとよいでしょう。

npm test -- -u
cdk deploy --all

こんな感じでマイ・CDKテストライフを進めていこうかな、と考えております。

さいごに

CDK のドキュメントにはテストの章があります。そこで、どんなテストがあるかは把握できます。

でもどんなときに、どのように利用するのが適切なのか、今ひとつ自分には分かりませんでした。

そして、軽くグーグル先生で調べてみても (使い方については紹介されていても) 使い分けや考え方についてまとまっているようなものは見当たりませんでした。 (いちいち書かんでも普通はだいたい分かるやろ、ということなのかもしれませんが)

という背景もあって、現状の自分だったら、こんな感じで考えるかな、というものをまとめてみました。

で、いや、違うんだな、これは、こうすべきなんだな、というのもあると思うので、是非教えて欲しいと思っている所存でございます。

以上です。

どなたかのお役に立てば幸いです。

脚注

  1. 本稿では L2 での環境構築を考えています。
  2. セキュリティの観点から外せないもの、変更が後からしにくくなる(作り直しやサーバ再起動が発生するなどの)パラメータなど