Terraform管理のLambdaに、ユニットテストを実装してみた(CI/CD)

# はじめに
こんにちは、はやぴー（@HayaP）です。
皆さん、IaCを使っていますか？

IaC（Infrastructure as Code）とは、
サーバーなどのシステムインフラの構築を、コードを用いて行う技術です。

今回は、IaCツールであるTerraformを使用し、
サーバーレスのコンピューティングサービスである、AWS Lambdaを実装したいと思います。

また、ユニットテスト（JEST）を導入し、
**テスト失敗時にはリソースが作成されないように**します。

イメージは、下記のとおりです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478323/9d577353-b06c-0fdd-0e59-adc725562b62.png)



## 対象読者
- IaC（Terraform）を始めてみたい
- コンソールやSAMでLambdaを管理していたが、Terraformを使ってみたい
- Terraformで構築したLambdaに、ユニットテストを導入してみたい。

# 概要
本記事は、下記構成になっています。
もし、ユニットテスト関連のみを知りたい方は
後半からご覧下さい。

1. AWS Lambda関数を作成する
1. TerraformでAWS Lambda関数を構築する
1. AWS Lambda関数に、テスト追加する
1. Terraform Plan時に自動ユニットテストを実施するよう変更
1. テスト

# 詳細
## 前提
検証用に作成した環境は下記です。

| 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のパッケージを指定しています。

```json:packege.json
{
	"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ファイルを以下のようにします。
```javascript:index.js
exports.handler = async (event) => {
  const str = event?.str?.toUpperCase() || '';
  console.log(`Original string: ${event?.str}, Converted string: ${str}`);
  return str;
};
```

例えば、下記eventを受け取ると
```json
{
  "str": "hello, world!"
}
```
下記のように出力します。
簡単なログ変換ツールだと思ってください。
```json
{
  "statusCode": 200,
  "body": "HELLO, WORLD!"
}
```

## 2.TerraformでAWS Lambda関数を構築する
それでは、Terraformで1.で作成したLambdaを定義してみましょう。
ソースコードに変更があった際に、それを検知しLambdaをUpdate(Change)できるよう
source_code_hashに、data "archive_file" "lambda_practice"を指定しています。

```hcl:main.tf
# 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"
}

```

実際に、構築してみましょう。
```bash
terraform apply
```

以上で、Lambdaを実行するためのリソースが完成しました。

## 3.AWS Lambda関数に、テスト追加する
それでは、ユニットテストを導入していきましょう。
今回は、Jestを使ってユニットテストを導入していきます。

テストファイルである、index.test.jsを以下のように編集します。
```javascript: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](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource)を使用します。



main.tfを以下のように編集します。
```diff_hcl: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.テスト
### 正常終了のパターン(テスト成功)
では、実際にコードを変更して試してみましょう。
```diff_javascript:index.js
exports.handler = async (event) => {
+  // hello
  const str = event?.str?.toUpperCase() || '';
  console.log(`Original string: ${event?.str}, Converted string: ${str}`);
  return str;
};
```

Terrafoorm Planを実行してみましょう。
```bash
[root@ip-xxx-xx-xx-xxx lambda-practice]# terraform plan
# 一部省略
Plan: 1 to add, 1 to change, 1 to destroy.
```

想定通り、Changeが１件出ました。
addとdestroyが出ているのは、テストコード実行のトリガーがONになっている為です。

では、Applyをしてみましょう。

```bash
[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されました！

### 異常終了のパターン(テスト失敗)

では、テスト失敗時の挙動を確認しましょう。
テストコードを失敗するように変更します。

```diff_javascript: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 WORL'); //Dを削除
                    });

          it('returns empty string if str is not provided', async () => {
                      const result = await handler({});
                      expect(result).toBe('');
                    });
});
```

Lambda関数を更新するため、
先ほどと同じように、index.jsも変更します。

```diff_javascript: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されなければ成功です。

```bash
[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導入を検討する会社は多いと思います。
是非、その際の参考になれば幸いです。