はじめに
こんにちは、はやぴー(@HayaP)です。
皆さん、IaCを使っていますか?
IaC(Infrastructure as Code)とは、
サーバーなどのシステムインフラの構築を、コードを用いて行う技術です。
今回は、IaCツールであるTerraformを使用し、
サーバーレスのコンピューティングサービスである、AWS Lambdaを実装したいと思います。
また、ユニットテスト(JEST)を導入し、
テスト失敗時にはリソースが作成されないようにします。
対象読者
- IaC(Terraform)を始めてみたい
- コンソールやSAMでLambdaを管理していたが、Terraformを使ってみたい
- Terraformで構築したLambdaに、ユニットテストを導入してみたい。
概要
本記事は、下記構成になっています。
もし、ユニットテスト関連のみを知りたい方は
後半からご覧下さい。
- AWS Lambda関数を作成する
- TerraformでAWS Lambda関数を構築する
- AWS Lambda関数に、テスト追加する
- Terraform Plan時に自動ユニットテストを実施するよう変更
- テスト
詳細
前提
検証用に作成した環境は下記です。
OS | Terraform | Node.js | JEST |
---|---|---|---|
Amazon Linux2 | Terraform v1.3.9 | nodejs16.x | ^27.0.5 |
0. 準備
今回は、下記のようなディレクトリ構成を想定します。
lambda-practice
├── main.tf
├── variables.tf
└── lambda
├── packege.json
├── index.js
└── index.test.js
packege.jsonは、下記を参考に作成してください。
JESTと、AWS-SDKのパッケージを指定しています。
{
"name": "practice-lambda",
"version": "1.0.0",
"description": "Lambda function",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "Your Name",
"license": "ISC",
"dependencies": {
"aws-sdk": "^2.100.0"
},
"devDependencies": {
"jest": "^27.0.5"
}
}
1. AWS Lambda関数を作成する
まず、AWS Lambda関数用のソースコードをNode.jsで作成します。
今回は、入力(event)文字列を大文字に変換するLambda関数を作成してみます。
lambda/index.jsファイルを以下のようにします。
exports.handler = async (event) => {
const str = event?.str?.toUpperCase() || '';
console.log(`Original string: ${event?.str}, Converted string: ${str}`);
return str;
};
例えば、下記eventを受け取ると
{
"str": "hello, world!"
}
下記のように出力します。
簡単なログ変換ツールだと思ってください。
{
"statusCode": 200,
"body": "HELLO, WORLD!"
}
2.TerraformでAWS Lambda関数を構築する
それでは、Terraformで1.で作成したLambdaを定義してみましょう。
ソースコードに変更があった際に、それを検知しLambdaをUpdate(Change)できるよう
source_code_hashに、data "archive_file" "lambda_practice"を指定しています。
# AWSプロバイダーの設定
provider "aws" {
region = "ap-northeast-1"
}
# Lambda関数の定義
resource "aws_lambda_function" "lambda_practice" {
filename = data.archive_file.lambda_practice.output_path
function_name = var.function_name
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs16.x"
source_code_hash = data.archive_file.lambda_practice.output_base64sha256
environment {
variables = {
NODE_ENV = "production"
}
}
}
data "archive_file" "lambda_practice" {
type = "zip"
output_path = "${path.module}/lambda/lambda_practice.zip"
source_file = "${path.module}/lambda/index.js"
}
# IAMロールの定義
resource "aws_iam_role" "lambda_role" {
name = var.role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# IAMロールに必要な権限を設定するポリシーの定義
resource "aws_iam_role_policy_attachment" "lambda_policy" {
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
role = aws_iam_role.lambda_role.name
}
# 変数定義
variable "function_name" {
default = "lambda-practice"
}
variable "role_name" {
default = "lambda-practice-role"
}
実際に、構築してみましょう。
terraform apply
以上で、Lambdaを実行するためのリソースが完成しました。
3.AWS Lambda関数に、テスト追加する
それでは、ユニットテストを導入していきましょう。
今回は、Jestを使ってユニットテストを導入していきます。
テストファイルである、index.test.jsを以下のように編集します。
const { handler } = require('./index');
describe('handler', () => {
it('returns uppercased string', async () => {
const result = await handler({ str: 'hello world' });
expect(result).toBe('HELLO WORLD');
});
it('returns empty string if str is not provided', async () => {
const result = await handler({});
expect(result).toBe('');
});
});
このテストは、必要最低限ですが、
①入力"hello world"が"HELLO WORLD"に変換され出力されるか?
②入力にstrが入っていない場合、''で返却されるか?
をテストしています。
これで、テストファイルの準備は完了です。
4.Terraform Plan時に自動ユニットテストを実施するよう変更
いよいよ、自動テスト機能をTerraformに定義していきます。
テストに必要なモジュールのInstall、テスト実行には
null_resourceを使用します。
main.tfを以下のように編集します。
# Lambda関数の定義
resource "aws_lambda_function" "lambda_practice" {
filename = data.archive_file.lambda_practice.output_path
function_name = var.function_name
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs16.x"
source_code_hash = data.archive_file.lambda_practice.output_base64sha256
environment {
variables = {
NODE_ENV = "production"
}
}
}
+ resource "null_resource" "lambda_practice_test" {
+ triggers = {
+ source_code_hash = data.archive_file.lambda_practice.output_base64sha256
+ }
+ provisioner "local-exec" {
+ # npm packegeのinstall
+ # テスト(JEST)の実行
+ command = "cd ${path.module}/lambda && npm install && npm run test"
+ # テストがfaildだった場合、Applyを失敗させる。
+ on_failure = fail
+ }
+ }
# --以下略--
5.テスト
正常終了のパターン(テスト成功)
では、実際にコードを変更して試してみましょう。
exports.handler = async (event) => {
+ // hello
const str = event?.str?.toUpperCase() || '';
console.log(`Original string: ${event?.str}, Converted string: ${str}`);
return str;
};
Terrafoorm Planを実行してみましょう。
[root@ip-xxx-xx-xx-xxx lambda-practice]# terraform plan
# 一部省略
Plan: 1 to add, 1 to change, 1 to destroy.
想定通り、Changeが1件出ました。
addとdestroyが出ているのは、テストコード実行のトリガーがONになっている為です。
では、Applyをしてみましょう。
[root@ip-xxx-xx-xx-xxx lambda-practice]# terraform apply
# 一部省略
Plan: 1 to add, 1 to change, 1 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
null_resource.lambda_practice_test: Destroying... [id=4254859263503847519]
null_resource.lambda_practice_test: Destruction complete after 0s
null_resource.lambda_practice_test: Creating...
null_resource.lambda_practice_test: Provisioning with 'local-exec'...
null_resource.lambda_practice_test (local-exec): Executing: ["/bin/sh" "-c" "cd ./lambda && npm install && npm run test"]
aws_lambda_function.lambda_practice: Modifying... [id=lambda-practice]
null_resource.lambda_practice_test (local-exec): up to date, audited 366 packages in 1s
null_resource.lambda_practice_test (local-exec): 42 packages are looking for funding
null_resource.lambda_practice_test (local-exec): run `npm fund` for details
null_resource.lambda_practice_test (local-exec): found 0 vulnerabilities
null_resource.lambda_practice_test (local-exec): > my-lambda-function@1.0.0 test
null_resource.lambda_practice_test (local-exec): > jest
null_resource.lambda_practice_test (local-exec): console.log
null_resource.lambda_practice_test (local-exec): Original string: hello world, Converted string: HELLO WORLD
null_resource.lambda_practice_test (local-exec): at handler (index.js:4:12)
null_resource.lambda_practice_test (local-exec): console.log
null_resource.lambda_practice_test (local-exec): Original string: undefined, Converted string:
null_resource.lambda_practice_test (local-exec): at handler (index.js:4:12)
null_resource.lambda_practice_test (local-exec): PASS ./index.test.js
null_resource.lambda_practice_test (local-exec): handler
null_resource.lambda_practice_test (local-exec): ✓ returns uppercased string (26 ms)
null_resource.lambda_practice_test (local-exec): ✓ returns empty string if str is not provided (3 ms)
null_resource.lambda_practice_test (local-exec): Test Suites: 1 passed, 1 total
null_resource.lambda_practice_test (local-exec): Tests: 2 passed, 2 total
null_resource.lambda_practice_test (local-exec): Snapshots: 0 total
null_resource.lambda_practice_test (local-exec): Time: 0.404 s, estimated 1 s
null_resource.lambda_practice_test (local-exec): Ran all test suites.
null_resource.lambda_practice_test: Creation complete after 3s [id=2653020907830787170]
aws_lambda_function.lambda_practice: Modifications complete after 6s [id=lambda-practice]
Apply complete! Resources: 1 added, 1 changed, 1 destroyed.
[root@ip-172-31-36-119 lambda-practice]#
テストが成功し、Applyされました!
異常終了のパターン(テスト失敗)
では、テスト失敗時の挙動を確認しましょう。
テストコードを失敗するように変更します。
const { handler } = require('./index');
describe('handler', () => {
it('returns uppercased string', async () => {
const result = await handler({ str: 'hello world' });
+ expect(result).toBe('HELLO WORL'); //Dを削除
});
it('returns empty string if str is not provided', async () => {
const result = await handler({});
expect(result).toBe('');
});
});
Lambda関数を更新するため、
先ほどと同じように、index.jsも変更します。
exports.handler = async (event) => {
+ // hello world
const str = event?.str?.toUpperCase() || '';
console.log(`Original string: ${event?.str}, Converted string: ${str}`);
return str;
};
では、Applyを実行してみましょう。
テストが失敗し、Applyされなければ成功です。
[root@ip-xxx-xx-xx-xxx lambda-practice]# terraform apply
# 一部省略
null_resource.lambda_practice_test (local-exec): 42 packages are looking for funding
null_resource.lambda_practice_test (local-exec): run `npm fund` for details
null_resource.lambda_practice_test (local-exec): found 0 vulnerabilities
null_resource.lambda_practice_test (local-exec): > my-lambda-function@1.0.0 test
null_resource.lambda_practice_test (local-exec): > jest
null_resource.lambda_practice_test (local-exec): console.log
null_resource.lambda_practice_test (local-exec): Original string: hello world, Converted string: HELLO WORLD
null_resource.lambda_practice_test (local-exec): at handler (index.js:4:12)
null_resource.lambda_practice_test (local-exec): console.log
null_resource.lambda_practice_test (local-exec): Original string: undefined, Converted string:
null_resource.lambda_practice_test (local-exec): at handler (index.js:4:12)
null_resource.lambda_practice_test (local-exec): FAIL ./index.test.js
null_resource.lambda_practice_test (local-exec): handler
null_resource.lambda_practice_test (local-exec): ✕ returns uppercased string (32 ms)
null_resource.lambda_practice_test (local-exec): ✓ returns empty string if str is not provided (5 ms)
null_resource.lambda_practice_test (local-exec): ● handler › returns uppercased string
null_resource.lambda_practice_test (local-exec): expect(received).toBe(expected) // Object.is equality
null_resource.lambda_practice_test (local-exec): Expected: "HELLO WORL"
null_resource.lambda_practice_test (local-exec): Received: "HELLO WORLD"
null_resource.lambda_practice_test (local-exec): 4 | it('returns uppercased string', async () => {
null_resource.lambda_practice_test (local-exec): 5 | const result = await handler({ str: 'hello world' });
null_resource.lambda_practice_test (local-exec): > 6 | expect(result).toBe('HELLO WORL');
null_resource.lambda_practice_test (local-exec): | ^
null_resource.lambda_practice_test (local-exec): 7 | });
null_resource.lambda_practice_test (local-exec): 8 |
null_resource.lambda_practice_test (local-exec): 9 | it('returns empty string if str is not provided', async () => {
null_resource.lambda_practice_test (local-exec): at Object.<anonymous> (index.test.js:6:24)
null_resource.lambda_practice_test (local-exec): Test Suites: 1 failed, 1 total
null_resource.lambda_practice_test (local-exec): Tests: 1 failed, 1 passed, 2 total
null_resource.lambda_practice_test (local-exec): Snapshots: 0 total
null_resource.lambda_practice_test (local-exec): Time: 0.446 s, estimated 1 s
null_resource.lambda_practice_test (local-exec): Ran all test suites.
aws_lambda_function.lambda_practice: Modifications complete after 6s [id=lambda-practice]
╷
│ Error: local-exec provisioner error
│
│ with null_resource.lambda_practice_test,
│ on main.tf line 25, in resource "null_resource" "lambda_practice_test":
│ 25: provisioner "local-exec" {
│
│ Error running command 'cd ./lambda && npm install && npm run test': exit status 1. Output:
│ up to date, audited 366 packages in 1s
│
│ 42 packages are looking for funding
│ run `npm fund` for details
│
│ found 0 vulnerabilities
│
│ > my-lambda-function@1.0.0 test
│ > jest
│
│ console.log
│ Original string: hello world, Converted string: HELLO WORLD
│
│ at handler (index.js:4:12)
│
│ console.log
│ Original string: undefined, Converted string:
│
│ at handler (index.js:4:12)
│
│ FAIL ./index.test.js
│ handler
│ ✕ returns uppercased string (32 ms)
│ ✓ returns empty string if str is not provided (5 ms)
│
│ ● handler › returns uppercased string
│
│ expect(received).toBe(expected) // Object.is equality
│
│ Expected: "HELLO WORL"
│ Received: "HELLO WORLD"
│
│ 4 | it('returns uppercased string', async () => {
│ 5 | const result = await handler({ str: 'hello world' });
│ > 6 | expect(result).toBe('HELLO WORL');
│ | ^
│ 7 | });
│ 8 |
│ 9 | it('returns empty string if str is not provided', async () => {
│
│ at Object.<anonymous> (index.test.js:6:24)
│
│ Test Suites: 1 failed, 1 total
│ Tests: 1 failed, 1 passed, 2 total
│ Snapshots: 0 total
│ Time: 0.446 s, estimated 1 s
│ Ran all test suites.
│
╵
想定通りテストは失敗し、Applyは中止されました。
まとめ
いかがだったでしょうか。
SAMやServerless frameworkなどでは、Lambdaのテスト戦略に関する情報は多いものの
Terraformの情報は少なかったので、作成してみました。
特に、中規模~大規模の企業ではTerraform導入を検討する会社は多いと思います。
是非、その際の参考になれば幸いです。