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

2024-11-01
コミュニティ通信

Author : 後藤 健太 (AWS DevTools Hero)

皆さん、こんにちは。AWS DevTools Hero の後藤と申します。普段、AWS Cloud Development Kit (AWS CDK) へのコントリビュート活動を行っており、Top Contributor、並びに Community Reviewer に選定いただいています。

AWS CDK は、使い慣れたプログラミング言語でクラウドリソースを定義できるオープンソースのフレームワークです。プログラミング言語で書けるということは、一般的なソフトウェア開発で行われるようなテストコードを書くことができるということです。

今回はそんな IaC ツールである AWS CDK におけるテストのうち、単体テストの使い所、つまり「どんな場面でどのように単体テストを使えば良いのか」について紹介します。


1. 単体テストとは

一般的なソフトウェア開発における単体テスト (Unit Tests) とは、関数やクラスなど、アプリケーションの最小の構成要素である「ユニット」に対して行われるテストのことです。

単体テスト以外にも、単体テストよりも広い範囲を対象とする統合テスト (Integration Tests) などのテストもあります。

それらと比べると、単体テストはユニット単位の小さい粒度を対象としたテストであるため、バグの検出時に原因を特定しやすく、またテストの実行が速いというメリットがあります。


2. AWS CDK における単体テストの種類

AWS CDK における単体テストの種類には、主に以下の 3 つがあります。

  • スナップショットテスト
  • Fine-grained assertions テスト
  • バリデーションテスト

