みなさん、こんにちは!ISID FS事業部 市場系ソリューション1部の寺山です。 本日は2022年2月22日ということで、2が5つ並んでいる貴重な瞬間です(しかも ニャンニャン ニャンの日!私猫を3匹飼ってます)。次に同じ数字が5つ以上並んでいる日を迎えるのは、90年後なのですが、私はその時何をしているでしょうかね。。。?(笑) 私は現在、汎用的なマイクロサービスアプリケーション開発プロジェクト内で、このアプリをホストする クラウド インフラスト ラク チャのコード化(Infrastructure as Code, IaC)をチームで進めています。 その取り組みの中でインフラテストのコード化を行いたく、ツールの選定と比較を行いました。その内容を共有させていただこうと思います! コード化対象のテスト なぜコード化するのか? 比較対象のツール サンプルのテストコード Terratest 環境 テストコード EC2のパラメータテスト 疎通テスト 実行結果 AWS-SDK for JS+Jest 環境 テストコード EC2のパラメータテスト 疎通テスト 実行結果 比較 汎用性と自由度 テストコード実装の容易性 実行時間 結果レポート どちらを選択するか? 終わりに 参考)AWS-SDK + TypeScript のテストフレームワークをMochaに変更 コード化対象のテスト 対象として考えているテストの種類は以下のとおりです。 パラメータテスト 実際のインフラスト ラク チャ/ クラウド リソースのパラメータが、設計書やIaCと比較し想定とおりであることを検証する。 疎通テスト ルーティングや ファイアウォール の トラフィック 制御により、許可したアクセス元/アクセス先/ プロトコル /ポート番号でアクセス可能であることを検証する。 テストの名称には他のものもあるかも知れませんが、本記事内では記載の名称を用いて説明します。 また、パラメータテストで検証するパラメータは以下のとおりです。今回は選定/比較の段階でしたので、サンプルとしてEC2 インスタンス (いわゆる踏み台サーバー)を対象としました。 こちらのEC2 インスタンス はテスト前に手動で terraform apply を実行し、デプロイ済みの状態でテストを実行します。 実際のリソースの画面キャプチャを載せると長くなってしまうため、Terraformのstateファイルで代替させてください。 なお、以降のコードは公開にあたり修正している部分がございますので、ご留意ください。 クラウド プロバイダ AWS Terraform 1.0.11 AWS Provider 3.69.0 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 インフラテスト専用のヘルパー関数とテストテンプレートを提供する Golang ライブラリ Terraform だけでなく、他の オーケストレーション ツール、各種 クラウド プロバイダの API 、HTTP リク エス トや SSH コマンドの実行をサポート テストコードに Terraform の Plan/Apply/Destroy を組み込めるため自動化と相性が良い AWS SDK for JavaScript v3 + Jest + TypeScript 各 クラウド プロバイダの提供する SDK と、その言語のテスト フレームワーク 後者で 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 Driven 3 なテストとしていたので、こちらでも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 ↩