TOP>コラム一覧>AWS CDK 入門 - CDK でテスト駆動開発をやってみた

AWS CDK 入門 - CDK でテスト駆動開発をやってみた

はじめに

CDK では、ベストプラクティスとして、テスト駆動開発(TDD)が紹介されています。

https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/best-practices-cdk-typescript-iac/development-best-practices.html

TDD はテストファーストのソフトウェア開発手法として有名ですが、CDK の開発ではどの程度有効なのでしょうか?
本記事にて、TDD で簡単な CDK コードを書いてみました。

参考文献

なお、本記事の執筆に当たって以下の文献を参考にしていますが、本記事の内容は、筆者個人の解釈で記載していることにご留意ください。

「テスト駆動開発」( Kent Beck 著 、 和田 卓人 翻訳、出版社: オーム社)

テスト駆動開発(TDD)について

本題に入る前に、TDD について基本的な内容を説明します。

テスト駆動開発(TDD)とは

テスト駆動開発(TDD)とは、簡単に言うと、コードを実装する際、テストコードから書き始めるコーディングスタイルです。
一般的に、コーディングに TDD のアプローチを取り入れることで、欠陥が少なく可読性の高いコードが実装できると言われています。

TDD のプロセス

TDD のプロセスはシンプルです。
コードを実装する際に、次のようなステップを繰り返すことでコーディングを行っていきます。

順序 ステップ 説明
1 レッド 実装内容をテストするためのテストコードを書きます
なお、プロダクトコードが未実装なので普通は失敗します
2 グレーン テストが通るようにプロダクトコードを書きます
このステップでは、テストが通ることだけに集中し、実装の良し悪しは問わないものとします
3 リファクタリング コードの重複を排除します
例えば、同様の処理が複数回実行されるのであれば、抽象化、メッソド化等をして一般化します

TDD のサイクル

CDK でテスト駆動開発をやってみる

実装対象

例題として、次の Cloudfront と S3 Bucket からなるフロントエンドアーキテクチャを構築したいと思います。

構成図

要件

  • Amazon CloudFront
    • CloudFront Distribution のアクセスログを出力する。
    • CloudFront が返す全てのレスポンスへセキュリティ関連の HTTP ヘッダーを追加する。
    • CloudFront のオリジンパスを'/'に設定
  • Amazon S3 バケット
    • S3 バケットのアクセスロギングを有効にする。
    • AWS が管理する KMS Key を使用して、S3 Bucket のサーバーサイド暗号化を有効にする
    • 転送中のデータの暗号化を有効にする
    • S3 バケットのバージョニングを有効にする
    • S3 バケットのパブリックアクセスを許可しない
    • CloudFormation スタックの削除時に S3 Bucket を保持する
    • 90 日後に非現行オブジェクトバージョンを Glacier ストレージに移動するライフサイクルルールを適用する

準備

ディレクトリ構成

cdk init app --language typescript で CDK のひな形を作成し、ディレクトリ構成とファイル名を以下のようにします。

.
  ├── bin
  │   └── cdk-tdd-blog.ts // プログラムのエントリーポイント
  ├── lib
  │   ├── cdk-tdd-blog-stack.ts // スタック
  │   └── construct
  │       └── cloudfront-s3-construct.ts // コンストラクト(テスト対象のプロダクトコード )
  └── test
      └── cloudfront-s3-construct.test.ts // コンストラクトに対するテストコード

各コードの初期状態

bin/cdk-tdd-blog.ts

import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { CdkTddBlogStack } from "../lib/cdk-tdd-blog-stack";

const app = new cdk.App();
new CdkTddBlogStack(app, "CdkTddBlogStack", {});

lib/cdk-tdd-blog-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { CloudfrontS3Construct } from "./construct/cloudfront-s3-construct";

export class CdkTddBlogStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    new CloudfrontS3Construct(this, "CloudfrontS3Construct");
  }
}

lib/construct/cloudfront-s3-construct.ts (テスト対象)

import { Construct } from "constructs";

export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
  }
}

test/cloudfront-s3-construct.test.ts (テストコード)
テストコードに関しては、以下の状態から TDD をはじめるものとします。