前者 2 つに関しては、AWS CDK の Developer Guide の Test AWS CDK applications というページに詳しい説明が掲載されているので、ぜひご覧ください。また、AWS Black Belt Online Seminar にて公開されている AWS CDK における開発とテスト (Advanced #1) からもこれらのテストについて学ぶことができます。

また、AWS CDK における統合テストは、実際に CDK コードで定義したリソースをデプロイして動作確認するのが一般的で非常に有用なテストなのですが、本記事では統合テストに関しては触れません。AWS CDK における統合テストに関しては、AWS の公式ブログで AWS CDK アプリケーションのためのインテグレーションテストの作成と実行 という記事が公開されているのでぜひご覧ください。

ではここから、AWS CDK におけるそれぞれの単体テストの書き方や、具体的にどんな場面でどのように使えば良いのかといった使い所についてご紹介していきます。


3. スナップショットテスト

3-1. スナップショットテスト

AWS CDK におけるスナップショットテストとは、CDK コードから合成される AWS CloudFormation テンプレートを出力し、以前のテスト実行時に生成したテンプレートの内容と比較してテンプレートの差分を検出するテストのことです。

例えば、MyStack という Stack クラスに対するスナップショットテストは、以下のような書き方で行うことができます。

※テストファイルは、cdk initコマンドで CDK プロジェクト作成時にプロジェクトのルートディレクトリの直下に test というディレクトリが作られているため、その中に my-stack.test.ts というようなファイルを作成して書くことが一般的です。

import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

describe('MyStack Tests', () => {
  test('Snapshot Tests', () => {
    const app = new App();
    const stack = new MyStack(app, 'MyStack');

    const template = Template.fromStack(stack);
    expect(template.toJSON()).toMatchSnapshot();
  });
});

このテストファイルを、cdk init を実行した際に定義されている npm run test コマンドで実行すると、test ディレクトリの直下に __snapshots__ というディレクトリが作成され、その中に my-stack.test.ts.snap というスナップショットファイルが保存されます。スナップショットファイルは、CloudFormation テンプレートの JSON 形式で保存されています。そして、その以前のテスト実行時に保存されたスナップショットと、今回の CDK コードによって生成されたテンプレートを比較し、差分がある場合はテストが失敗します。

その差分が想定通りのものである場合、npx jest --updateSnapshotというコマンドでテストを実行することで、スナップショットファイルを今回生成されたものに更新することができます。

3-2. スナップショットテストの使い所

私の個人的な考えとしてですが、スナップショットテストは AWS CDK を使用した開発において、開発初期などの一部を除きほとんど必須であると考えています。

特に、以下の場面で効果を発揮します。

  1. AWS CDK のバージョンアップデート
  2. CDK コードのリファクタリング
  3. バージョン管理システムでの差分管理

3-2-1. AWS CDK のバージョンアップデート

スナップショットテストを使っておいた方が良い大きな理由として挙げられるのが、AWS CDK ライブラリのバージョンを上げた際でも、スナップショットテストが成功してさえいれば CloudFormation テンプレートに差分は生じていない、つまりデプロイ済みのリソースへの影響が無いことを保証出来るからです。

というのも、AWS CDK ライブラリはバージョンが上がるごとに様々な変更が加えられ、新たな機能が追加されたり、バグが修正されたりすることによって CloudFormation テンプレートの出力が変わることがまれにあります。

しかし、AWS CDK を用いて開発を行っているプロジェクトにおいて、生成される CloudFormation テンプレートの内容が AWS CDK のバージョンを上げた際に変わってしまうと、それによってすでにデプロイしているリソースに思わぬ変更が発生してしまうことがあります。それを事前に検知して防ぐことができるのが、このスナップショットテストなのです。

基本的には OSS (オープンソースソフトウェア) である AWS CDK ライブラリ自体の開発側で、生成される CloudFormation テンプレートに極力変更がないように開発が行われているのですが、どうしても変わってしまうことはあるためユーザー側でスナップショットテストを行うことが重要になってきます。(とはいえ、AWS CDK の OSS 開発においては破壊的変更を防ぐための仕組みがあるため安心です。)

3-2-2. CDK コードのリファクタリング

CDK コードをリファクタリングする際にも、スナップショットテストは非常に有用です。

基本的にリファクタリングでは、コードの変更前と変更後で挙動 (CDK においては CloudFormation テンプレート) が変わらないようにすることが求められます。

スナップショットテストを活用することで、リファクタリングによって生成される CloudFormation テンプレートが、リファクタリング前と同じであることを確認することができます。

3-2-3. バージョン管理システムでの差分管理

Git などでスナップショットファイルを含むコードのバージョン管理をしていると、開発・運用中にリソース定義の変更を行った際に、より強力な効果を発揮します。

それは、CDK コードの粒度だけでなく、CloudFormation テンプレートの粒度での更新差分が可視化・記録されるからです。

CDK コード上ではわかりづらいような思わぬ変更も確認できるため、リソース定義の変更に対するリスクを最小限に抑えることができます。


4. Fine-grained assertions テスト

4-1.Fine-grained assertions テストとは

AWS CDK における Fine-grained assertions テストとは、生成された CloudFormation テンプレートの一部を取り出して、その部分に対してチェックを行うテストのことです。これにより、どのようなリソースが生成されるのかといった細かい構成要素に対するテストをすることができます。

例えば、「AWS::SNS::Subscription のリソースが 2 つ生成されているか」や、「AWS::Lambda::Function Runtime nodejs20.x が設定されているか」といったような細かい粒度のテストを行うことができます。

import { App, assertions } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

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

describe('Fine-grained assertions tests', () => {
  test('Two SNS subscriptions are created', () => {
    const template = getTemplate();
    template.resourceCountIs('AWS::SNS::Subscription', 2);
  });

  test('Lambda has nodejs20.x', () => {
    const template = getTemplate();
    template.hasResourceProperties('AWS::Lambda::Function', {
      Handler: 'handler',
      Runtime: 'nodejs20.x',
    });
  });
});

4-2. Fine-grained assertions テストの使い所

この Fine-grained assertions テストですが、実は具体的にどういう時にどんなテストを書けば良いのか、といったセオリーはまだあまり確立されていないように感じます。

というのも、リソースごと、プロパティごとといった細かい粒度で様々なチェックをすることができ、多岐にわたるテストケースが考えられるためです。また全てのリソースに対してテストを書いていくとさらに膨大な量になり、テストの旨みよりも冗長さやメンテナンスの大変さが上回ってしまうケースもあります。

そのため、あくまで私個人の判断基準となりますが、以下のような場面で使うのが良いと考えています。

1. ループ処理
2. 条件分岐
3. プロパティの override
4. 特に保証したい定義
5. props を使った値の指定

前提として、AWS CDK はプログラミング言語で書けるが故に「手続き的」にリソース定義のコード記述を行うこともできますが、「宣言的」にリソース定義の記述を行うことが可能です。

※ここでいう「宣言的」とは、「○○ というリソースを作成する」というように、リソースの存在を宣言することを指します。一方で「手続き的」とは、「○○ というリソースを作成するために、まずはこういう処理を行い、次にこういう処理を行う」といったように、リソースの作成手順を手続き的に記述することを指します。

インフラ定義としては、やはり定義を見るだけでどんなリソースが生成されるのかがわかりやすい「宣言的」な方が好ましいケースが多いかと思われます。AWS CDK はあくまでインフラ定義のためのツールであるため、基本的には「宣言的」に書くことが多いでしょう。

そして、「リソース A を作る」というような宣言的な記述において、「リソース A が作られる」というのは自明です。自明なものを確認する旨みに対して、Fine-grained assertions テストではリソース定義側のコードとほぼ同じようなコードが出来上がることで二重定義のような煩わしさを感じ、テストのメンテナンスコストが上がってしまうこともあります。

そのような理由から、私の個人的な判断基準としては、全てのリソースに対して細かく Fine-grained assertions テストを書くのではなく、上記のような場面で使うのが良いと考えています。

4-2-1. ループ処理

上記では「宣言的」にリソース定義の記述を行うお話をしましたが、ループ処理を使ってリソースを生成する場合、それは「手続き的」なコード記述になります。

つまり、これによってどのようなリソースが生成されるかは自明とは言えなくなってしまいます。

そのためループ処理を使ってリソースを生成する場合には、そのループ処理が正しく動作しているかを確認するための Fine-grained assertions テストを書くことが重要になります。

具体的には、以下のような CDK コードがあるとします。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Topic } from 'aws-cdk-lib/aws-sns';

