電通総研 テックブログ

電通総研が運営する技術ブログ

インフラテストコード化ツールを比較してみた

みなさん、こんにちは!ISID FS事業部 市場系ソリューション1部の寺山です。
本日は2022年2月22日ということで、2が5つ並んでいる貴重な瞬間です(しかもニャンニャンニャンの日!私猫を3匹飼ってます)。次に同じ数字が5つ以上並んでいる日を迎えるのは、90年後なのですが、私はその時何をしているでしょうかね。。。?(笑)

私は現在、汎用的なマイクロサービスアプリケーション開発プロジェクト内で、このアプリをホストするクラウドインフラストラクチャのコード化(Infrastructure as Code, IaC)をチームで進めています。
その取り組みの中でインフラテストのコード化を行いたく、ツールの選定と比較を行いました。その内容を共有させていただこうと思います!

コード化対象のテスト

対象として考えているテストの種類は以下のとおりです。

  • パラメータテスト
    • 実際のインフラストラクチャ/クラウドリソースのパラメータが、設計書やIaCと比較し想定とおりであることを検証する。
  • 疎通テスト

テストの名称には他のものもあるかも知れませんが、本記事内では記載の名称を用いて説明します。

また、パラメータテストで検証するパラメータは以下のとおりです。今回は選定/比較の段階でしたので、サンプルとしてEC2インスタンス(いわゆる踏み台サーバー)を対象としました。
こちらのEC2インスタンスはテスト前に手動でterraform applyを実行し、デプロイ済みの状態でテストを実行します。
実際のリソースの画面キャプチャを載せると長くなってしまうため、Terraformのstateファイルで代替させてください。
なお、以降のコードは公開にあたり修正している部分がございますので、ご留意ください。

{
  "module": "module.mainte",
  "mode": "managed",
  "type": "aws_instance",
  "name": "instance",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [
    {
      "index_key": "dev-ec2instance-bastion-01",
      "schema_version": 1,
      "attributes": {
        "ami": "ami-0923d9a4d39b22a91",
        "arn": "arn:aws:ec2:ap-northeast-1:999999999999:instance/i-0de3100c9e84299af",
        "associate_public_ip_address": true,
        "availability_zone": "ap-northeast-1a",
        "capacity_reservation_specification": [
          {
            "capacity_reservation_preference": "open",
            "capacity_reservation_target": []
          }
        ],
        "cpu_core_count": 1,
        "cpu_threads_per_core": 2,
        "credit_specification": [
          {
            "cpu_credits": "standard"
          }
        ],
        "disable_api_termination": false,
        "ebs_block_device": [],
        "ebs_optimized": false,
        "enclave_options": [
          {
            "enabled": false
          }
        ],
        "ephemeral_block_device": [],
        "get_password_data": false,
        "hibernation": false,
        "host_id": null,
        "iam_instance_profile": "dev-iamrole-bastion-instance",
        "id": "i-0de3100c9e84299af",
        "instance_initiated_shutdown_behavior": "stop",
        "instance_state": "running",
        "instance_type": "t3a.medium",
        "ipv6_address_count": 0,
        "ipv6_addresses": [],
        "key_name": "dev-ec2keypair-bastion",
        "launch_template": [],
        "metadata_options": [
          {
            "http_endpoint": "enabled",
            "http_put_response_hop_limit": 1,
            "http_tokens": "optional"
          }
        ],
        "monitoring": false,
        "network_interface": [],
        "outpost_arn": "",
        "password_data": "",
        "placement_group": "",
        "placement_partition_number": null,
        "primary_network_interface_id": "eni-00460786274d281f7",
        "private_dns": "ip-192-168-137-136.ap-northeast-1.compute.internal",
        "private_ip": "192.168.137.136",
        "public_dns": "ec2-99-99-99-99.ap-northeast-1.compute.amazonaws.com",
        "public_ip": "99.99.99.99",
        "root_block_device": [
          {
            "delete_on_termination": true,
            "device_name": "/dev/xvda",
            "encrypted": false,
            "iops": 100,
            "kms_key_id": "",
            "tags": {},
            "throughput": 0,
            "volume_id": "vol-014bb815a9d7d7202",
            "volume_size": 30,
            "volume_type": "gp2"
          }
        ],
        "secondary_private_ips": [],
        "security_groups": [],
        "source_dest_check": true,
        "subnet_id": "subnet-0d648e07f975d70d0",
        "tags": {
          "Env": "dev",
          "Name": "dev-ec2instance-bastion-01"
        },
        "tags_all": {
          "Env": "dev",
          "Name": "dev-ec2instance-bastion-01"
        },
        "tenancy": "default",
        "timeouts": null,
        "user_data": "d96ad8c0a045bbc14cbabfe3d4ce442460ddc60e",
        "user_data_base64": null,
        "volume_tags": {
          "Env": "dev",
          "Name": "dev-ec2instance-bastion-01"
        },
        "vpc_security_group_ids": [
          "sg-09eba84df7f9ede87"
        ]
      },
      "sensitive_attributes": [
        [
          {
            "type": "get_attr",
            "value": "ami"
          }
        ]
      ],
      "private": "ABCDEFG....",
      "dependencies": [
          //省略
      ]
    }
  ]
}

