みなさん、こんにちは!ISID FS事業部 市場系ソリューション1部の寺山です。
本日は2022年2月22日ということで、2が5つ並んでいる貴重な瞬間です(しかもニャンニャンニャンの日!私猫を3匹飼ってます)。次に同じ数字が5つ以上並んでいる日を迎えるのは、90年後なのですが、私はその時何をしているでしょうかね。。。?(笑)
私は現在、汎用的なマイクロサービスアプリケーション開発プロジェクト内で、このアプリをホストするクラウドインフラストラクチャのコード化(Infrastructure as Code, 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に分類しており、テストピラミッドの考え方に則ると実施頻度が高くなります。また、コストや所要時間は小さいことが望ましいです。
しかしながら私たちは今まで、前述のテストを実行する際、GUIやCLIを用いて実際のパラメータや動作を目視で確認し、画面キャプチャやコマンドのログをエビデンスとして取得してきました。この手法はシステム開発サイクルが短期化している昨今では以下の課題があります。
- エビデンスの取得作業やレビュープロセスを含め、実施負荷が高い(スケジュールやコストの圧迫)
- テストの実施が手動作業となるため、再現性や再試行容易性を高めるのが難しい
そのため、パラメータテストをコード化することにより、高頻度で実施してもコストや所要時間を削減したいと考えております。
また、今回のパラメータテストのコード化を契機にインフラテストのコード化を進めることで、以下を達成するのが今後の目標です。
- 再現性を獲得し、不具合の早期発見を可能することでシステム品質向上に寄与する。
- CI/CDパイプラインに組み込むことでインフラもDevOpsの実現をする。
比較対象のツール
- Terratest
- AWS SDK for JavaScript v3 + Jest + TypeScript
後者で Jest と TypeScript という組み合わせにした理由は、インフラ運用のためのバッチ処理をNode.jsランタイム上に、AWS SDK を利用してTypeScriptで実装していたのが背景です。バッチ処理用に準備した実装環境やコード規約等のナレッジを流用しました。
なお、ツールの選定においては、対象のインフラがマネージドサービスやサーバレスアーキテクチャを採用しているため、サーバOS、やサーバOSにインストールしたミドルウェアのテストを可能とするツール(GossやServerspec等)は対象外にしました。
サーバ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
環境
テストコードの実装に利用した環境は以下です。
- OS(正確にはDockerイメージ)
- Debian GNU/Linux 11 (bullseye)
- IDE
- VSCode 1.63.2
- Go
- 1.17
- Terratest
- 0.38.9
利用しているライブラリは以下です。(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イメージ)
- Debian GNU/Linux 11 (bullseye)
- IDE
- VSCode 1.63.2
- 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', };
- npm スクリプト
{ "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)、レビュー:@higa (Shodoで執筆されました)
- Mike Cohn氏が「Succeeding with Agile」の中で提唱したもの↩
- プログラミングを業務で行うことなくキャリアを歩んできたため、プログラミング全般初学者です↩
- https://github.com/golang/go/wiki/TableDrivenTests↩