5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-02-26

はじめに

こんにちは、はやぴー(@HayaP)です。
皆さん、IaCを使っていますか?

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

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

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

イメージは、下記のとおりです。
image.png

対象読者

  • IaC(Terraform)を始めてみたい
  • コンソールやSAMでLambdaを管理していたが、Terraformを使ってみたい
  • Terraformで構築したLambdaに、ユニットテストを導入してみたい。

概要

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

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

詳細

前提

検証用に作成した環境は下記です。

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

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ファイルを以下のようにします。

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"を指定しています。

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"
}

実際に、構築してみましょう。

terraform apply

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

3.AWS Lambda関数に、テスト追加する

それでは、ユニットテストを導入していきましょう。
今回は、Jestを使ってユニットテストを導入していきます。

テストファイルである、index.test.jsを以下のように編集します。

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を以下のように編集します。

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.テスト

正常終了のパターン(テスト成功)

では、実際にコードを変更して試してみましょう。

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を実行してみましょう。

[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されました!

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

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

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も変更します。

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

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?