🍜

AWSを使用したアプリケーションのローカルテスト

2024/05/17に公開

AWSを使用したアプリケーションのテスト方法

AWSを使用しているアプリケーションの開発時に問題となるのは、どのようにテストを実施するかという問題が発生します。
この時の選択肢は次の通りとなります。

  • 単体テスト時にAWSのモックを作成してテストを行う
  • AWS上にテスト用の環境を用意してテスト時に接続してテストを行う
  • LocalStackを使用してローカルにAWSサービスのエミュレーターを動かしてテストする

単体テスト時にAWSのモックを作成してテストを行う

単体テスト時にモックアップする方法はテストに都合のいいデータを返せるため容易にテストが行えます。
モックの作成に手間がかかりますし、思い込みでモックを作成してバグを作成する場合もありますが、Motoなどを用いることでこれらの問題は軽減できるでしょう。
ただし、あくまで単体テストでしか使用できません。例えば複数プロセスを連携するようなテストが必要な場合、うまくいかないでしょう。

AWS上にテスト用の環境を用意してテスト時に接続してテストを行う

AWS上に検証環境を構築してテストを行うというのはコストが許すならば有効なアイディアです。
しかしながら、テスト環境が1つしかない場合、同時に別のテストを行うことができずテスト実行の容易性に問題が発生します。
これを回避するには、同時に実行するテストごとに環境を用意することですが、コストの問題で困難でしょう。

LocalStackを使用してローカルにAWSサービスのエミュレーターを動かしてテストする
第三の選択肢としてローカル環境でAWSのサービスを動かせるLocalStackを使用してテストを実施する方法もあります。

この方法のメリットとしては以下の通りです。

  • テスト環境の分離が容易
    • 破壊的なテストを行いやすい
    • テスト実行の競合が発生しない
  • AWSの機能を多くエミュレートしている
    • Community版でもある程度のAWSのサービスをローカルで動かせるが、有償のPro版を使用することでより多くの機能が使用できるようになる。

本記事ではローカル環境でAWSのサービスを動かしてテストをするためにLocalStackの使用方法について解説しようと思います。

LocalStackの起動方法

以下のページにLocalStackのインストール方法が書いてあります。

https://docs.localstack.cloud/getting-started/installation/

環境を汚さずにテストをするならDocker-composeを使用した方法が良いでしょう。

version: '3.8'

services:
  localstack:
    image: localstack/localstack
    container_name: localstack_main
    ports:
      - "4566-4599:4566-4599"
    environment:
      - TZ=Asia/Tokyo
      - DEFAULT_REGION=ap-northeast-1
      - SERVICES=s3,sqs,dynamodb,ses,lambda,logs,stepfunctions
      - DYNAMODB_SHARE_DB=1 
      - DEBUG=1
      - DATA_DIR=/tmp/localstack/data
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"

この例では、s3, sqs, dynamodb, lambda, sesなどをサービスとして起動しています。
その他、設定の詳細については下記のドキュメントを参照してください。

https://docs.localstack.cloud/references/configuration/

LocalStack Desktop

LocalStack DesktopをインストールすることでGUIでLocalStackの操作を行うことが可能になります。
SignUpを行うことでLocalStack Desktopのダウンロードが可能になります。

LocalStackDeskを使用して、作成したLocalStackのコンテナに接続することが可能です。

その後、ResourceBrowserにアクセスすることでLocalStack上のS3やDynamoDBの内容を確認、変更が可能になります。

LocalStackのS3のバケットの閲覧例

LocalStackのDynamoDBの閲覧例

AWSのサービスをローカルで動かすサンプル

ここでは以下のAWSのサービスをローカルで動かすサンプルを紹介します。

サンプルを動かすためには以下のパッケージをインストールする必要があります。