export interface MyStackProps extends cdk.StackProps {
  appNames: string[];
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    // 重複する要素がある場合を考慮して一意な組み合わせにする
    const appNames = new Set(props.appNames);

    for (const appName of appNames) {
      new Topic(this, `${appName}Topic`, {
        displayName: `${appName}Topic`,
      });
    }
  }
}

このようなループ処理を使ったリソース定義に対して、以下のような Fine-grained assertions テストを書くことができます。

import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

describe('Fine-grained assertions tests', () => {
  test('SNS Topics are created', () => {
    const appNames = ['App1', 'App1', 'App2'];
    const expectedNumberOfTopics = 2;

    const app = new App();
    const stack = new MyStack(app, 'MyStack', {
      appNames: appNames,
    });
    const template = Template.fromStack(stack);

    template.resourcePropertiesCountIs(
      'AWS::SNS::Topic',
      {
        DisplayName: Match.stringLikeRegexp('Topic'),
      },
      expectedNumberOfTopics,
    );
  });
});

4-2-2. 条件分岐

if 文のような条件分岐を使って環境ごとにリソースを生成するかどうかを変えるような場合も、その条件分岐が正しく動作しているかの確認は重要です。

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

import * as cdk from 'aws-cdk-lib';
import { CfnWebACL } from 'aws-cdk-lib/aws-waf';
import { Construct } from 'constructs';

export interface MyStackProps extends cdk.StackProps {
  isProd: boolean;
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    if (props.isProd) {
      new CfnWebACL(this, 'WebAcl', {
        // ...
      });
    }
  }
}

isProd true の場合に CfnWebACL が作られることを確認するには、以下のようなテストを書くことができます。

import { App } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

describe('Fine-grained assertions tests', () => {
  test('Web ACL is created in prod', () => {
    const app = new App();
    const stack = new MyStack(app, 'MyStack', {
      isProd: true,
    });
    const template = Template.fromStack(stack);

    template.resourceCountIs('AWS::WAFv2::WebACL', 1);
  });
});

