背景

こんにちは。
普段よく䜿うIaCツヌルにAWS Cloud Development Kit (AWS CDK)がありたすが、 長い間気になっおいたものにテスト機胜がありたす。
せっかくなので、勉匷も兌ねおCDKのテスト機胜を詊しおみたした。

怜蚌環境

今回は以䞋の環境で怜蚌しおいたす。たた、CDKのコヌドはPythonを甚いお実装したす。

> aws --version
aws-cli/2.15.16 Python/3.11.6 Windows/10 exe/AMD64 prompt/off
> cdk --version
2.164.0 (build 75cf2e0)
> Python -V
Python 3.11.8
> pytest --version
pytest 6.2.5

platform win32 -- Python 3.11.8, pytest-6.2.5, py-1.11.0, pluggy-1.5.0 

ドキュメントを読む

たずは公匏ドキュメントを読んでみたした。
CDKにはAWSのリ゜ヌスを定矩するモゞュヌル以倖にも、テスト目的で䜿甚するモゞュヌルがassertionsずしお甚意されおいたす。このモゞュヌルを䜿甚するこずで、CDKによっお䜜成されたCloudFormationのテンプレヌトが想定通りに䜜成されおいるか確認するこずができたす。たた、assertionsを甚いお実装されたテストコヌドはpytestなどの䞀般的なテストフレヌムワヌクが䜿えるため、他のテストコヌドず同じ感芚でテストを行うこずができたす。
CDKのテストには2皮類のアプロヌチがありたす。

  • きめ现かなアサヌションCDKによっお出力されたCloudFormationテンプレヌトに察しお、想定されたパラメヌタず䞀臎しおいるか確認するテストです。
  • スナップショットテストCDKのコヌドを倉曎した堎合に、新しく出力されるCloudFormationのテンプレヌトず倉曎前のCloudFormationテンプレヌトの間で差分が発生しないか確認するテストです。CloudFormationのテンプレヌトに倉曎が発生しないこずを確認できるため、CDKのコヌドに察するリファクタリングが容易になりそうです。

「きめ现かなアサヌション」の方は䜿う機䌚が倚そうであるため、こちらを深堀りしおいきたす。

テスト察象コヌド

今回の怜蚌で䜿甚するテスト察象のCDKコヌドは、VPC呚りずEC2むンスタンス2぀、IAMロヌルを構築する単玔なコヌドです。長くなるため、折りたたんでいたす。

テスト察象の゜ヌスコヌド
from constructs import Construct
from aws_cdk import (
    Stack,
    aws_ec2 as ec2,
    aws_iam as iam
)

class CdkTestStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # VPCずサブネットを定矩
        vpc = ec2.Vpc(
            self,
            id="test-vpc",
            vpc_name="test-vpc",
            availability_zones=["us-west-2a","us-west-2b"],
            ip_addresses=ec2.IpAddresses.cidr("192.168.0.0/16"),
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="test-subnet-public",
                    subnet_type=ec2.SubnetType.PUBLIC
                ),
                ec2.SubnetConfiguration(
                    name="test-subnet-private",
                    subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
                ),
            ]
        )

        # EC2むンスタンス甚IAMロヌル
        iam_role = iam.Role(
            self,
            "test-ec2-role",
            assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"),
            managed_policies=[
                iam.ManagedPolicy.from_aws_managed_policy_name(
                    "AmazonSSMManagedInstanceCore"
                ),
                iam.ManagedPolicy.from_aws_managed_policy_name(
                    "AmazonS3FullAccess"
                ),
            ],
        )

        # EC2むンスタンス共通蚭定
        ec2_common_params = dict(
            vpc=vpc,
            role=iam_role,
            instance_type=ec2.InstanceType.of(
                instance_class=ec2.InstanceClass.T3,
                instance_size=ec2.InstanceSize.MICRO
            ),
            machine_image=ec2.MachineImage.latest_amazon_linux2()
        )

        # EC2むンスタンス1台目
        ec2.Instance(
            self,
            id="test-ec2-instance01",
            instance_name="test-ec2-instance01",
            vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC, availability_zones=["us-west-2a"]),
            **ec2_common_params
        )

        # EC2むンスタンス2台目
        ec2.Instance(
            self,
            id="test-ec2-instance02",
            instance_name="test-ec2-instance02",
            vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC, availability_zones=["us-west-2b"]),
            **ec2_common_params
        )

テストコヌド実装

実際にCDKのテストコヌドを曞き、pytestでテスト実行をやっおみたした。

テンプレヌトの呌び出し

CDKのテストでは、生成されたCloudFormationのテンプレヌトを利甚したす。そのため、事前準備ずしおテストコヌド䞊でテンプレヌトを扱うためのスタック及びテンプレヌトを䜜成したす。

import aws_cdk as cdk
import aws_cdk.assertions as assertions
from cdk_code.cdk_stack import CdkTestStack

app = cdk.App()