import { Template, Match } from "aws-cdk-lib/assertions";
import { CloudfrontS3Construct } from "../lib/construct/cloudfront-s3-construct";
import * as cdk from "aws-cdk-lib";

describe("CloudfrontS3Construct", () => {
  let template: Template;

  // When (振る舞いを記述)
  beforeAll(() => {
    const app = new cdk.App(); // テスト用のアプリケーションを作成
    const stack = new cdk.Stack(app, "Test"); // テスト用のアプリケーションにテスト用のスタックを作成
    new CloudfrontS3Construct(stack, "CloudfrontS3Construct"); // テスト用のスタックにテスト対象のコンストラクトを作成。
    template = Template.fromStack(stack); // アサーションテストスイートを作成
  });
  // Then(振る舞いの結果を記述)
});

TODO リスト

コードを作成する前に、まずは、実装要件を TODO リストに書き出します。
今回は、記事の尺の都合上、途中まで構築するものとし、以下までを実装したいと思います。

TODO リスト

  • Amazon CloudFront
    • CloudFront Distribution が 1 つ存在する。
    • CloudFront Distribution がアクセスログを出力する。
  • Amazon S3 バケット
    • S3 Bucket が 3 つ存在する。

コンストラクトに含まれるリソースの数量を確認する

それでは、最初のステップを実装していきます。
最初のステップは最も簡単なリソース数量を確認するテストから始めます。

レッド: リソース数量をカウントするテストコードを書く

レッドでは、失敗するテストコードを書きます。まずは、リソース数量を確認する為の失敗するテストを書きます。

テストコード (test/cloudfront-s3-construct.test.ts) の修正

※ 本来、TDD では、テストケースは一つずつ実装するのが原則ですが、記事の都合上、2 つ一遍に追加します。

describe("Amazon CloudFront", () => {
  test("CloudFront Distribution が1つ存在する。", () => {
    template.resourceCountIs("AWS::CloudFront::Distribution", 1);
  });
});
describe("S3 Bucket", () => {
  test("S3 Bucket が3つ存在する。", () => {
    template.resourceCountIs("AWS::S3::Bucket", 3);
  });
});

テスト実行結果

npx jest を実行すると想定通り、失敗します。

グリーン: テストコードが通るようにリソースを作成

テストに失敗したら、テストコードが通るようにコンストラクトを修正します。
この段階では、テストを通すことのみに集中してコードを書きます。

プロダクトコード (lib/construct/cloudfront-s3-construct.ts)の修正
リソース数量を合わせるため、以下を作成するよう修正します。

  • S3 バケットを 3 つ作成
  • CloudFront を一つ作成
import { Construct } from "constructs";
import * as Cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as CloudfrontOrigins from "aws-cdk-lib/aws-cloudfront-origins";
import * as S3 from "aws-cdk-lib/aws-s3";

export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    new S3.Bucket(this, "Bucket1");
    new S3.Bucket(this, "Bucket2");
    const originBucket = new S3.Bucket(this, "OriginBucket");

    new Cloudfront.Distribution(this, "CloudFront", {
      defaultBehavior: {
        origin: new CloudfrontOrigins.S3Origin(originBucket),
      },
    });
  }
}

S3 バケットのコードが冗長だったり、コンストラクト名が適当ですが、この時点では許容するものとします。

テスト実行結果

リファクタリング: コードの重複排除

リファクタリングでは、プロダクトコードとテストコードの重複を排除していきます。
テストコードについては、重複がないので、プロダクトコードのみ修正していきます。

プロダクトコード (lib/construct/cloudfront-s3-construct.ts)の修正

S3 バケットの作成が冗長なので、Map 関数と Destructuring 構文を使用して簡潔にします。

export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    // 修正
    const [bucket1, bucket2, originBucket] = ["Bucket1", "Bucket2", "OriginBucket"].map(
      (id_) => new S3.Bucket(this, id_),
    );

    new Cloudfront.Distribution(this, "CloudFront", {
      defaultBehavior: {
        origin: new CloudfrontOrigins.S3Origin(originBucket),
      },
    });
  }
}