今度は、環境ごとにプロパティを指定するかどうかを変えるような場合です。

import * as cdk from 'aws-cdk-lib';
import { Distribution } from 'aws-cdk-lib/aws-cloudfront';
import { Construct } from 'constructs';

export interface MyStackProps extends cdk.StackProps {
  isProd: boolean;
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    // ...

    new Distribution(this, 'Distribution', {
      // ...
      webAclId: props.isProd ? webAclId : undefined,
    });
  }
}

例えば、isProd false の場合に webAclId が「紐付かない」ことを確認するテストも書くことができます。

特徴としては、Match.absent メソッドを使うことで、該当のプロパティに「値が指定されていない」ことを確認することができます。

import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

describe('Fine-grained assertions tests', () => {
  test('Web ACL is not associated in dev', () => {
    const app = new App();
    const stack = new MyStack(app, 'MyStack', {
      isProd: false,
    });
    const template = Template.fromStack(stack);

    template.hasResourceProperties('AWS::CloudFront::Distribution', {
      DistributionConfig: {
        // WebACLId プロパティが指定されていないことを確認
        WebACLId: Match.absent(),
      },
    });
  });
});

4-2-3. プロパティの override

AWS CDK では、CDK で提供される L2 Construct を使ってリソースを定義することが一般的です。

しかし、L2 Construct には対応していないプロパティを設定したいケースなどで、エスケープハッチ を用いて L1 Construct にキャストしてから、addPropertyOverride などのメソッドでプロパティを override (上書き) することがあります。

この場合プロパティの指定に Construct の型が使えず、CloudFormation テンプレートの構造に合わせて自前でプロパティの記述をする必要があり、特に階層構造のようなプロパティの場合に記述ミスが発生しやすいです。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket, CfnBucket } from 'aws-cdk-lib/aws-s3';

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const bucket = new Bucket(this, 'Bucket');

    // Bucket をエスケープハッチしてプロパティを override する
    const cfnSrcBucket = bucket.node.defaultChild as CfnBucket;
    cfnSrcBucket.addPropertyOverride('NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled', true);
  }
}

このように override を用いてリソース定義を上書きする場合に、それが意図した通りに反映されているかを確認するためのテストを書くことができます。

import { App, assertions } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

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

describe('Fine-grained assertions tests', () => {
  // エスケープハッチをして正しくプロパティを上書き出来ているか
  test('EventBridge is enabled', () => {
    const template = getTemplate();
    template.hasResourceProperties('AWS::S3::Bucket', {
      NotificationConfiguration: {
        EventBridgeConfiguration: { EventBridgeEnabled: true },
      },
    });
  });
});

4-2-4. 特に保証したい定義

次は、特に保証したい定義に対してテストを書くケースです。

これは最初にご説明した「宣言的」なコード記述に対して行うテストになります。

先ほどは、「宣言的」、つまりリソース定義が自明なものには Fine-grained assertions テストを書かないかのような説明をしましたが、特に保証したい定義に対してテストを書くことも重要です。

例えば、「ある要件を実現するためにこのプロパティは設定しておきたい」などのような、他のプロパティと比べて重要な定義に対して、「意思表示」 のような形でテストを書いておくことができます。もし今後、別の開発者がその設定を変更してしまった際に該当するテストが失敗し、その違反したテストを参照することで元の設計者の意図を理解でき、意思の伝搬に繋がることもあります。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket } from 'aws-cdk-lib/aws-s3';

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new Bucket(this, 'Bucket', {
      lifecycleRules: [{ expiration: cdk.Duration.days(100) }],
    });
  }
}

この lifecycleRules における expiration という設定を保証したい場合、以下のようなテストを書くことができます。

import { App, assertions } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

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