# テストでの確認察象ずなるスタック・テンプレヌトの䜜成
stack = CdkTestStack(app, "CdkTestStack", env=cdk.Environment(region="us-west-2"))
template = assertions.Template.from_stack(stack)

リ゜ヌス数の確認

指定したリ゜ヌスタむプにおいお、テンプレヌトで定矩されおいるリ゜ヌス数が䞀臎するか確認するテストです。今回は2皮類のサブネットを2぀のAZで䜜成するため、合蚈4぀のサブネットが䜜成されたす。よっお以䞋のテストは成功したす。

def test_subnet_resource_count_is():
    # 指定したリ゜ヌスタむプのリ゜ヌス数が想定数ず䞀臎するか確認
    template.resource_count_is(
        type="AWS::EC2::Subnet", 
        count=4
    )

指定したパラメヌタが含たれおいるか確認

指定したリ゜ヌスタむプにおいお、テンプレヌト内にパラメヌタが含たれおいるか確認するテストです。
テストコヌド内で指定したCloudFormationのPropertiesの蚭定内容が含たれおいる堎合にテストは成功するため、特定の蚭定倀が想定通りに蚭定されおいるこずを確認できたす。

def test_vpc_has_resource_properties():
    # 指定したリ゜ヌスタむプのテンプレヌトにパラメヌタが含たれおいるか確認
    template.has_resource_properties(
        type="AWS::EC2::VPC",
        props={
            "CidrBlock": "192.168.0.0/16",
            "EnableDnsHostnames": True
        }
    )

なお、泚意点ずしおは、䞊蚘のテストは条件に䞀臎するリ゜ヌスが぀でも存圚するずテストは成功する点です。
䟋えば、䞋蚘のテストはEC2むンスタンスのAZがus-west-2bであるか確認するテストです。
今回のテンプレヌトではus-west-2aずus-west-2bのAZに1台ず぀むンスタンスを定矩しおいたすが、このテストは成功したす。

def test_ec2_has_resource_properties():
    # ぀でも䞀臎するリ゜ヌスがあればテスト自䜓はPASSする
    template.has_resource_properties(
        type="AWS::EC2::Instance",
        props={
            "InstanceType": "t3.micro",
            "AvailabilityZone": "us-west-2b"
        }
    )

そのため、同じリ゜ヌスタむプのリ゜ヌスが耇数存圚する堎合は、このテストだけ察象リ゜ヌスがテストパタヌン通りの蚭定になっおいる保蚌はありたせん。すべおのリ゜ヌスで共通する蚭定倀に察しおテストする堎合には次節で玹介するテストメ゜ッドを䜿甚したす。

すべおのリ゜ヌスで同じ蚭定になっおいるか確認

リ゜ヌス名の呜名芏則やすべおのリ゜ヌスで共通する蚭定倀に察しお確認する際に利甚するテストです。テストコヌド内で指定したCloudFormationのPropertiesの蚭定内容が指定したリ゜ヌスタむプにおけるすべおのリ゜ヌスに含たれおいる堎合にテストは成功したす。

def test_ec2_all_resource_properties():
    # すべおのリ゜ヌスで同じ蚭定になっおいるか、デプロむ時に決たる倀に぀いおアサヌトをかける
    isinstance_name_capture = assertions.Capture()
    template.all_resources_properties(
        type="AWS::EC2::Instance",
        props={
            "InstanceType": "t3.micro",
            "Tags":[
                {
                    "Key": "Name",
                    "Value": isinstance_name_capture
                }
            ]
        }
    )

    # むンスタンス名の圢匏を確認
    while (isinstance_name_capture.next()):
        assert re.match(r"^test-ec2-instance[0-9]{2}", isinstance_name_capture.as_string())

たた、䞊蚘のテストコヌドではCapture()を䜿甚しおいたす。これはテストコヌド内でテンプレヌトを比范する際にはワむルドカヌドずしお機胜し぀぀、生成されたテンプレヌトから䞀臎する郚分の倀を取埗できる機胜です。別途assertを甚いるこずで、テンプレヌト生成時に決たる倀やテンプレヌトに入る倀に぀いお確認するこずにも利甚できたす。

特殊な䞀臎

配列に含たれおいる䞀郚の倀やネストされおいる䞭の䞀郚オブゞェクトなど特定の倀のみ確認する堎合に利甚できる機胜がありたす。Match()を利甚するこずで、柔軟なテンプレヌトの確認が行えたす。
以䞋はIAMロヌルを定矩するテンプレヌトですが、プリンシパルずマネヌゞドポリシヌに蚭定された倀のみを確認するテストコヌドです。Match.object_like()では、Principalに察する倀が想定しおいる倀かどうかのみ確認しおおり、Statement内に含たれる他の項目に぀いおはテスト結果に圱響したせん。たたMatch.array_with()では、マネヌゞドポリシヌを列挙するManagedPolicyArnsの配列内に「AmazonSSMManagedInstanceCore」が含たれおいるかのみ確認しおいたす。