package.json
{
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.564.0",
    "@aws-sdk/client-ec2": "^3.564.0",
    "@aws-sdk/client-iam": "^3.564.0",
    "@aws-sdk/client-lambda": "^3.564.0",
    "@aws-sdk/client-s3": "^3.556.0",
    "@aws-sdk/client-ses": "^3.564.0",
    "@aws-sdk/client-sfn": "^3.564.0",
    "@aws-sdk/client-sqs": "^3.564.0",
    "@aws-sdk/client-ssm": "^3.564.0",
    "@aws-sdk/lib-dynamodb": "^3.564.0",
    "archiver": "^7.0.1",
    "axios": "^1.6.8"
  }
}

S3をローカルで動かす

docker-compose.ymlのSERVICE環境変数にs3が含まれている場合、S3のサービスを提供します。
endpointに「http://localhost:4566」を指定することでlocalstackのs3の操作が可能になります。

aws cliでのサンプル

aws s3api list-objects --bucket test-bucket --endpoint-url http://localhost:4566

Node.jsでのサンプル

  const s3Client = new s3.S3Client({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',
    forcePathStyle: true // このクライアントにバケットに対してパス形式のアドレス指定を使用するように強制します。
                         // デフォルトはfalseだがtrueにしておかなとLocalStackで動作しない
  })

バケットに登録されたオブジェクトは以下のURLから確認することが可能です。

http://localhost:4566/バケット名

また、バケット名:test-bucketにtest/test.txtオブジェクトを追加した場合、以下のURLからオブジェクトをダウンロードできます。

http://localhost:4566/test-bucket/test/test.txt

次のサンプルではLocalStackのS3に対してバケットの作成、オブジェクトをアップロードして、ダウンロードを行うサンプルになっています。

S3の操作例
const s3 = require('@aws-sdk/client-s3')
const fs = require('fs')

async function testS3() {
  const s3Client = new s3.S3Client({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',
    forcePathStyle: true // このクライアントにバケットに対してパス形式のアドレス指定を使用するように強制します。
                         // デフォルトはfalseだがtrueにしておかなとLocalStackで動作しない
  })
  // バケットの存在チェックと削除
  const bucket_name = 'test-bucket'
  const { Buckets } = await s3Client.send(new s3.ListBucketsCommand({}))
  if (Buckets && Buckets.some(bucket => bucket.Name === bucket_name)) {
    console.log('バケットの削除')
    // パケットを空にする。このロジックは1000件程度が上限
    const listObjectsResponse = await s3Client.send(new s3.ListObjectsV2Command({
      Bucket: bucket_name
      // ContinuationToken: continuationToken
    }))
    if (listObjectsResponse && listObjectsResponse.Contents && listObjectsResponse.Contents.length > 0) {
      const deleteParams = {
        Bucket: bucket_name,
        Delete: {
          Objects: listObjectsResponse.Contents.map(obj => ({ Key: obj.Key }))
        }
      }
      await s3Client.send(new s3.DeleteObjectsCommand(deleteParams))
    }
    // continuationToken = listObjectsResponse.NextContinuationToken;
    await s3Client.send(new s3.DeleteBucketCommand({ Bucket: bucket_name }))    
  }
  // パケットの作成
  console.log('バケットの作成')
  await s3Client.send(new s3.CreateBucketCommand({
    Bucket: bucket_name
  }))
  //
  console.log('オブジェクト追加')
  const fileStream = fs.createReadStream("./test.txt")
  const uploadParams = {
    Bucket: bucket_name,
    Key: "test/test.txt",
    Body: fileStream
  }
  const command = new s3.PutObjectCommand(uploadParams)
  await s3Client.send(command)
  // オブジェクトのダウンロードの確認
  console.log('オブジェクト取得')
  const res = await s3Client.send(new s3.GetObjectCommand(uploadParams))
  const chunks = []
  for await (const chunk of res.Body) {
    chunks.push(Buffer.from(chunk))
  }
  console.log(Buffer.concat(chunks).toString('utf-8'))
}

testS3()