describe('Fine-grained assertions tests', () => {
  test('Expiration for lifecycle must be specified', () => {
    const template = getTemplate();

    template.hasResourceProperties('AWS::S3::Bucket', {
      LifecycleConfiguration: {
        Rules: [
          {
            ExpirationInDays: 100,
            Status: 'Enabled',
          },
        ],
      },
    });
  });
});

この場合、開発中の要件変更などでプロパティの値を変更した際に、テスト側の値も合わせて変更する必要があります。もしプロパティを指定できているかだけを確認できれば良い場合は、Match.anyValue メソッドを用いることで具体値の指定はせずとも確認することができ、テストのメンテナンスコストを下げることができます。

import { Match } from 'aws-cdk-lib/assertions';

// ...

template.hasResourceProperties('AWS::S3::Bucket', {
  LifecycleConfiguration: {
    Rules: [
      {
        ExpirationInDays: Match.anyValue(),
        Status: 'Enabled',
      },
    ],
  },
});

また、例えば addDependency のような CDK によって提供されるメソッドを使って定義を加える際にも、意図した通りに反映されていることを保証したいケースもあるでしょう。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { LogGroup, ResourcePolicy } from 'aws-cdk-lib/aws-logs';
import { PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { HostedZone } from 'aws-cdk-lib/aws-route53';
import { Bucket, CfnBucket } from 'aws-cdk-lib/aws-s3';

export interface MyStackProps extends cdk.StackProps {
  domainName: string;
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    const logGroup = new LogGroup(this, 'QueryLogGroup');
    const hostedZone = new HostedZone(this, 'HostedZone', {
      zoneName: props.domainName,
      queryLogsLogGroupArn: logGroup.logGroupArn,
    });
    const resourcePolicy = new ResourcePolicy(this, 'QueryLogResourcePolicy', {
      policyStatements: [
        new PolicyStatement({
          principals: [new ServicePrincipal('route53.amazonaws.com')],
          actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
          resources: [logGroup.logGroupArn],
        }),
      ],
    });

    // HostedZone が QueryLogResourcePolicy に依存するように
    hostedZone.node.addDependency(resourcePolicy);
  }
}

この例の場合では以下のようなテストを書くことで、想定通りのリソース間に依存が追加されているかどうかを確認できます。

import { App, assertions } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

const getTemplate = (): assertions.Template => {
  const app = new App();
  const stack = new MyStack(app, 'MyStack', {
    domainName: 'example.com',
  });
  return Template.fromStack(stack);
};

describe('Fine-grained assertions tests', () => {
  // addDependency によって意図する依存関係を定義できているか
  test('HostedZone depends on QueryLogResourcePolicy', () => {
    const template = getTemplate();
    template.hasResource('AWS::Route53::HostedZone', {
      DependsOn: [Match.stringLikeRegexp('QueryLogResourcePolicy')],
    });
  });
});

4-2-5. props を使った値の指定

Stack や Construct に渡す props の値を使ってリソースのプロパティを指定する場面でも、Fine-grained assertions テストは有用です。具体的には、props の値が正しくリソースに反映されているかを確認するテストを書きます。

これは、リソースのプロパティに具体値を直接記述する「ベタ書き」の定義ではない場合に起こりうる、値の渡し忘れの防止になります。

import { App, assertions } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

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

describe('Fine-grained assertions tests', () => {
  test('messageRetentionPeriodInDays from props', () => {
    const template = getTemplate();

    template.hasResourceProperties('AWS::SNS::Topic', {
      // props の値を正しく渡せていることを確認
      ArchivePolicy: { MessageRetentionPeriod: 10 },
    });
  });
});

また実際にデプロイされる CDK コードによるリソース定義をテストするために、テスト用 props ではなく実際の Stack に渡す用に定義した props をそのまま使いたいケースも多いかと思います。

この場合、その props が持つプロパティを使って確認をすることで、具体値の指定はせずとも props から渡された値を指定できていることを保証でき、テストのメンテナンスコストを削減できます。

import { App, assertions } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { myStackProps } from '../lib/config';
import { MyStack } from '../lib/my-stack';

