CDK integ-tests モジュールを使用したintegration test(以下integ test)を試してみたのでその記録です。
CDKでdeployしたリソースに対して、動作確認用のinteg testを走らせるということができます。
以下がAWS公式の解説ブログで、基本的な使い方や仕組みはこの記事を読むと概ね理解できます。
本記事では公式ブログの記事とは別のパターンとして、構築したREST APIが期待通り動作するかのinteg testを試してみます。
今回はLambda Function URLsを使用した簡易的なAPIを構築して、integ testでリクエストを送信した時に期待通り動作するかをテストします。
※仕組み等は公式のドキュメントが参考になるので、本記事では使い方にフォーカスします。
目次
1. 参照ドキュメント
integ testをやるとなった時に参照するドキュメントをまとめます。
①integ testに関するガイド
integ testのやり方が書いてあります。また、そもそもinteg testとは何か、どういう時に必要かという考え方も記載されています
②@aws-cdk/integ-tests-alpha
のドキュメント
aws-cdk
のinteg testのテストケース定義用のモジュールになります。こちらを使用してテストケースを実装していく形になります。
③integ-runner
のドキュメント
②と組み合わせてinteg testを実行するためのツールです。実行方法やオプションを確認したいときはこちらを見ます。
2. integ testの実装(CDK v2.89.0時点)
今回の実装サンプルは以下に格納しています。
以下がCDKプロジェクトの構成です。
. ├── README.md ├── bin │ └── cdk_integ_test.ts ├── cdk.json ├── cdk.out ├── jest.config.js ├── lib │ ├── cdk_integ_test-stack.ts │ └── lambda ├── package-lock.json ├── package.json ├── test │ └── integ.lamba.ts // ★integ testを実装 └── tsconfig.json
★がinteg testを実装している箇所になります。
integ testに関するガイドに従いtest/
配下にinteg.
で始まるファイル名で作成しています。
なおinteg testのファイルのディレクトリは、コマンド実行時に指定することができます(デフォルトではtest/
配下にあるinteg testを行うようになっているだけ)。
An integration tests is any file located in the test/ directory that has a name that starts with integ. (e.g. integ.*.ts).
以下具体的な実装手順を順番に見ていきます。
Step1. プロジェクト作成およびinteg test用のモジュールのインストール
@aws-cdk/integ-tests-alpha
とinteg-runner
が必要ですが、アルファモジュールなので個別にインストールが必要です。
そのためCDKプロジェクトをcdk init
で作成後に以下でインストールします。
npm install @aws-cdk/integ-tests-alpha npm install @aws-cdk/integ-runner
Step2. テスト対象のStackを実装
ここは普通のCDK開発と同様にStackを実装するだけです。
今回はlib/cdk_integ_test-stack.ts
に全て実装しています。
以下実装の全量です。Lambda Function URLsでエンドポイントを公開し、DynamoDBに書き込みを行うというだけです。
import * as path from 'path'; import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { aws_lambda as lambda } from 'aws-cdk-lib'; import { aws_dynamodb as dynamodb } from 'aws-cdk-lib'; import { aws_logs as logs } from 'aws-cdk-lib'; export class CdkIntegTestStack extends cdk.Stack { public readonly dynamodbTableName: string public readonly functionName: string public readonly functionUrl: string constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // DynamoDB Tableを定義 const dynamodbTable = new dynamodb.Table(this, 'SampleTable', { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING, }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Lambda Functionを定義 const lambdaFunction = new lambda.Function(this, 'SampleFunction', { runtime: lambda.Runtime.PYTHON_3_11, handler: 'lambda.handler', code: lambda.Code.fromAsset(path.join(__dirname, 'lambda')), environment: { 'DYNAMO_DB_TABLE': dynamodbTable.tableName }, logRetention: logs.RetentionDays.ONE_DAY }) // Lambda Functions URLsを追加 const functionUrl = lambdaFunction.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE }) // テーブルへの書き込みを許可 dynamodbTable.grantWriteData(lambdaFunction) // integ-testで使う項目 this.dynamodbTableName = dynamodbTable.tableName this.functionName = lambdaFunction.functionName this.functionUrl = functionUrl.url } }
Lambda Function URLsのエンドポイントに対して、POSTメソッドで以下のようなid
とmessage
を含むボディをリクエストすると、その内容をDynamoDB Tableに書き込むようにしています。
{ "id": "1", "message": "aaaa" }
Lambdaの実装としては以下です。 リクエストボディで受け取った内容をDynamoDBのテーブルに書き込んだ上で、レスポンス返すだけです。
import json import os import boto3 client = boto3.client('dynamodb') table_name = os.environ['DYNAMO_DB_TABLE'] def handler(event, context): print(event) body = json.loads(event['body']) id = body['id'] message = body['message'] item = { "id": {"S": id}, "message": {"S": message} } client.put_item(TableName=table_name, Item=item) result = json.dumps( { 'id': id, 'message': 'succeeded' }) print(result) return { 'statusCode': 200, 'body': result }
Step3. integ testの実装
今回の肝となるinteg testの実装箇所です。以下全量を記載します。
ざっくりとした流れは以下です。
- ①テスト対象のStackを生成
- ②Integ TestのStackを生成
- ③HTTPのAPI Callテストの定義
①、②に関してはAWS公式の解説ブログとほぼ同じです。
#!/usr/bin/env node import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { CdkIntegTestStack } from '../lib/cdk_integ_test-stack'; import { IntegTest, ExpectedResult } from '@aws-cdk/integ-tests-alpha'; const crypto = require("crypto"); // ①テスト対象のStackを生成 const app = new cdk.App(); const integTestStack = new CdkIntegTestStack(app, 'CdkIntegTestStack', {}); // ②Integ TestのStackを生成 const integ = new IntegTest(app, 'DataFlowTest', { testCases: [integTestStack], cdkCommandOptions: { destroy: { args: { force: true, }, }, }, regions: [integTestStack.region], }); // API Callのために必要な項目を作成 const url = integTestStack.functionUrl // Lambda Function URLsはStackから取得(できるようにStackの方を実装しておいた) const id = crypto.randomUUID() const message = "test message" const requestBody = JSON.stringify( { "id": id, "message": message } ) // ③HTTPのAPI Callテストの定義 integ.assertions.httpApiCall( url, { method: 'POST', body: requestBody, } // レスポンスが期待通りかを確認 ).expect(ExpectedResult.objectLike( { "status": 200, "body": { "id": id, "message": "succeeded" } } ) ).next( // API Call後にDynamoDBに格納される値が期待通りかを確認する integ.assertions // APIをCallしてDynamoDBテーブルのアイテムを取得 .awsApiCall('DynamoDB', 'getItem', { TableName: integTestStack.dynamodbTableName, Key: { id: { S: id } }, }) // 取得したアイテムの値が期待通りかを確認 .expect( ExpectedResult.objectLike({ Item: { id: { S: id, }, message: { S: message, } }, }), ) // タイムアウト設定。3秒間隔でリクエストを行い、正しい結果が25秒以内に得られなかったらタイムアウト .waitForAssertions({ interval: cdk.Duration.seconds(3), totalTimeout: cdk.Duration.seconds(25), }) );
③HTTPのAPI Callテストの定義
の実装部分を詳しく見ていきます。
まずhttpApiCall
メソッドで対象のエンドポイントにPOSTします。ここの使い方はfetch
と同等です。
integ.assertions.httpApiCall( url, { method: 'POST', body: requestBody, } )
次に1つ目のテストをメソッドチェーンでexpect
で定義しています。
ここではレスポンスのHTTPステータスコードが200であり、bodyが想定通りであることを確認しています。
// レスポンスが期待通りかを確認 .expect(ExpectedResult.objectLike( { "status": 200, "body": { "id": id, "message": "succeeded" } } ) )
次にメソッドチェーンでnext
を使用し、もう一つテストを定義しています。
これはPOST後にDynamoDBに期待通りのアイテムが存在しているかを確認するテストです。AWS公式の解説ブログでも同じようなことをやっておりそちらを参考にしています。
.next( // API Call後にDynamoDBに格納される値が期待通りかを確認する integ.assertions // APIをCallしてDynamoDBテーブルのアイテムを取得 .awsApiCall('DynamoDB', 'getItem', { TableName: integTestStack.dynamodbTableName, Key: { id: { S: id } }, }) // 取得したアイテムの値が期待通りかを確認 .expect( ExpectedResult.objectLike({ Item: { id: { S: id, }, message: { S: message, } }, }), ) // タイムアウト設定。3秒間隔でリクエストを行い、正しい結果が25秒以内に得られなかったらタイムアウト .waitForAssertions({ interval: cdk.Duration.seconds(3), totalTimeout: cdk.Duration.seconds(25), }) );
3. テスト実行
integ-runner
を使用してinteg testを実行していきます。
ここでは以下で実行してみます。
npx integ-runner --directory ./test --parallel-regions ap-northeast-1 --clean true
上記で指定しているオプションの説明は以下です(オプションの全量が知りたい場合はドキュメントを参照)。
--directory
: integ testの定義ファイルを探索するディレクトリです。デフォルトはtest
です。--parallel-regions
: ここで指定したリージョンに対して並列で実行されます。ここでは東京リージョンを指定しています。--clean: テスト終了後にStackを削除するかのフラグです。デフォルトは
true`です。 これにより テスト用Stackをデプロイ→integ testを実行→テスト用Stackを削除 という動作になります。テスト後にデバッグ用にStackを残しておきたい場合はfalseにする必要があります。
では実際に実行してみます。ドキュメントを見ると以下のようにsnapshotをチェックして、その後integ testを実行となるようです。
コマンド実行後、まずは1のsnapshotの確認が行われました。
この時は前回のinteg test実行時のsnapshotから変更がないかをチェックします。初回実行なので当然snapshotが存在しないため、failedになります。
しかしこのまま、snapshot関連のファイルが生成されinteg testが実行されます。
Verifying integration test snapshots... NEW integ.lamba 2.175s Snapshot Results: Tests: 1 failed, 1 total
実行結果としては以下になりました。
今回Assertionとしては2つ(APIのレスポンスが期待通りか、DynamoDBのアイテムが期待通りか)を実装していたので2つsuccess
が表示されています。
Stackのデプロイと削除も合わせて行うため、それなりに時間がかかる印象です。
これでinteg testが実装できました。
cdk_integ_test/test/integ.lamba.ts in ap-northeast-1 SUCCESS integ.lamba-DataFlowTest/DefaultTest 267.771s AssertionResultsHttpApiCallf1ea57bba64346bccfe36392be9c24a5 - success AssertionResultsAwsApiCallDynamoDBgetItema66e752e58d0b7d6bc07521ebe98ebcd - success Test Results: Tests: 1 passed, 1 total
snapshotの挙動が知りたかったので、Stackの実装を変更しないままもう一度integ-runner
を実行してみました。
そうすると以下のように変更なしでテストをパスする形になります。integ testの再実行は行われません。
integ-runner
実行時に--force true
とするとsnapshotの差分なしでもinteg testを再実行する挙動となります。
Verifying integration test snapshots... UNCHANGED integ.lamba 4.022s Snapshot Results: Tests: 1 passed, 1 total Running integration tests for failed tests... Running in parallel across regions: ap-northeast-1 Test Results: Tests: 0 passed, 0 total
次にStackの実装を少し変更してから再度integ-runner
を実行してみました。
そうするとsnapshotから変更ありとなり、テスト失敗となります。この時デフォルトオプションだと、integ testは再実行されません(差分が意図したものか確認させるために安全面に倒していると予想)。
ユースケースとしてStackの実装は変えた上でinteg testが通るかを確認したいことがあると思いますが、その場合はinteg-runner
実行時に--update-on-failed true
を指定します。これによりsnapshotの差分があった場合でもそのままinteg testを実施する動作となります。
Verifying integration test snapshots... CHANGED integ.lamba 4.122s Resources [~] AWS::DynamoDB::Table SampleTable08FBB2D0 replace ├─ [~] AttributeDefinitions │ └─ @@ -1,6 +1,6 @@ │ [ ] [ │ [ ] { │ [-] "AttributeName": "id", │ [+] "AttributeName": "i", │ [ ] "AttributeType": "S" │ [ ] } │ [ ] ] └─ [~] KeySchema (requires replacement) └─ @@ -1,6 +1,6 @@ [ ] [ [ ] { [-] "AttributeName": "id", [+] "AttributeName": "i", [ ] "KeyType": "HASH" [ ] } [ ] ] Snapshot Results: Tests: 1 failed, 1 total
以上でinteg testの動作確認は終わりです。
integ testはいつ使う?
今回の例のようなインフラも含めた「疎通レベル」のテストを回帰的にやりたい場合に使うと便利そうかなと思いました。
複数のパターンのテストもできそうですが、それはアプリ開発の範疇でやることない気がするので少し違うかなと。
ただ使い方はちょっと工夫が必要な印象です。
- CDKだとStackを極力分割しないのがベストプラクティスになりつつあるが、その場合1Stackが大きくなるのでinteg testに時間がかかる。
- integ test実行後にStackを削除するといっても、単純にdestroyするときと同様でRETAINのリソースが残るなどは起こる。
既存の環境のStackに変更を加える際に、CICDの中で一旦変更加えてからinteg testを実施→testが通ったらそのまま反映、NGだったらロールバックみたいなことができると便利そうかなと思いました(できるのかはわからんですが)。
使い方はもう少し考えていきたいと思います。