テスト実行結果

TODO リスト
以下の実装が完了しました。

  • Amazon CloudFront
    • CloudFront Distribution が 1 つ存在する。
    • CloudFront Distribution がアクセスログを出力する。
  • Amazon S3 バケット
    • S3 Bucket が 3 つ存在する。

CloudFront がアクセスログを出力することを確認する

続いて、CloudFront Distribution がアクセスログを出力するようにしたいと思います。

レッド: CloudFront がアクセスログを出力することを確認するテストを書く

CloudFront がアクセスログを出力することを確認するテストコードを書きます。
どの S3 バケットに出力するかは現段階では問わないものとし、必要になったら実装をするものとします。

テストコード (test/cloudfront-s3-construct.test.ts) の修正

describe("Amazon CloudFront", () => {
  test("CloudFront Distribution が1つ存在する。", () => {
    template.resourceCountIs("AWS::CloudFront::Distribution", 1);
  });
  test("CloudFront がアクセスログを出力する。", () => {
    template.hasResourceProperties("AWS::CloudFront::Distribution", {
      DistributionConfig: {
        Logging: {
          Bucket: Match.anyValue(),
        },
      },
    });
  });
});

5 行目以降を追加しています。

テスト実行結果

想定通り、Logging パラメータがないというエラーが出ています。

グリーン: CloudFront Distribution のロギングを有効にする

テストコードが失敗したら、プロダクトコードを修正します。

プロダクトコード (lib/construct/cloudfront-s3-construct.ts) の修正

Cloudfront Distribution の L2 コンストラクトクラスにenableLogging プロパティがあるので、true にしてみます。

export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    const [bucket1, bucket2, originBucket] = ["Bucket1", "Bucket2", "OriginBucket"].map(
      (id_) => new S3.Bucket(this, id_),
    );

    new Cloudfront.Distribution(this, "CloudFront", {
      enableLogging: true, // 修正
      defaultBehavior: {
        origin: new CloudfrontOrigins.S3Origin(originBucket),
      },
    });
  }
}

テスト実行結果

CloudFront のアクセスログは有効になりましたが、予想に反して、S3 バケットの数量が 3 では無くなってしまいました。
デグレが発生しており、出力結果を読むと S3 Bucket の数量が 4 になっているようです。
enableLogging プロパティのみを有効にすると、コンストラクトが新規に S3 Bucket を生成するようです。

再びプロダクトコード (lib/construct/cloudfront-s3-construct.ts) の修正

テスト結果が依然としてレッドのままなので、追加で修正する必要があります。

修正方法は以下の 2 通りが考えられますが、今回はよりシンプルな 1 で対応します。

  1. Bucket2 を削除して、数量を合わせる。
  2. Cloudfront Distribution の L2 コンストラクトのlogBucket プロパティに既存の S3 Bucket であるBucket1Bucket2 を指定する。
export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    const [bucket1, originBucket] = ["Bucket1", "OriginBucket"].map((id_) => new
S3.Bucket(this, id_)); // 修正

    new Cloudfront.Distribution(this, "CloudFront", {
      enableLogging: true,
      defaultBehavior: {
        origin: new CloudfrontOrigins.S3Origin(originBucket),
      },
    });
  }
}

テスト実行結果

jest を実行すると Cloudfront のアクセスログは有効なままで、S3 バケットの数量エラーが解消されています。

TODO リスト
CloudFront Distribution のアクセスログを出力するの実装が完了しました。

  • Amazon CloudFront
    • CloudFront Distribution が 1 つ存在する。
    • CloudFront Distribution がアクセスログを出力する。
  • Amazon S3 バケット
    • S3 Bucket が 3 つ存在する。

ここまでのまとめ

このように、細かく分割したタスクについて、テストコードとプロダクトコードを実装し、検証を行うサイクルを回すことで、最終的に要件が完備された無駄のないコードを完成させます。
本記事ではここまでですが、プロダクトコードは未完成なので、次に必要な TODO を考えて、実装していく必要があります。

この時点でのコード