const getTemplate = (): assertions.Template => {
  const app = new App();

  // 実際の Stack に渡す用に定義した props をそのまま使う
  const stack = new MyStack(app, 'MyStack', myStackProps);

  return Template.fromStack(stack);
};

describe('Fine-grained assertions tests', () => {
  test('messageRetentionPeriodInDays from props', () => {
    const template = getTemplate();

    template.hasResourceProperties('AWS::SNS::Topic', {
      ArchivePolicy: { MessageRetentionPeriod: myStackProps.messageRetentionPeriod },
    });
  });
});

5. バリデーションテスト

5-1. バリデーションテストとは

3 つ目にご説明するバリデーションテストとは、その名の通りバリデーションに関するテストになります。

バリデーションとは、条件分岐などを通して値の妥当性を検証する処理のことです。AWS CDK においても、Stack や Construct への入力である props のプロパティに対してバリデーション処理を実装することがあります。

AWS CDK における具体的なバリデーションの方法に関しては、筆者が以前 builders.flash で執筆した AWS CDK におけるバリデーションの使い分け方を学ぶ という記事をご覧ください。

例えばバリデーションテストには、あるプロパティに対する入力値が特定の範囲内に収まっているかを検証するコードを書くことができます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket } from 'aws-cdk-lib/aws-s3';

export interface MyStackProps extends cdk.StackProps {
  lifecycleDays: number;
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    if (!cdk.Token.isUnresolved(props.lifecycleDays) && props.lifecycleDays > 400) {
      throw new Error('ライフサイクル日数は400日以下にしてください');
    }

    new Bucket(this, 'Bucket', {
      lifecycleRules: [
        {
          expiration: cdk.Duration.days(props.lifecycleDays),
        },
      ],
    });
  }
}

cdk.Token.isUnresolved メソッドは、値が Token でないかどうかを確認するメソッドになります。こちらの解説なども上記の「AWS CDK におけるバリデーションの使い分け方を学ぶ」記事に記載しているため、ぜひご覧ください。

このような CDK コードに対して、以下のようなバリデーションテストを書くことができます。許容しない入力値が渡された際に、エラーが発生することを確認するテストになります。

import { App } from 'aws-cdk-lib';
import { MyStack } from '../lib/my-stack';

describe('Validation tests', () => {
  test('lifecycle days must be lower than or equal to 400 days', () => {
    const app = new App();

    expect(() => {
      new MyStack(app, 'MyStack', { lifecycleDays: 500 });
    }).toThrowError('ライフサイクル日数は400日以下にしてください');
  });
});

5-2. バリデーションテストの使い所

Stack や Construct が受け取るプロパティに対して何らかのバリデーション処理を実装している場合、そのバリデーション処理が正しく動作しているかは非常に重要な確認事項であるため、バリデーションテストはぜひ書いておきたいテストです。各バリデーションごとにテストケースを書けると良いでしょう。

逆に言えば、特にバリデーション処理を何も実装していない場合は不要となります。


6. CDK の単体テストで覚えておくと良いこと

6-1. 個数チェックと自動生成リソース

Fine-grained assertions テストでは、assertions モジュールTemplate クラスが持つ resourceCountIs メソッドなどで、特定のリソースタイプの個数を確認するテストを書くことができます。

const template = Template.fromStack(stack);
template.resourceCountIs('AWS::Logs::LogGroup', 5);

一方で、CDK でよく使う L2 Construct ですが、ベストプラクティスに沿ったり、より高い開発者体験を提供するために、Construct 内部に自動でいくつかのリソースが生成されることがあります。そこで、例えば上記のテストのように、自分ではその種類のリソースを 5 つ定義したつもりでも、実際には 6 つのリソースが生成されていた、といったようなケースがあります。