参考:
https://docs.localstack.cloud/user-guide/aws/s3/

DynamoDBをローカルで動かす

docker-compose.ymlのSERVICE環境変数にdynamodbが含まれている場合、Dynamoのサービスを提供します。

endpointに「http://localhost:4566」を指定することでlocalstackのdynamoDbの操作が可能になります。

aws cliでのサンプル

aws dynamodb describe-table --table-name TestTable --endpoint-url http://localhost:4566

Node.jsでのサンプル

const dynamodbClient = require('@aws-sdk/client-dynamodb')
const dynamoDBClient = new dynamodbClient.DynamoDBClient({
  endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
  region: 'ap-northeast-1',
})

また、SERVICE環境変数のDYNAMODB_SHARE_DB=1とすることで、リージョンごとにdynamoDBを作成せずに全てのリージョン共有のDynamoDBを作成します。これにより、NoSQL Workbenchを使用してDynamoDB localに接続してGUIによるDynamoDBの操作が可能になります。

DynamoDB table created by Terraform in LocalStack not visible in NoSQL Workbench
https://stackoverflow.com/questions/69406956/dynamodb-table-created-by-terraform-in-localstack-not-visible-in-nosql-workbench

次のサンプルではLocalStackのDynamoDBに対してテーブルを作成後、オブジェクトを追加、取得するものとなります。

DynamoDBの操作例
const dynamodbClient = require('@aws-sdk/client-dynamodb')
const libDynamoDb = require('@aws-sdk/lib-dynamodb')
async function testDynamoDb() {
  const dynamoDBClient = new dynamodbClient.DynamoDBClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',
  })
  const docClient = libDynamoDb.DynamoDBDocumentClient.from(dynamoDBClient)
  const tableName = 'TestTable'
  {
    try {
      // dynamodbのテーブルの存在チェック
      const res = await dynamoDBClient.send(
        new dynamodbClient.DescribeTableCommand({
          TableName: tableName
        })
      )
      console.log(res)
      // dynamodbのテーブル削除
      await dynamoDBClient.send(new dynamodbClient.DeleteTableCommand({
        TableName: tableName
      }))
    } catch (error) {
      if (error instanceof dynamodbClient.ResourceNotFoundException) {
        console.log(`Table ${tableName} does not exist.`)
      } else {
        throw error
      }
    }
    // テーブルの再作成
    await docClient.send(
      new dynamodbClient.CreateTableCommand({
        TableName: tableName,
        AttributeDefinitions: [
          {
            AttributeName: 'userId',
            AttributeType: 'S'
          },
        ],
        KeySchema: [
          {
            AttributeName: 'userId',
            KeyType: 'HASH'
          }
        ],
        ProvisionedThroughput: {
          ReadCapacityUnits: 1,
          WriteCapacityUnits: 1
        }
      })
    )
  }
  // オブジェクトの追加
  await docClient.send(new libDynamoDb.PutCommand({
    TableName: tableName,
    Item: {
      userId: 'U0001',
      name: 'test太郎',
      age: 1234
    }
  }))
  // オブジェクトの取得
  const res = await docClient.send(new libDynamoDb.GetCommand({
    TableName: tableName,
    Key: {
      userId: 'U0001'
    }
  }))
  console.log(res)
}

testDynamoDb()

参考
https://docs.localstack.cloud/user-guide/aws/dynamodb/

SQSをローカルで動かす

docker-compose.ymlのSERVICE環境変数にsqsが含まれている場合、SQSによるキュー操作のサービスを提供します。

endpointに「http://localhost:4566」を指定することでlocalstackのSQSClientの操作が可能になります。

aws cliでのサンプル

aws sqs get-queue-attributes --queue-url http://sqs.ap-northeast-1.localhost.localstack.cloud:4566/000000000000/test.fifo --attribute-names All --endpoint-url http://localhost:4566

Node.jsでのサンプル

  const sqsClient = new SQSClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  })

