LocalStackで開発環境をつくってCDKでデプロイしてテストも書く

最近はAWSを活用したアプリケーションを作っているのですが、たとえばいくつかのAWSサービスに依存するLambda関数を気軽に動作確認したくなったとき、都度デプロイをするのが億劫になってしまったのでした。 また、実装した処理にバグがあることで予期せぬコストが発生しても困ります。

そこで、AWS環境をローカル環境で構築することができる LocalStack を使ってみました。

Community版(無料)とPro版(有料)がありますが、今回はCommunity版を利用してみます。Community版はデプロイしたAWSリソースは永続化できず、Pro版と比較して利用できるAWSサービスが限定されています。 (Community / Pro版で利用できるサービスの詳細は LocalStack Coverage で確認できます)

ちなみに筆者のアプリケーションが依存しているAWSサービス(Lambda, API Gateway, SNS, SQS, Cloudformation, DynamoDB)はCommunity版でも利用できました。 アプリケーションを小さい単位で作っておくことで、Community版でも部分的なAWS依存のアプリケーションをテストするのに役立ちそうです。

インストール

READMEに書いてある通りにインストールします。筆者はWSL環境にインストールするので以下のようにしました。

brew install localstack/tap/localstack-cli

AWS CLIにLocalStack環境の接続処理をラップした awslocal コマンド をインストールしておきます。

pip install awscli-local

LocalStackを起動してみます。

localstack start

この状態で localstack status services コマンドを実行すると、利用可能なAWSサービスを一覧できます。 起動ができていれば、AWS CLIコマンドと同様に awslocal コマンドで各種リソースを作成できます。

デプロイしてみる

筆者はAWS CDKでリソースをソースコード管理しているので、cdk コマンドのラッパーである cdklocal コマンド をインストールしておきます。

npm install -g aws-cdk-local

cdk コマンドと同様に、まずは bootstrap サブコマンドを実行してから、 deploy サブコマンドを実行するとLocalStack環境にデプロイできます。

cdklocal bootstrap
cdklocal deploy

筆者が手がけているアプリケーションはCognito UserPoolに依存していますが、LocalStack Community版では利用できないので、デプロイに失敗してしまいます。 CDK Stackを分けてこのようなサービスを含まないようにするか、ソースコード中に条件分岐を記述してこのようなリソースを一時的に含めないようにする必要があります。

export class MyApplicationStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // UserCreateFunction というLambda Functionを定義
    const userCreateFunction = new RustFunction(stack, 'UserCreateFunction', {
      manifestPath: '../user-create-function/Cargo.toml',
    });

    // デプロイ環境に関するコンテキストを取得
    const stage = this.node.tryGetContext('stage') || 'production';

    // 開発環境ならUserPoolをデプロイしない
    if (stage === 'dev') {
      const userPool = new UserPool(stack, 'MyUserPool', {
        userPoolName: 'MyUserPool',
        selfSignUpEnabled: true,
        userVerification: {
          emailSubject: 'ようこそ!'
          emailBody: '{username}さん。 一時コードは {####} です。',
          emailStyle: VerificationEmailStyle.CODE,
        },
        autoVerify: {
          email: true,
        },
        standardAttributes: {
          email: {
            required: true,
            mutable: true,
          },
        },
        lambdaTriggers: {
          // 認証の確認後にLambda Functionを実行
          postConfirmation: userCreateFunction
        }
      });
    }

  }
}

上記ソースコードを以下のようなコマンドでデプロイすると、UserPoolはデプロイされず、デプロイが成功します。

cdklocal deploy -c stage=dev

Lambda Functionを実行してみる

AWS CLIを使って、payload.jsonに保存したJSONファイルをイベントパラメータにして、例として「UserCreateFunction」という名前のLambda Functionを実行してみます。 LocalStackに向けてAWS CLIを実行するには awslocal コマンドを利用します。

awslocal --cli-binary-format raw-in-base64-out lambda invoke --function-name UserCreateFunction --payload file://./payload.json out.txt

実行ログを見てみます。下記のコマンドはロググループの一覧表示をしたあとログストリームを選択し、あるログを閲覧するものです(jqpeco のインストールが必要)。

arn=$(awslocal logs describe-log-groups | jq -c '.logGroups[] | {creationTime, logGroupName}' | sort -r | peco | jq -r -c '.logGroupName' | xargs -i{} awslocal logs describe-log-streams --log-group-name={} | jq -c  '.logStreams[] | {creationTime, arn}' | sort -r | peco | jq '.arn') && awslocal logs get-log-events --log-group-name="$(echo $arn | cut -d : -f 7)" --log-stream-name="$(echo $arn | cut -d ':' -f 9 | cut -d \" -f 1)" --output text