なぜコード化するのか?

インフラのテスト工程をテストピラミッド1 に当てはめると下図のようになると考えています。

パラメータテストはUTに分類しており、テストピラミッドの考え方に則ると実施頻度が高くなります。また、コストや所要時間は小さいことが望ましいです。
しかしながら私たちは今まで、前述のテストを実行する際、GUICLIを用いて実際のパラメータや動作を目視で確認し、画面キャプチャやコマンドのログをエビデンスとして取得してきました。この手法はシステム開発サイクルが短期化している昨今では以下の課題があります。

  • エビデンスの取得作業やレビュープロセスを含め、実施負荷が高い(スケジュールやコストの圧迫)
  • テストの実施が手動作業となるため、再現性や再試行容易性を高めるのが難しい

そのため、パラメータテストをコード化することにより、高頻度で実施してもコストや所要時間を削減したいと考えております。
また、今回のパラメータテストのコード化を契機にインフラテストのコード化を進めることで、以下を達成するのが今後の目標です。

  • 再現性を獲得し、不具合の早期発見を可能することでシステム品質向上に寄与する。
  • CI/CDパイプラインに組み込むことでインフラもDevOpsの実現をする。

比較対象のツール

後者で Jest と TypeScript という組み合わせにした理由は、インフラ運用のためのバッチ処理をNode.jsランタイム上に、AWS SDK を利用してTypeScriptで実装していたのが背景です。バッチ処理用に準備した実装環境やコード規約等のナレッジを流用しました。

なお、ツールの選定においては、対象のインフラがマネージドサービスやサーバレスアーキテクチャを採用しているため、サーバOS、やサーバOSにインストールしたミドルウェアのテストを可能とするツール(GossServerspec等)は対象外にしました。
サーバOSのパラメータではなく、EC2やRDSといったクラウドリソースのパラメータテストを行うツールとしてはTerratestのほぼ一択となるかなと思います。
しかしながら、Terratestと同じことをAWS SDK + テストフレームワークでも実装可能なのでは?と思い比較してみたのが裏話となります。

サンプルのテストコード

前のセクションで紹介した2種類のツールで実際にテストコードを実装してみました。
なお、tfstateでは明示的に指定していないパラメータもTerraformやAWS APIのデフォルト値が出力されますが、そのような項目はテストコードの実装の対象外としています。
また、VPC-ID/Subnet-ID/SecurityGroup-IDのパラメータに対するテストを実装していないのは、EC2のテスト内でIDを意識する実装にはしたくないと考えたためです。今後、VPCのテストを実装する際にIDを引数としてテストできるようなヘルパー関数を提供する予定です。
以下のサンプルでは、プライベートIPアドレスとパブリックIPアドレスアサインされていることを確認しました。

疎通テストは固定レスポンスを返すエンドポイントのパスに対してHTTPSプロトコルのリクエストを検証しています。イメージとしてはヘルスチェックエンドポイントに対するテストなのですが、レスポンスのBodyの内容も検証してみたかったので、ALBに固定レスポンスを返却するリスナールールを追加したものとなります。

Terratest

環境

テストコードの実装に利用した環境は以下です。

利用しているライブラリは以下です。(go.modより抜粋)

require (
    github.com/aws/aws-sdk-go v1.40.56
    github.com/gruntwork-io/terratest v0.38.9
    github.com/stretchr/testify v1.7.0
)