また、そのようなケースも加味してテストで指定する個数を 6 つとしたとしても、後からその値を見た際に内訳がよくわからず混乱や認知負荷につながることもあるため注意しましょう。よほど自動生成リソースも含めた個数を確認したいわけではない場合、リソースの自動生成はスナップショットテストの更新差分からでも確認可能なため、このような個数チェックを捨てるといった選択肢に目を向けるのも良いかもしれません。それでもテストを残したい場合、意図がわかるようにきちんとコメントを書くことも良いでしょう。

もしくは、resourcePropertiesCountIs メソッドを使用して、特定のプロパティや値を持つリソースに絞った個数を確認するテストもぜひご検討ください。

const template = Template.fromStack(stack);
template.resourcePropertiesCountIs(
  'AWS::Logs::LogGroup',
  {
    // '/aws/lambda/my-app/'という命名規則を持つロググループに限定
    LogGroupName: Match.stringLikeRegexp('/aws/lambda/my-app/'),
  },
  5,
);

6-2. Construct ごとのテスト

本記事では基本的に、実際に定義した Stack クラスに対する単体テストを例としてご紹介しました。

しかし CDK コードを書く上で、カスタム Construct を作成して、それらを組み合わせて Stack を定義することも多いかと思います。

そのような場合、カスタム Construct ごとに単体テストを書くことで、その Construct に閉じた範囲でのテストを行うことができ、他の Construct に影響を受けずに動作を確認することができます。これにより、Construct 単体での信頼性や再利用性を担保することができます。

また、テストファイルを Construct ごとに分けることで、一つ一つのテストファイルが責務ごとに凝集されてよりシンプルになり、理解容易性が増すかもしれません。

具体的には、空のスタックを定義し、テスト対象のカスタム Construct のみを追加してテストを行うことができます。

import { Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyConstruct } from '../lib/constructs/my-construct';

test('Construct Tests', () => {
  // 空のスタックを定義
  const stack = new Stack();

  // テストしたい Construct を上記 Stack に追加
  new MyConstruct(stack, 'MyConstruct', {
    messageRetentionPeriodInDays: 10,
  });

  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::SNS::Topic', {
    ArchivePolicy: { MessageRetentionPeriod: 10 },
  });
});

しかし、Construct の数が多くなるにつれ、全ての Construct ごとにテストを書こうとするとテストの数が非常に多くなってしまうことがあります。また、Construct ごとのテストを書いたとしても、実際にデプロイされる環境の構成となる Stack に対するテストは必須で書いておきたいでしょう。その場合、Stack のテストと Construct のテストで重複しないようにうまくテストの範囲や責務を分けないと、テストのメンテナンスが大変になる可能性があるため注意が必要です。

再利用性を特に担保したい Construct のみに Construct 単位のテストを書くといった使い分けも良いでしょう。もしくは、特に Construct を再利用するケースがない場合は、Construct ごとの単体テストを書かないという選択肢も問題ないと思います。


7. オススメの最小構成

本記事でご紹介した全てのテストをまとめて導入することは中々大変かと思われます。

そのため最小構成でお手軽に CDK での単体テストを導入したい場合、まずはスナップショットテストから導入してみるのが良いでしょう。簡単に導入ができ、かつデプロイされる CloudFormation テンプレートでの思わぬ変更を検知できることは非常に大きなメリットです。


8. まとめ

AWS CDK における単体テストとして、以下の 3 つの単体テストの書き方、およびそれらの使い所についてご紹介しました。

  • スナップショットテスト
  • Fine-grained assertions テスト
  • バリデーションテスト


単体テストは AWS CDK においても信頼性を向上するために非常に重要な要素です。ぜひこれらのテストを活用して、より信頼性の高い AWS CDK 開発に取り組んでみてもらえると幸いです。


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

後藤 健太 (AWS DevTools Hero / @365_step_tech)

AWS CDK のコントリビュート活動を行っており、Top Contributor や Community Reviewer に選定。2024 年 2 月に発足されたコミュニティ駆動の CDK コンストラクトライブラリである Open Constructs Library では、メンテナーを担っている。
また、cls3 や delstack といった自作 AWS ツールの OSS 開発も行なっている。2024 年 3 月、AWS DevTools Hero に選出。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する