mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

【CDK】HTTP API Callを使ったIntegration testを試す

CDK integ-tests モジュールを使用したintegration test(以下integ test)を試してみたのでその記録です。

CDKでdeployしたリソースに対して、動作確認用のinteg testを走らせるということができます。

以下がAWS公式の解説ブログで、基本的な使い方や仕組みはこの記事を読むと概ね理解できます。

aws.amazon.com

本記事では公式ブログの記事とは別のパターンとして、構築したREST APIが期待通り動作するかのinteg testを試してみます。

今回はLambda Function URLsを使用した簡易的なAPIを構築して、integ testでリクエストを送信した時に期待通り動作するかをテストします。

※仕組み等は公式のドキュメントが参考になるので、本記事では使い方にフォーカスします。

目次

1. 参照ドキュメント

integ testをやるとなった時に参照するドキュメントをまとめます。

①integ testに関するガイド

integ testのやり方が書いてあります。また、そもそもinteg testとは何か、どういう時に必要かという考え方も記載されています

github.com

@aws-cdk/integ-tests-alphaのドキュメント

aws-cdkのinteg testのテストケース定義用のモジュールになります。こちらを使用してテストケースを実装していく形になります。

docs.aws.amazon.com

integ-runnerのドキュメント

②と組み合わせてinteg testを実行するためのツールです。実行方法やオプションを確認したいときはこちらを見ます。

github.com

2. integ testの実装(CDK v2.89.0時点)

今回の実装サンプルは以下に格納しています。

github.com

以下が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-alphainteg-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メソッドで以下のようなidmessageを含むボディをリクエストすると、その内容を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. Check if a snapshot file exists (i.e. /*.snapshot$/)
  2. If the snapshot does not exist 2a. Synth the integ app which will produce the integ.json file
  3. Read the integ.json file which contains instructions on what the runner should do.
  4. Execute instructions

コマンド実行後、まずは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だったらロールバックみたいなことができると便利そうかなと思いました(できるのかはわからんですが)。

使い方はもう少し考えていきたいと思います。