テストを書いてみる

以下のような、Cognito UserPoolでユーザーの確認が完了した際に実行されるLambda Functionをテストしてみます。

use lambda_runtime::{service_fn, LambdaEvent, Error};
use aws_lambda_events::cognito::CognitoEventUserPoolsPostConfirmation;
use aws_sdk_dynamodb::Client;
use aws_config::BehaviorVersion;

async fn handler(event: LambdaEvent<CognitoEventUserPoolsPostConfirmation>) -> Result<CognitoEventUserPoolsPostConfirmation, Error> {
    // DynamoDBにアイテムを登録する

    Ok(event.payload)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    lambda_runtime::run(service_fn(handler)).await
}

このLambda Functionを実行し、DynamoDBにアイテムが追加されていることを確認するテストを書いてみます。 なお、今回はテストをTypeScriptで書きたかったので、Vitest を使います。 AWS SDKが提供されているプログラミング言語で手に馴染みのあるテストフレームワークを使うとよさそうです。

前述したとおり、今回の条件ではLocalStackでCognitoを利用できないので、代わりにCognito UserPoolのPostConfirmationトリガーの発生時に送信されるイベントパラメータを自分で送信します。

以下のようなダミーのイベントパラメータをJSON形式で保存します。

{
  "version": "1",
  "region": "us-east-1",
  "userPoolId": "us-east-1_123456789",
  "userName": "user123",
  "callerContext": {
    "awsSdkVersion": "aws-sdk-js-2.149.0",
    "clientId": "client123"
  },
  "triggerSource": "PostConfirmation_ConfirmSignUp",
  "request": {
    "userAttributes": {
      "sub": "uuid123",
      "cognito:user_status": "CONFIRMED",
      "email_verified": "true",
      "email": "user@example.com"
    }
  },
  "response": {}
}

上記JSONを参照する形でテストを記述します。

import { readFileSync } from 'fs'
import { join } from 'path'
import { describe, it, expect } from 'vitest'
import { LambdaClient, ListFunctionsCommand, InvokeCommand } from '@aws-sdk/client-lambda'
import { DynamoDBClient, ScanCommand } from '@aws-sdk/client-dynamodb'
import { unmarshall } from '@aws-sdk/util-dynamodb'

describe('handler', () => {
  it('should return 200', async () => {
    // 適当な認証情報でよい
    // 接続先はLocalStackが公開しているlocalhost:4566を利用
    const config = {
      region: 'us-east-1',
      endpoint: 'http://localhost:4566',
      credentials: {
        accessKeyId: 'dummy',
        secretAccessKey: 'dummy'
      }
    }

    const lambdaClient = new LambdaClient(config)

    // 関数一覧から実行したい関数を検索
    const res = await lambdaClient.send(new ListFunctionsCommand({}))
    const func = res.Functions?.find(f => f.FunctionName?.indexOf('UserCreateFunction'))
    expect(func).toBeDefined()

    // ダミーのイベントパラメータをファイルから読み込む
    // ユーザー名だけ実行する度に替わるようにリライトする
    const payload = JSON.parse(readFileSync(join(__dirname, 'fixtures/payload.json')).toString())
    payload.userName = 'test-' + Date.now()

    // Lambda Functionを実行
    const invokeRes = await lambdaClient.send(new InvokeCommand({
      FunctionName: func?.FunctionName,
      Payload: new TextEncoder().encode(JSON.stringify(payload))
    }))
    expect(invokeRes.StatusCode).toBe(200)

    // DynamoDBに登録されているはずのアイテムを探す
    const dynamoClient = new DynamoDBClient(config)
    const userEventScanRes = await dynamoClient.send(new ScanCommand({
      TableName: 'UserTable'
    }))
    const item = userEventScanRes.Items?.find(i => i.payload?.M?.payload?.M?.name.S === payload.userName) || {}

    // 期待するアイテムが登録されていることを確認する
    expect(unmarshall(item)).toEqual({
      resource_id: expect.any(String),
      sequence: expect.any(String),
      payload: {
        type: 'Create',
        payload: {
          name: payload.userName,
        }
      }
    })
  })
})

これで他のAWSサービスに依存するLambda Functionを手軽にテストできそうです。

まとめ

AWS上に構築したアプリケーションのテストにすこし困っていましたが、開発環境にAWS環境を簡単に用意することができました。 特にAWSの各種サービスに慣れていないときに活用するとよさそうです。

ではでは。

このカウンタは @piyoppi/counter-tools を使っています。

クリックすると匿名でいいねできます。