プロダクトコード (lib/construct/cloudfront-s3-construct.ts) の修正

import { Construct } from "constructs";
import * as Cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as CloudfrontOrigins from "aws-cdk-lib/aws-cloudfront-origins";
import * as S3 from "aws-cdk-lib/aws-s3";

export class CloudfrontS3Construct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    const [bucket1, originBucket] = ["Bucket1", "OriginBucket"].map((id_) => new S3.Bucket(this, id_));

    new Cloudfront.Distribution(this, "CloudFront", {
      enableLogging: true,
      defaultBehavior: {
        origin: new CloudfrontOrigins.S3Origin(originBucket),
      },
    });
  }
}

テストコード (test/cloudfront-s3-construct.test.ts)

import { Template, Match } from "aws-cdk-lib/assertions";
import { CloudfrontS3Construct } from "../lib/construct/cloudfront-s3-construct";
import * as cdk from "aws-cdk-lib";

describe("CloudfrontS3Construct", () => {
  let template: Template;

  beforeAll(() => {
    const app = new cdk.App();
    const stack = new cdk.Stack(app, "Test");
    new CloudfrontS3Construct(stack, "CloudfrontS3Construct");
    template = Template.fromStack(stack);
  });
  describe("Amazon CloudFront", () => {
    test("CloudFront Distribution が1つ存在する。", () => {
      template.resourceCountIs("AWS::CloudFront::Distribution", 1);
    });
    test("CloudFront がアクセスログを出力する。", () => {
      template.hasResourceProperties("AWS::CloudFront::Distribution", {
        DistributionConfig: {
          Logging: {
            Bucket: Match.anyValue(),
          },
        },
      });
    });
  });
  describe("S3 Bucket", () => {
    test("S3 Bucket が3つ存在する。", () => {
      template.resourceCountIs("AWS::S3::Bucket", 3);
    });
  });
});

考察

本記事では、CDK のコンストラクトの実装に対して、TDD を試してみました。
以下は、冒頭で申し上げた「CDK が TDD にどの程度有効なのか」についての私見になります。

TDD は品質技法の印象が先行していますが、本来は設計・分析技法とされています。
しかしながら、IaC はアプリと違い、開始時点で構築対象の実装要件が明確であることが多く、実装に悩むことも少ないので、この観点においては、効果は限定的だと感じました。
ただし、TDD のリズム(レッド、グリーン、リファクタリング)で進めることにより、テストコードとリファクタリングの強制がなされるため、品質に対する一定の効果はあるとは思います。

また、観点は変わりますが、テストファースト、インクリメンタルに実装という TDD の性質は、簡潔かつ明確なプロンプトの入力を必要とするプロンプトエンジニアリングによるコード生成と相性が良いと思いました。
実際に、本記事のコードの続きを書く際、テストコードの CloudFormation 部分のみの実装を CodeWhisperer に頼りましたが、CDK のテストコードは構造が単純というのもあり、それなりに高い確率で正解のテストコードを出力できます。
また、TDD のリズムで実装するのであれば、AI が誤ったテストコードの実装を行ったとしても、プロダクトコードの実装で人間の検証が入り、結果的に修正するので安全です。
TDD と生成 AI を組み合わせて CDK を実装すれば、生成 AI によるテストコードの実装コスト削減と、TDD による品質向上の良いとこ取りができるのではないかと思いました。

以上となりますが、 本記事が CDK で TDD を模索している皆様へ、少しでも参考になったのなら幸いです。

CTCは、AWSのビジネス利活用に向けて、お客様のステージに合わせた幅広い構築・運用支援サービスを提供しています。
経験豊富なエンジニアが、ワンストップかつ柔軟にご支援します。
ぜひ、お気軽にお問い合わせください。

お問い合わせ



【著者プロフィール】

山本 和輝(やまもと かずき)

伊藤忠テクノソリューションズ株式会社 クラウドアーキテクト

クラウドネイティブ技術を活用したシステム基盤の提案・構築や内製化支援等の案件を担当。

TOP>コラム一覧>AWS CDK 入門 - CDK でテスト駆動開発をやってみた

pagetop