送信されたメッセージについては以下のURLから確認することが可能です。

http://localhost.localstack.cloud:4566/_aws/sqs/messages?QueueUrl=キューのURL

以下はFIFOのキューの作成を行い、キューへのメッセージの送信、受信を行うサンプルとなります。

SQSの操作例
const { SQSClient, GetQueueUrlCommand, DeleteQueueCommand, CreateQueueCommand, QueueDoesNotExist, SendMessageCommand, ReceiveMessageCommand }  = require('@aws-sdk/client-sqs')
async function testSqs() {
  const sqsClient = new SQSClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  })
  const queueName = 'test.fifo'
  try {
     // キューURLを取得
     const { QueueUrl } = await sqsClient.send(new GetQueueUrlCommand({
      QueueName: queueName
    }))

    // キューを削除
    await sqsClient.send(new DeleteQueueCommand({
      QueueUrl
    }))
    console.log(`Queue ${queueName} deleted successfully.`)
  } catch (error) {
    if (error instanceof QueueDoesNotExist) {
      console.log(`Queue ${queueName} does not exist.`)
    } else {
      console.error('Error:', error)
      throw error
    }
  }
  // キューの作成
  const { QueueUrl } = await sqsClient.send(new CreateQueueCommand({
    QueueName: queueName,
    Attributes: {
      FifoQueue: 'true',
      VisibilityTimeout: '3',
      ContentBasedDeduplication: 'true' // メッセージ重複除去機能を有効化
    }
  }))
  // キューにデータを詰める
  await sqsClient.send(
    new SendMessageCommand({
      QueueUrl: QueueUrl,
      MessageBody: JSON.stringify({
        id: 1,
        name: "test1"
      }),
      MessageGroupId: 'group1',
      MessageDeduplicationId: 'duplicationId1'
    })
  )
  await sqsClient.send(
    new SendMessageCommand({
      QueueUrl: QueueUrl,
      MessageBody: JSON.stringify({
        id: 2,
        name: "test2"
      }),
      MessageGroupId: 'group1',
      MessageDeduplicationId: 'duplicationId2'
    })
  )
  // キューからデータの取得
  const receivedMessages = await sqsClient.send(new ReceiveMessageCommand({
    QueueUrl: QueueUrl,
    MaxNumberOfMessages: 10, // 一度に受信する最大メッセージ数
    WaitTimeSeconds: 10, // ロングポーリング
    AttributeNames: ['All']
  }))
  for (const msg of receivedMessages.Messages) {
    console.log(msg)
  }
}

testSqs()

参考
https://docs.localstack.cloud/user-guide/aws/sqs/

SESをローカルで動かす

docker-compose.ymlのSERVICE環境変数にsesが含まれている場合、SESによるメール送信のエミュレーションが動作します。
これはあくまでエミュレーションであり、実際にメールの送信を行うわけではありません。

endpointに「http://localhost:4566」を指定することでlocalstackのSESClientの操作が可能になります。

aws cliのサンプル

aws ses list-identities --endpoint-url http://localhost:4566

Node.jsのサンプル

  const sesClient = new SESClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  })

また、送信されたメールの確認については以下のURLにアクセスすることで送信履歴を確認できます。

http://localhost:4566/_aws/ses?email=送信者メールアドレス

次のサンプルはメール送信を行い、その送信内容を確認するサンプルになります

SESの操作例
const  { SESClient, SendEmailCommand, VerifyEmailIdentityCommand } = require('@aws-sdk/client-ses')