def test_iamrole_match():
    # matchを甚いたパラメヌタ確認
    template.has_resource_properties(
        type="AWS::IAM::Role",
        props= {
            "AssumeRolePolicyDocument": 
                {
                    "Statement": [
                        # 指定したPrincipalが存圚しおいるかのみ確認他の項目に぀いおは考慮しない
                        assertions.Match.object_like(
                            {
                                "Principal": {
                                    "Service": "ec2.amazonaws.com",
                                },
                            }
                        ),
                    ],
                },
            "ManagedPolicyArns": assertions.Match.array_with(
            # 配列内に指定した倀が含たれおいるかのみ確認
                [
                    {
                        "Fn::Join": [
                            "",
                            [
                                "arn:",
                                assertions.Match.any_value(),
                                ":iam::aws:policy/AmazonSSMManagedInstanceCore"
                            ]
                        ]
                    }
                ]
            )
        }
    )

たずめ

今回はCDKのテストに぀いお調査しおみたした。CDKで甚意されおいるテスト甚のassertionsモゞュヌルを䜿甚するこずで、䜜成されるCloudFormationのテンプレヌトが想定通りの蚭定倀で構成されおいるか確認するこずができたす。
pytestなどの䜿い慣れたテストツヌルがそのたた利甚できるこずや、パむプラむンのテストフェヌズなどに組み蟌むこずで、デプロむ時に想定倖の倉曎が発生しおいないか確認できるこずからリ゜ヌスの保護機胜ずしおも利甚できるメリットがある䞀方、CDKのテストにはCloudFormationのテンプレヌトを甚いるため、テストコヌドを実装するにあたっおはCloudFormationの知識も必芁になり、孊習コストが高くなるデメリットは感じられたした。

テストコヌド党䜓
import aws_cdk as cdk
import aws_cdk.assertions as assertions
from cdk_code.cdk_stack import CdkTestStack
import re

app = cdk.App()
# テストでの確認察象ずなるスタック・テンプレヌトの䜜成
stack = CdkTestStack(app, "CdkTestStack", env=cdk.Environment(region="us-west-2"))
template = assertions.Template.from_stack(stack)

def test_vpc_resource_count_is():
    # 指定したリ゜ヌスタむプのリ゜ヌス数が想定数ず䞀臎するか確認
    template.resource_count_is(
        type="AWS::EC2::Subnet",
        count=4
    )

def test_vpc_has_resource_properties():
    # 指定したリ゜ヌスタむプのテンプレヌトにパラメヌタが含たれおいるか確認
    template.has_resource_properties(
        type="AWS::EC2::VPC",
        props={
            "CidrBlock": "192.168.0.0/16",
            "EnableDnsHostnames": True
        }
    )

def test_ec2_has_resource_properties():
    # ぀でも䞀臎するリ゜ヌスがあればテスト自䜓はPASSする
    template.has_resource_properties(
        type="AWS::EC2::Instance",
        props={
            "InstanceType": "t3.micro",
            "AvailabilityZone": "us-west-2b"
        }
    )

def test_ec2_all_resource_properties():
    # すべおのリ゜ヌスで同じ蚭定になっおいるか、デプロむ時に決たる倀に぀いおアサヌトをかける
    isinstance_name_capture = assertions.Capture()
    template.all_resources_properties(
        type="AWS::EC2::Instance",
        props={
            "InstanceType": "t3.micro",
            "Tags":[
                {
                    "Key": "Name",
                    "Value": isinstance_name_capture
                }
            ]
        }
    )
    # isinstance_name_capture.next()
    while (isinstance_name_capture.next()):
        assert re.match(r"^test-ec2-instance[0-9]{2}", isinstance_name_capture.as_string())


def test_ec2_resource_properties_count_is():
    # 指定したパラメヌタをも぀リ゜ヌス数を確認
    template.resource_properties_count_is(
        type="AWS::EC2::Instance",
        props={
            "InstanceType": "t3.micro",
            "AvailabilityZone": "us-west-2a"
        },
        count=1
    )

def test_iamrole_match():
    # matchを甚いたパラメヌタ確認
    template.has_resource_properties(
        type="AWS::IAM::Role",
        props= {
            "AssumeRolePolicyDocument": 
                {
                    "Statement": [
                        # 指定したPrincipalが存圚しおいるかのみ確認他の項目に぀いおは考慮しない
                        assertions.Match.object_like(
                            {
                                "Principal": {
                                    "Service": "ec2.amazonaws.com",
                                },
                            }
                        ),
                    ],
                },
            "ManagedPolicyArns": assertions.Match.array_with(
            # 配列内に指定した倀が含たれおいるかのみ確認
                [
                    {
                        "Fn::Join": [
                            "",
                            [
                                "arn:",
                                assertions.Match.any_value(),
                                ":iam::aws:policy/AmazonSSMManagedInstanceCore"
                            ]
                        ]
                    }
                ]
            )
        }
    )

参考文献