require (
    github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
    github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
    github.com/davecgh/go-spew v1.1.1 // indirect
    github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 // indirect
    github.com/go-sql-driver/mysql v1.4.1 // indirect
    github.com/google/uuid v1.2.0 // indirect
    github.com/gruntwork-io/go-commons v0.8.0 // indirect
    github.com/hashicorp/errwrap v1.0.0 // indirect
    github.com/hashicorp/go-multierror v1.1.0 // indirect
    github.com/jmespath/go-jmespath v0.4.0 // indirect
    github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect
    github.com/pmezard/go-difflib v1.0.0 // indirect
    github.com/pquerna/otp v1.2.0 // indirect
    github.com/russross/blackfriday/v2 v2.1.0 // indirect
    github.com/urfave/cli v1.22.2 // indirect
    golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
    golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
    golang.org/x/sys v0.0.0-20210603125802-9665404d3644 // indirect
    google.golang.org/appengine v1.6.7 // indirect
    gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

テストコード

EC2のパラメータテスト 

package test

import (
    "testing"

    awsSDK "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/ec2"
    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/stretchr/testify/assert"
)

func TestAwsEc2Parameter(t *testing.T) {
    t.Parallel()

    // Table driven
    tests := []struct {
        env          string
        instanceType string
        keyPair      string
    }{
        {"dev", "t3a.medium", "dev-ec2keypair-bastion"},
    }

    for _, tt := range tests {
        t.Run("AWS EC2 instance parameter. env: "+tt.env, func(t *testing.T) {
            expectedInstanceName := tt.env + "-ec2instance-bastion-01"
            awsRegion := "ap-northeast-1"

            instanceIds, err := aws.GetEc2InstanceIdsByTagE(t, awsRegion, "Name", expectedInstanceName)

            if err != nil {
                t.Fatal("Failed to get EC2 instance IDs.", err)
            }

            if !(assert.Len(t, instanceIds, 1)) {
                t.Fatalf("instanceIds not 1, actual: %v", len(instanceIds))
            }

            ec2Client := aws.NewEc2Client(t, awsRegion)
            instances, err := ec2Client.DescribeInstances(&ec2.DescribeInstancesInput{
                InstanceIds: []*string{awsSDK.String(instanceIds[0])},
            })

            if err != nil {
                t.Fatal("Failed to DescribeInstances API.", err)
            }

            t.Run("Got EC2 instance only 1", func(t *testing.T) {
                assert.Len(t, instances.Reservations, 1)
                assert.Len(t, instances.Reservations[0].Instances, 1)
            })

            t.Run("CPU architecture", func(t *testing.T) {
                assert.Equal(t, "x86_64",
                    awsSDK.StringValue(instances.Reservations[0].Instances[0].Architecture))

            })

            t.Run("Instance type", func(t *testing.T) {
                assert.Equal(t, tt.instanceType,
                    awsSDK.StringValue(instances.Reservations[0].Instances[0].InstanceType))

            })

            t.Run("Instance key pair", func(t *testing.T) {
                assert.Equal(t, tt.keyPair,
                    awsSDK.StringValue(instances.Reservations[0].Instances[0].KeyName))
            })

            t.Run("Instance profile", func(t *testing.T) {
                accoundId := aws.GetAccountId(t)
                assert.Equal(t, "arn:aws:iam::"+accoundId+":instance-profile/"+tt.env+"-iamrole-bastion-instance",
                    awsSDK.StringValue(instances.Reservations[0].Instances[0].IamInstanceProfile.Arn))
            })

            t.Run("Security group", func(t *testing.T) {
                assert.Len(t, instances.Reservations[0].Instances[0].SecurityGroups, 1)
                assert.Equal(t, tt.env+"-sg-bastion", awsSDK.StringValue(instances.Reservations[0].Instances[0].SecurityGroups[0].GroupName))
            })

            t.Run("Asigned private ip address", func(t *testing.T) {
                assert.NotEmpty(t, instances.Reservations[0].Instances[0].PrivateIpAddress)
            })

            t.Run("Asigned public ip address", func(t *testing.T) {
                assert.NotEmpty(t, instances.Reservations[0].Instances[0].PublicIpAddress)
            })
        })
    }
}

EC2インスタンスのパラメータ取得には、Terratestのawsモジュールを利用しています。このモジュールはAWS-SDKのラッパーなので、API Refenceよりプロパティを参照しながら実装できます。
aws.GetEc2InstanceIdsByTagEというヘルパー関数が提供されていたため使ってみました。が、この後直接AWS-SDKとJestで実装している時に気づいたのですが、DescribeInstancesでもタグによりフィルタができたのでコード量削減できますね。コードを書いている当時は気が付いていませんでした。ここでは紹介ということでそのままにしています。

疎通テスト

package test

import (
    "crypto/tls"
    "testing"
    "time"

    http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
)

func TestAwsAlbHttps(t *testing.T) {
    t.Parallel()

    tests := []struct {
        env    string
        domain string
    }{
        {"dev", "dev.aws.domain.com"},
    }

    for _, tt := range tests {
        t.Run(tt.env, func(t *testing.T) {
            tlsConfig := tls.Config{
                MinVersion: 2,
            }

            path := "/test"

            t.Run("HTTPS request to "+path+" is 200 status.", func(t *testing.T) {
                targetUrl := "https://public." + tt.domain + path
                http_helper.HttpGetWithRetry(t, targetUrl, &tlsConfig, 200, "success test response.",
                    5, 3*time.Second)
            })
        })
    }
}

Terratestのhttp-helperモジュールの提供するヘルパー関数を利用しています。私でも2すんなり実装できました。

実行結果

以下のような実行結果を得ます。

$ ls -l ./
total 140
-rw-r--r-- 1 vscode vscode   1327 Jan 30 02:07 go.mod
-rw-r--r-- 1 vscode vscode 127730 Jan 30 02:07 go.sum
-rw-r--r-- 1 vscode vscode   7039 Feb  6 15:27 README.md
drwxr-xr-x 4 vscode vscode    128 Jan 30 20:07 test

$ ls -l ./test/
total 8
-rw-r--r-- 1 vscode vscode 2886 Feb  6 19:49 ec2_test.go
-rw-r--r-- 1 vscode vscode  679 Feb  6 01:11 https_test.go

$ go test -v ./test/
=== RUN   TestAwsEc2Parameter
=== PAUSE TestAwsEc2Parameter
=== RUN   TestAwsAlbHttps
=== PAUSE TestAwsAlbHttps
=== CONT  TestAwsEc2Parameter
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev
=== CONT  TestAwsAlbHttps
=== RUN   TestAwsAlbHttps/dev
=== RUN   TestAwsAlbHttps/dev/HTTPS_request_to_/test_is_200_status.
TestAwsAlbHttps/dev/HTTPS_request_to_/test_is_200_status. 2022-02-06T19:51:12+09:00 retry.go:91: HTTP GET to URL https://public.dev.aws.domain.com/test
TestAwsAlbHttps/dev/HTTPS_request_to_/test_is_200_status. 2022-02-06T19:51:12+09:00 http_helper.go:32: Making an HTTP GET call to URL https://public.dev.aws.domain.com/test
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Got_EC2_instance_only_1
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/CPU_architecture
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_type
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_key_pair
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_profile
--- PASS: TestAwsAlbHttps (0.36s)
    --- PASS: TestAwsAlbHttps/dev (0.36s)
        --- PASS: TestAwsAlbHttps/dev/HTTPS_request_to_/test_is_200_status. (0.36s)
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Security_group
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Asigned_private_ip_address
=== RUN   TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Asigned_public_ip_address
--- PASS: TestAwsEc2Parameter (1.33s)
    --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev (1.33s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Got_EC2_instance_only_1 (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/CPU_architecture (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_type (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_key_pair (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Instance_profile (0.98s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Security_group (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Asigned_private_ip_address (0.00s)
        --- PASS: TestAwsEc2Parameter/AWS_EC2_instance_parameter._env:_dev/Asigned_public_ip_address (0.00s)
PASS
ok      github.com/ISID/tech-blog/test      1.345s

テストコードでt.Parallel()メソッドを利用しているのでテスト/サブテストが並行して実行されているのが分かります。
テストが完了したら、testing フレームワークのデフォルト形式で結果のレポートが出力されます。テスト > サブテスト とインデントが下がって表示されています。
今回実装したテスト/サブテストは全て合格しているので、 PASS ステータスで出力されています。不合格の場合は FAIL ステータスが出力されます。

AWS-SDK for JS+Jest

環境

  • OS(正確にはDockerイメージ)
  • IDE
  • Node.js
    • v14.18.3
  • TypeScript
    • 4.5.5
  • AWS-SDK
    • 3.49.0
    • ※一部v2を利用しています
  • Jest
    • 27.4.7

利用しているライブラリは以下です。(package.jsonより抜粋)

{
    "devDependencies": {
        "@aws-sdk/types": "^3.47.1",
        "@types/jest": "^27.4.0",
        "@typescript-eslint/eslint-plugin": "^5.10.1",
        "@typescript-eslint/parser": "^5.10.1",
        "eslint": "^8.7.0",
        "eslint-config-prettier": "^8.3.0",
        "prettier": "^2.5.1",
        "ts-jest": "^27.1.3",
        "ts-node": "^10.4.0",
        "typescript": "^4.5.5"
    },
    "dependencies": {
        "@aws-sdk/client-ec2": "^3.49.0",
        "aws-sdk": "^2.1065.0",
        "axios": "^0.25.0",
        "jest": "^27.4.7"
    }
}

テストコード

EC2のパラメータテスト

import { DescribeInstancesCommand, DescribeInstancesCommandOutput, EC2Client } from '@aws-sdk/client-ec2';
import { STS } from 'aws-sdk';

interface TestParam {
    env: 'dev' | 'stg' | 'prd';
    instanceType: string;
    keyPair: string;
}

const testParamTable: TestParam[] = [
    {
        env: 'dev',
        instanceType: 't3a.medium',
        keyPair: 'dev-ec2keypair-bastion',
    },
];

describe.each(testParamTable)('AWS EC2 instance parameter. env: $env.', ({ env, instanceType, keyPair }) => {
    const expectedInstanceName = `${env}-ec2instance-bastion-01`;
    const ec2Client = new EC2Client({ region: 'ap-northeast-1', apiVersion: '2016-11-15' });
    const sts = new STS();
    let instances: DescribeInstancesCommandOutput;
    let accountId: string;
    beforeAll(async () => {
        instances = await ec2Client
            .send(
                new DescribeInstancesCommand({
                    Filters: [
                        {
                            Name: 'tag:Name',
                            Values: [expectedInstanceName],
                        },
                    ],
                })
            )
            .catch((reason) => {
                throw new Error(`Failed to DescribeInstances API: ${reason}`);
            });

        if (instances.Reservations?.length !== 1) {
            throw new Error(`Got EC2 instances reservation not only 1: ${instances.Reservations?.length}`);
        }

        const identity = await sts.getCallerIdentity({}).promise();
        if (identity.Account != null) {
            accountId = identity.Account;
        }
    });

    describe(`Test start(${env})`, () => {
        test('Got instance only 1', () => {
            expect(instances.Reservations?.[0].Instances).toHaveLength(1);
        });

        test('CPU architecture', () => {
            expect(instances.Reservations?.[0].Instances?.[0].Architecture).toEqual('x86_64');
        });

        test('Instance type', () => {
            expect(instances.Reservations?.[0].Instances?.[0].InstanceType).toEqual(instanceType);
        });

        test('Instance key pair', () => {
            expect(instances.Reservations?.[0].Instances?.[0].KeyName).toEqual(keyPair);
        });

        test('Instance profile', () => {
            expect(instances.Reservations?.[0].Instances?.[0].IamInstanceProfile?.Arn).toEqual(
                `arn:aws:iam::${accountId}:instance-profile/${env}-iamrole-bastion-instance`
            );
        });

        test('Security Group', () => {
            expect(instances.Reservations?.[0].Instances?.[0].SecurityGroups).toHaveLength(1);
            expect(instances.Reservations?.[0].Instances?.[0].SecurityGroups?.[0].GroupName).toEqual(
                `${env}-sg-bastion`
            );
        });

        test('Asigned private ip address', () => {
            expect(instances.Reservations?.[0].Instances?.[0].PrivateIpAddress).toBeTruthy();
        });

        test('Asigned public ip address', () => {
            expect(instances.Reservations?.[0].Instances?.[0].PublicIpAddress).toBeTruthy();
        });
    });
});

Terratestのテストコードでは、Golangで慣例となっているTable Driven3なテストとしていたので、こちらでもTable Drivenを導入してみました。

疎通テスト

import axios from 'axios';

interface TestParam {
    env: 'dev' | 'stg' | 'prd';
    domain: string;
}

const testParamTable: TestParam[] = [
    {
        env: 'dev',
        domain: 'dev.aws.domain.com',
    },
];

describe.each(testParamTable)('AWS ALB connectivity test. env: $env', ({ env, domain }) => {
    describe(`Test start(${env})`, () => {
        const path = '/test';
        test(`HTTPS request to ${path} is 200 status.`, async () => {
            // responseに型付けをする方が望ましいが、テストのためany型を許容する
            const response = await axios({
                method: 'GET',
                url: `https://public.${domain}${path}`,
            });
            expect.assertions(2);
            expect(response.status).toEqual(200);
            expect(response.data).toEqual('success test response.');
        });
    });
});

HTTPクライアントにはJSでデファクトaxiosを利用しました。

実行結果

Jestの設定は以下のとおりです。(jest.config.js

module.exports = {
    clearMocks: true,
    collectCoverage: false,
    roots: ['<rootDir>test'],
    testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
    transform: { '^.+\\.ts$': 'ts-jest' },
    verbose: true,
};

テストはnpmスクリプトとして実行します。(package.jsonより抜粋)

{
    "scripts": {
        "test": "jest"
    }
}

以下のような実行結果を得ます。

$ npm run test 

> aws@1.0.0 test <path>
> jest

PASS  test/https.test.ts
  AWS ALB connectivity test. env: dev
    Test start(dev)
      ✓ HTTPS request to /test is 200 status. (163 ms)

PASS  test/ec2.test.ts (8.461 s)
  AWS EC2 instance parameter. env: dev
    Test start(dev)
      ✓ Got instance only 1 (2 ms)
      ✓ CPU architecture (1 ms)
      ✓ Instance type
      ✓ Instance key pair
      ✓ Instance profile
      ✓ Security Group (1 ms)
      ✓ Asigned private ip address
      ✓ Asigned public ip address

Test Suites: 2 passed, 2 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        9.81 s, estimated 10 s
Ran all test suites.

テストスイートとテストがシーケンシャルに実行されています。
テストが完了したら、Jest フレームワークのデフォルト形式で結果のレポートが出力されます。テストスイート内の各テストの合否とサマリが出力されています。
今回実装したテスト/サブテストは全て合格しているので、 ステータスで出力されています。。不合格の場合は × ステータスが出力されます。

比較

ここからは、実際に利用してみて得られた結果や感想より、いくつかの観点で比較します。

汎用性と自由度

ここでの「汎用性」と「自由度」は、以下を指す意図で使用しています。

  • 汎用性:一つのツールでカバー可能な範囲の広さ
  • 自由度:実装する言語や補助ライブラリの選択肢の多さ

これらの観点に対し、以下のように評価しました。

  • 比較対象のツールセクションで紹介したとおり、Terratestは複数のクラウドプロバイダやオーケストレーションツールをサポートしているため、汎用性が高い。
    • 一方、実装する言語は Golang に固定される。
  • SDK + テストフレームワークは、サポートされる言語の中からであれば自由に選択できるので自由度は高い。
    • 一方、テスト対象のクラウドプロバイダ毎に検討が必要となる。

つまり、今回比較したツールにおいてはトレードオフの関係にあると言えます。
この評価はツール選定の時点で自明ではありましたが、記事の構成上この場で言及させていただきました。

テストコード実装の容易性

私自身が、GolangとTypeScriptのどちらが得意というものもないため大きな差は感じませんでした。
個人的には配列やスライスを扱う際にポインタの理解が必要な分、Golangの方が言語としての難易度が高いと感じますが、本件のようにシンプルにテストコードを記述する範囲では大きな影響はないと考えます。
実装スキルに依存するため、あまり意味はないと思いつつ、ステップ数でも比較してみました。

ツール EC2パラメータテスト 疎通テスト 合計
Terratest 69 29 98
AWS-SDK for JS+Jest 75 25 100

やはり大きな差はないですね。

実行時間

ツール EC2パラメータテスト 疎通テスト 全体
Terratest 1.33s 0.36s 1.35s
AWS-SDK for JS+Jest 8.46s 0.16s 9.81s

こちらは大きな差が出ました。特にCI/CDパイプラインに組み込んだ場合など、実行頻度が高いテストにおいて時間は重要な指標となります。

結果レポート

  • Terratest(正確には testging フレームワーク)はテスト/サブテストと結合されて結果が出力されるので冗長に感じる。
  • Jest はテストスイートとテストが改行とインデントを分けて出力される。
  • Jest はテスト結果のサマリも出力される。

という点より、個人的には、標準出力に表示されるレポートは Jest の方が見易いと感じます。

どちらを選択するか?

今回紹介したもの以外も含め、どのツールを選択するかはプロジェクトやシステムに委ねられると思います。
私の取り組みではどちらにするか?なのですが、ひじょーーーに迷いました。
プログラミング言語は適材適所で選択するのが望ましいですが、チームメンバのスキルセットも重要な判断材料となります。
私のチームはどちらも経験を有していたわけではないのですが、インフラ運用バッチ処理をTypeScriptで実装してNode.jsで実行する方式を採用していたため、実際に比較する前はAWS SDK + Jestにしようと考えていました。
しかしながら、実際に調査して動かしてみた結果、Terratestを採用するという結論を一旦出しました。理由は以下です。

  • 汎用性の高さ
    • ホストするマイクロサービスアプリケーションは、AWS以外のインフラへの展開を見込んでいるため
  • 実行時間が短い
    • 現在考えているテストサイクルは、IaCのメンテナンス後に環境をデプロイ後、半自動(Webhookのようなイベント駆動を想定)での実行を検討しているため、実行頻度はそこまで高くない
    • それでも実行時間の短さは大きなアドバンテージと評価した

終わりに

実は、実際に調査するまでTerratestは「Terraformのtfstateの中身をテストするツール」だと思い込んでいました。
偏見や思い込みは良くないなと反省します。。。
また、実際に触ってみるのと机上調査との違いの大きさも実感します。

今回はインフラテストコード化ツールについて比較してみました。参考になった方や、弊社に興味を持ってくれた方がいらしたら幸いです。 次は2111年11月11日にお会いしましょう!

参考)AWS-SDK + TypeScript のテストフレームワークをMochaに変更

@higa さんから「テストフレームワークMocha に変えたらテスト実行時間も変わるのでは?」というアドバイスをいただいたため追加で試してみました。
詳細な説明は割愛いたします。

  • 利用したライブラリとバージョン(package.jsonより抜粋)
{
    "devDependencies": {
        "@aws-sdk/types": "^3.47.1",
        "@types/chai": "^4.3.0",
        "@types/mocha": "^9.1.0",
        "@typescript-eslint/eslint-plugin": "^5.10.1",
        "@typescript-eslint/parser": "^5.10.1",
        "eslint": "^8.7.0",
        "eslint-config-prettier": "^8.3.0",
        "prettier": "^2.5.1",
        "ts-node": "^10.4.0",
        "typescript": "^4.5.5"
    },
    "dependencies": {
        "@aws-sdk/client-ec2": "^3.49.0",
        "aws-sdk": "^2.1065.0",
        "axios": "^0.25.0",
        "chai": "^4.3.6",
        "mocha": "^9.2.0"
    }
}
  • EC2パラメータテストのテストコード
import { DescribeInstancesCommand, DescribeInstancesCommandOutput, EC2Client } from '@aws-sdk/client-ec2';
import { STS } from 'aws-sdk';
import { assert } from 'chai';
import { before, describe, it } from 'mocha';

interface TestParam {
    env: 'dev' | 'stg' | 'prd';
    instanceType: string;
    keyPair: string;
}

const testParamTable: TestParam[] = [
    {
        env: 'dev',
        instanceType: 't3a.medium',
        keyPair: 'dev-ec2keypair-bastion',
    },
];

describe('AWS EC2 instance parameter', () => {
    const ec2Client = new EC2Client({ region: 'ap-northeast-1', apiVersion: '2016-11-15' });
    const sts = new STS();

    let accountId: string | undefined;

    before(async function () {
        accountId = (await sts.getCallerIdentity({}).promise()).Account;
    });

    testParamTable.forEach((testParam) => {
        const expectedInstanceName = `${testParam.env}-ec2instance-bastion-01`;
        let instances: DescribeInstancesCommandOutput;
        before(async function () {
            instances = await ec2Client
                .send(
                    new DescribeInstancesCommand({
                        Filters: [
                            {
                                Name: 'tag:Name',
                                Values: [expectedInstanceName],
                            },
                        ],
                    })
                )
                .catch((reason) => {
                    throw new Error(`Failed to DescribeInstances API: ${reason}`);
                });

            if (instances.Reservations?.length !== 1) {
                throw new Error(`Got EC2 instances reservation not only 1: ${instances.Reservations?.length}`);
            }
        });

        describe(`Test start(${testParam.env})`, () => {
            it('Got instance only 1', () => {
                assert.equal(instances.Reservations?.[0].Instances?.length, 1);
            });

            it('CPU architecture', () => {
                assert.equal(instances.Reservations?.[0].Instances?.[0].Architecture, 'x86_64');
            });

            it('Instance key pair', () => {
                assert.equal(instances.Reservations?.[0].Instances?.[0].KeyName, testParam.keyPair);
            });

            it('Instance profile', () => {
                assert.equal(
                    instances.Reservations?.[0].Instances?.[0].IamInstanceProfile?.Arn,
                    `arn:aws:iam::${accountId}:instance-profile/${testParam.env}-iamrole-bastion-instance`
                );
            });

            it('Security Group', () => {
                assert.equal(instances.Reservations?.[0].Instances?.[0].SecurityGroups?.length, 1);
                assert.equal(
                    instances.Reservations?.[0].Instances?.[0].SecurityGroups?.[0].GroupName,
                    `${testParam.env}-sg-bastion`
                );
            });

            it('Asigned private ip address', () => {
                assert.exists(instances.Reservations?.[0].Instances?.[0].PrivateIpAddress);
            });

            it('Asigned public ip address', () => {
                assert.exists(instances.Reservations?.[0].Instances?.[0].PublicIpAddress);
            });
        });
    });
});
  • 疎通テストのテストコード
import axios from 'axios';
import { assert } from 'chai';
import { describe, it } from 'mocha';

interface TestParam {
    env: 'dev' | 'stg' | 'prd';
    domain: string;
}

const testParamTable: TestParam[] = [
    {
        env: 'dev',
        domain: 'dev.aws.domain.com',
    },
];

describe('AWS ALB connectivity test', () => {
    testParamTable.forEach((testParam) => {
        describe(`Test start(${testParam.env})`, () => {
            const path = '/test';
            it(`HTTPS request to ${path} is 200 status.`, async () => {
                // responseに型付けをする方が望ましいが、テストのためany型を許容する
                const response = await axios({
                    method: 'GET',
                    url: `https://public.${testParam.domain}${path}`,
                });
                assert.equal(response.status, 200);
                assert.equal(response.data, 'success test response.');
            });
        });
    });
});
  • Mocha の設定
module.exports = {
    extension: ['ts'],
    spec: ['test/*.test.ts', 'test/**/*.test.ts'],
    require: 'ts-node/register',
};
{
    "scripts": {
        "test": "mocha"
    }
}
  • テスト実行
$ npm run test

> aws@1.0.0 test /workspaces/tech-blog-matrial/code/infra-test-with-awssdk-mocha
> mocha



  AWS EC2 instance parameter
    Test start(dev)
      ✔ Got instance only 1
      ✔ CPU architecture
      ✔ Instance key pair
      ✔ Instance profile
      ✔ Security Group
      ✔ Asigned private ip address
      ✔ Asigned public ip address

  AWS ALB connectivity test
    Test start(dev)
      ✔ HTTPS request to /test is 200 status. (68ms)


  8 passing (1s)

Docker コンテナのベースイメージを Ubuntu 21.04(Hirsute Hippo) に変更したため、他の組み合わせも再計測しました。

ツール EC2パラメータテスト 疎通テスト 全体
Terratest 1.33s 0.40s 1.343s
AWS-SDK for JS+Jest 6.843s 0.103s 8.209s
AWS-SDK for JS+Mocha - 0.068s 1.00s

は、早い。。。

※Mocha のレポートにミリ秒のオーダで実行時間を出力する方法を調査しきれませんでした。ご容赦ください。

執筆:寺山 輝 (@terayama.akira)、レビュー:@higaShodoで執筆されました


  1. Mike Cohn氏が「Succeeding with Agile」の中で提唱したもの
  2. プログラミングを業務で行うことなくキャリアを歩んできたため、プログラミング全般初学者です
  3. https://github.com/golang/go/wiki/TableDrivenTests