async function testSes() {
  const senderMail = 'sender@test.co.jp'
  const receiverMail = 'receiver@test.co.jp'
  const sesClient = new SESClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  })
  // 送信メールをVerifyする
  await sesClient.send(new VerifyEmailIdentityCommand({
    EmailAddress: senderMail
  }))

  // メールの送信
  await sesClient.send(new SendEmailCommand({
    Source: senderMail,
    Destination: {
      ToAddresses: [
        receiverMail
      ]
    },
    Message: {
      Subject: {
        Data: "テストメール"
      },
      Body: {
        Text: {
          Data: "テストの本文"
        }
      }
    }
  }))
  // 送信メールの確認
  const axios = require('axios');
  const res = await axios.get(`http://localhost:4566/_aws/ses?email=${senderMail}`);
  console.log(res.data)  
}
testSes()

参考
https://docs.localstack.cloud/user-guide/aws/ses/

Lambdaをローカルで動かす

Lambdaをローカルに動かすには以下の環境変数のSERVICESにlambdaを追加する必要があります。

endpointに「http://localhost:4566」を指定することでlocalstackのLambdaClientの操作が可能になります。

aws cliのサンプル

aws lambda list-functions --endpoint-url http://localhost:4566

Node.jsのサンプル

  const lambdaClient = new LambdaClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',    
  })

もしlambda実行時の標準出力時を確認したい場合はSERVICESにlogsも追加します。
以下のコマンドでLambdaの標準出力の内容が確認できるようになります。

aws --endpoint-url=http://localhost:4566 logs tail /aws/lambda/testFunc --follow

以下のサンプルではLambdaを作成後、実行するサンプルになります。

Lambdaの操作例

Lambda関数

lambda/test_func.py
import json


def handler(event, context):
    print('handler.....lambda')
    return {
        "statusCode": 200,
        "body": json.dumps({"message": "正常に起動しました"}),
        "headers": {"Content-Type": "application/json"},
    }

Lambda関数の操作例

const { LambdaClient, CreateFunctionCommand, GetFunctionCommand, DeleteFunctionCommand, ResourceNotFoundException, InvokeCommand } = require('@aws-sdk/client-lambda')
const archiver = require('archiver')

async function zipDirectoryToBuffer (sourceDir) {
  return new Promise((resolve, reject) => {
    const archive = archiver('zip', { zlib: { level: 9 } }) // 圧縮レベルを指定
    const chunks = []

    // メモリへの出力ストリームを作成
    archive.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
    archive.on('error', (err) => reject(err))
    archive.on('end', () => resolve(Buffer.concat(chunks))) // すべてのチャンクを連結して完了

    archive.directory(sourceDir, false) // フォルダの内容をアーカイブに追加
    archive.finalize() // 圧縮処理の開始
  })
}
async function test_lambda() {
  const functionName = 'testFunc'
  const handler = "test_func.handler"
  const lambdaClient = new LambdaClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',    
  })
  // 存在した関数を削除
  try {
    const data = await lambdaClient.send(new GetFunctionCommand({ FunctionName: functionName }))
    console.log('Function exists:', data)
    // Lambdaが存在するので削除
    const commandDel = new DeleteFunctionCommand({ FunctionName: functionName })
    await lambdaClient.send(commandDel)
  } catch (err) {
    if (err instanceof ResourceNotFoundException) {
      console.log(`${functionName} Function does not exist.`)
    } else {
      throw err
    }
  }
  // 関数を作成
  console.log('Lambda作成')
  // フォルダを選択してzipに圧縮
  const codeBuffer = await zipDirectoryToBuffer("./lambda")
  const commandCreate = new CreateFunctionCommand({
    FunctionName: functionName,
    Runtime: 'python3.9',
    Role: 'arn:aws:iam::000000000000:role/lambda-sample-role', // ローカル環境用のダミーのロールARN
    Handler: handler,
    Code: {
      ZipFile: Buffer.from(codeBuffer)
    }
  })
  const res = await lambdaClient.send(commandCreate)
  console.log('Lambda関数', res)

  // Lambda作成後 Activeになるまで待機
  while (true) {
    const data = await lambdaClient.send(new GetFunctionCommand({ FunctionName: functionName }))
    console.log('Function exists:', data.Configuration.State)
    if (data.Configuration.State === 'Active') {
      break
    }
  }
  // 関数実行
  const resInvoke = await lambdaClient.send(new InvokeCommand({
    FunctionName: functionName
  }))
  console.log('Lambda実行', resInvoke.StatusCode)
  const payload = JSON.parse(new TextDecoder("utf-8").decode(resInvoke.Payload));
  console.log(JSON.parse(payload.body)); // body の内容をログに出力
}

test_lambda()

参考
https://docs.localstack.cloud/user-guide/aws/lambda/

StepFunctionをローカルで動かす

docker-compose.ymlのSERVICE環境変数にstepfunctionsが含まれている場合、StepFunctionのサービスを提供します。

endpointに「http://localhost:4566」を指定することでlocalstackのStepFunctionの操作が可能になります。

aws cliでのサンプル

aws stepfunctions describe-state-machine --state-machine-arn "arn:aws:states:ap-northeast-1:000000000000:stateMachine:TestFuncName"  --endpoint-url=http://localhost:4566

Node.jsでのサンプル

  const sfnClient = new SFNClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  });

次のサンプルではステップファンクションの再作成を行い、作成したステップファンクションを実行します。

StepFunctionの操作例
const { SFNClient, DescribeStateMachineCommand, DeleteStateMachineCommand, CreateStateMachineCommand, StartExecutionCommand } = require('@aws-sdk/client-sfn');

async function test() {
  const sfnClient = new SFNClient({
    endpoint: "http://localhost:4566", // endpointにlocalStackのエンドポイントを指定する
    region: 'ap-northeast-1',  
  });
  const stateMachineName = 'TestFuncName';
  try {
    // 状態マシンの存在確認
    const describeResponse = await sfnClient.send(new DescribeStateMachineCommand({
      stateMachineArn: `arn:aws:states:ap-northeast-1:000000000000:stateMachine:${stateMachineName}`
    }))
    console.log("State Machine exists:", describeResponse.stateMachineArn);

    // 存在する場合は削除
    const deleteCommand = new DeleteStateMachineCommand({ stateMachineArn: describeResponse.stateMachineArn });
    await sfnClient.send(deleteCommand);
    console.log("State Machine deleted.");
  } catch (describeError) {
    if (describeError.name === 'StateMachineDoesNotExist') {
      console.log("State Machine does not exist.");
    } else {
      throw describeError; // 他のエラーは再スロー
    }
  }
  // 状態マシンの再作成
  const createCommand = new CreateStateMachineCommand({
    name: stateMachineName,
    definition: JSON.stringify({
      StartAt: "Task",
      States: {
        Task: {
          Type: "Pass",
          Result: "Task completed",
          End: true,
        },
      },
    }),
    roleArn: 'arn:aws:iam::000000000000:role/step-function-role',
  });
  const createResponse = await sfnClient.send(createCommand);
  console.log("State Machine recreated:", createResponse.stateMachineArn);
  const startResponse = await sfnClient.send(new StartExecutionCommand({
    stateMachineArn: createResponse.stateMachineArn,
    input: JSON.stringify({ key: "value" }), // 必要に応じて入力を設定
  }));
  console.log(startResponse)

}

test();

参考
https://docs.localstack.cloud/user-guide/aws/stepfunctions/

まとめ

AWSを使用したアプリケーションをテストする場合にはAWSのモックアップを考える必要があります。
最善の方法は実際のAWS上にテスト環境を用意することです。しかしながら、これはテストの容易性やコスト面で問題があります。

今回は、LocalStackを使用することでローカルの環境でAWSのいくつかのサービスをエミュレーションでき、テストが可能にする方法を紹介しました。
テスト対象のコードからLocalStackに繋げる場合はendpoint-urlを指定するだけで可能になります。
LocalStackを使用したテストの方法は、多くの状況でAWSを使用するアプリケーションのテストの作業効率を上げることが期待できるでしょう。

CareNet Engineers

Discussion