Terraform 1.7で利用可能なテスト手法の比較と使用例

Terraform 1.7 で利用可能なテストについて、比較のためポイントになりそうな部分を記載しました。

## 利用可能な選択肢(2024/6/14 現在)

| 手法                                                                                                                                                       | 概要                                                   | 記述場所             | 評価タイミング         |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------- | ---------------------- |
| [validation ブロック](https://developer.hashicorp.com/terraform/language/expressions/custom-conditions#input-variable-validation)                          | plan / apply 時に variable の値を検査する              | variable ブロック    | terraform plan / apply |
| [precondition / postcondition ブロック](https://developer.hashicorp.com/terraform/language/expressions/custom-conditions#preconditions-and-postconditions) | plan / apply 時にリソース等のプロパティを検査する      | lifecycle ブロック   | terraform plan / apply |
| [check ブロック](https://developer.hashicorp.com/terraform/language/expressions/custom-conditions#checks-with-assertions)                                  | plan / apply 後の結果の内容を検査する                  | tf ファイル          | terraform plan / apply |
| [terraform test](https://developer.hashicorp.com/terraform/language/tests)(mock 利用なし)                                                                  | 一時的なリソースを作成し、そのリソースの内容を検査する | .tftest.hcl ファイル | terraform test         |
| terraform test([mock 利用あり](https://developer.hashicorp.com/terraform/language/tests/mocking))                                                          | 擬似的なリソースを作成し、そのリソースの内容を検査する | .tftest.hcl ファイル | terraform test         |

## validation ブロック

variable は type を指定すればざっくりと文字列や数字等に入力を限定できますが、もう少し細かい検査を実施できます。

例えば AWS のリージョン指定する変数があるとして、日本のリージョンでしか使えなくするならこんな感じですね。

```hcl:main.tf
variable "region" {
  type        = string
  description = "AWS region"

  validation {
    condition     = var.region == "ap-northeast-1" || var.region == "ap-northeast-3"
    error_message = "Only Japanese regions are allowed."
  }
}
```

terraform plan / apply の時に評価してくれます。
下記のようにエラーを吐いてくれました。

```
│ Error: Invalid value for variable
│
│   on main.tf line N:
│    N: variable "region" {
│     ├────────────────
│     │ var.region is "value"
│
│ Only Japanese regions are allowed.
│
│ This was checked by the validation rule at main.tf:N,N-N.
```

## precondition / postcondition ブロック

こちらも terraform plan / apply の時に評価してくれます。

### precondition

precondition は、リソースの作成や変更前に特定の条件を満たしているかを確認します。
例えば、特定のインスタンスタイプの EC2 インスタンスのみ作成可能にするには、下記のように記載します。

```hcl:main.tf
locals {
  allowed_instance_types = ["t2.micro", "t2.small", "t3.micro"]
}

variable "instance_type" {
  default = "t2.medium"
}

resource "aws_instance" "example" {
  ami           = "ami-12345678"
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = contains(local.allowed_instance_types, var.instance_type)
      error_message = "Instance type must be one of the allowed types."
    }
  }
}
```

変数 `instance_type` の値が、ローカル変数　`allowed_instance_types` の中に含まれないので、下記のエラーが出ます。

```
│ Error: Resource precondition failed
│
│   on main.tf line NN, in resource "aws_instance" "example":
│   NN:       condition     = contains(local.allowed_instance_types, var.instance_type)
│     ├────────────────
│     │ local.allowed_instance_types is tuple with 3 elements
│     │ var.instance_type is "t2.medium"
│
│ Instance type must be one of the allowed types.
```

似たような制限を varidation ブロックでもできますが、precondition で指定した方が、より特定のリソースに関する条件として検査できますね。

### postcondition

postcondition は、リソースの作成や変更後に特定の条件を満たしているかを確認します。
例えば、作成されたインスタンスにパブリック IP アドレスが割り当てられていることを確認するには、下記のように記載します。

```hcl:main.tf
resource "aws_instance" "example" {
  ami           = "ami-hogehoge"
  instance_type = "t2.micro"

  lifecycle {
    postcondition {
      condition     = self.public_ip != ""
      error_message = "The instance must have a public IP address."
    }
  }
}
```

もし「パブリック IPv4 アドレスを自動割り当て」が「いいえ」なサブネットに EC2 インスタンスが作成され、パブリック IP アドレスが割り当てられないと下記のようにエラーになります。

```
│ Error: Resource postcondition failed
│
│   on main.tf line NN, in resource "aws_instance" "example":
│   NN:       condition     = self.public_ip != ""
│     ├────────────────
│     │ self.public_ip is ""
│
│ The instance must have a public IP address.
```

リソースは作成されたままになるので注意が必要です。

## check ブロック

check は事後処理として検証を行い、警告表示のみを行います。

例えば何らかの設計上の規定により、VPC 内のサブネット数の上限を３つまでに制限したい場合は下記のようになります。

```hcl:main.tf
locals {
  max_subnet_count = 3
}

resource "aws_vpc" "example" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "example1" {
  vpc_id     = aws_vpc.example.id
  cidr_block = "10.0.0.0/24"
}

resource "aws_subnet" "example2" {
  vpc_id     = aws_vpc.example.id
  cidr_block = "10.0.1.0/24"
}

resource "aws_subnet" "example3" {
  vpc_id     = aws_vpc.example.id
  cidr_block = "10.0.2.0/24"
}

data "aws_subnets" "example_data" {
  filter {
    name   = "vpc-id"
    values = [aws_vpc.example.id]
  }
}

check "validate_subnet_count" {
  assert {
    condition     = length(data.aws_subnets.example_data.ids) <= local.max_subnet_count
    error_message = "By design rule, you should not create more than 4 subnets in VPC."
  }
}
```

こんな感じで書いておけば、誤ってこのVPCにサブネットを追加してしまった場合に警告を表示してくれます。
警告表示だけなので、異常終了はしない点に注意してください。

下記を追記してみると・・・

```hcl
resource "aws_subnet" "example4" {
  vpc_id     = aws_vpc.example.id
  cidr_block = "10.0.3.0/24"
}
```

`data` でとってきたときの情報を見ているので、とりあえず terraform apply は成功します。

```
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

aws_subnet.example4: Creating...
aws_subnet.example4: Creation complete after 2s [id=subnet-hogefuga]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
```

その後 terraform plan すると再度 `data` が評価されるので、警告が表示されます。

```
│ Warning: Check block assertion failed
│
│   on main.tf line NN, in check "validate_subnet_count":
│   NN:     condition     = length(data.aws_subnets.example_data.ids) <= local.max_subnet_count
│     ├────────────────
│     │ data.aws_subnets.example_data.ids is list of string with 4 elements
│     │ local.max_subnet_count is 3
│
│ By design rule, you should not create more than 4 subnets in VPC.
```

## terraform test

terraform test を使うと、実際に tf ファイルのコードを動かして、その結果に対して評価し、最後に作成したリソースを削除する、ということをしてくれます。
実際にリソースを作るのでより実際の動作に近いテストができますが、グローバルにユニークな ID が必要なリソースは外部から与えられる変数で ID を変えられるようにしておかないと、すでにリソースが作成された tf ファイルのテストを実施できませんので注意が必要です。

例えば S3 バケットの命名が変数　`bucket_name` と一致するか確認するテストは、下記のように書けます。

```hcl:main.tf
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

variable "bucket_name" {
  type = string
}

resource "aws_s3_bucket" "my_bucket" {
  bucket = var.bucket_name
}
```

```hcl:bucket_name.tftest.hcl
run "sets_correct_name" {
  variables {
    bucket_name = "my-bucket-name"
  }

  assert {
    condition     = aws_s3_bucket.my_bucket.bucket == "my-bucket-name"
    error_message = "incorrect bucket name"
  }
}
```

tftest.hcl に記載した内容がテストコードです。
`variables {}` で変数値を渡し、`assert`で作成されたリソースの評価を行っています。

`terraform test` コマンドでテストを実施します。

※ これはあくまで例です。 `my-bucket-name` という名前の S3 バケットがグローバルで存在していればエラーになります

```
  run "sets_correct_name"... fail
╷
│ Error: creating Amazon S3 (Simple Storage) Bucket (my-bucket-name): BucketAlreadyExists: The requested bucket name is not available. The bucket namespace is shared by all users of the system. Please select a different name and try again
```

もし main.tf で下記のように S3 バケット名がベタ書きになっていると、すでに terraform apply 後には `my-bucket-name` という名前の S3 バケットが存在するので、テストが実施できません。

```hcl
resource "aws_s3_bucket" "my_bucket" {
  bucket = "my-bucket-name"
}
```

また、実際にリソースを作成するので、テスト完了まで時間がかかります。

### terraform test での Mock の利用

mock_provider を使用すると、元のプロバイダーと同じスキーマを返すがリソースの作成はしない、ということをしてくれます。

例えば先ほどの例で、`bucket_name.tftest.hcl`に `mock_provider "aws" {}` を追記して下記のようにすると、aws プロバイダーの振る舞いを Mock にしてくれます。

```hcl:bucket_name.tftest.hcl
mock_provider "aws" {}

run "sets_correct_name" {
  variables {
    bucket_name = "my-bucket-name"
  }

  assert {
    condition     = aws_s3_bucket.my_bucket.bucket == "my-bucket-name"
    error_message = "incorrect bucket name"
  }
}
```

このため、実際は S3 バケットが作成されずにテストが実施できますので、リソースの作成の待ち時間がなくスピーディーです。

注意点として、設定していないプロパティはダミーの値が入ります。

https://developer.hashicorp.com/terraform/language/tests/mocking#generated-data

> Mocked providers only generate data for computed attributes. All required resource attributes must be set when you use a mocked provider. If you do not provide a value for an optional computed attribute, Terraform will automatically generate one. The values that Terraform generates depends on the data type:
>
> - Numbers will be 0.
> - Booleans will be false.
> - Strings will be a random 8 character alphanumeric string.
> - Collections, including sets, lists, and maps, will be empty collections.
> - Objects will contain all required sub-attributes generated using this same set of rules recursively.

この特徴を十分に理解した上で利用する必要がありそうです。

例えば先ほどの例だと、S3 バケットのドメイン名(bucket_domain_name)には何も値をセットしていないのでランダムな値となり、この値に関するテストは組めません。

下記のように書いてテストしてみます。

```hcl:bucket_name.tftest.hcl
mock_provider "aws" {}

run "sets_correct_name" {
  variables {
    bucket_name = "my-bucket-name"
  }

  assert {
    condition     = aws_s3_bucket.my_bucket.bucket_domain_name == "my-domain-name"
    error_message = "incorrect domain name"
  }
}
```

そうすると、下記のような出力となり、適切なテストにはなりません。
(`43r4r8wn` の部分は毎回変わります)

```
│ Error: Test assertion failed
│
│   on sample.tftest.hcl line N, in run "sets_correct_name":
│    N:     condition     = aws_s3_bucket.my_bucket.bucket_domain_name == "my-domain-name"
│     ├────────────────
│     │ aws_s3_bucket.my_bucket.bucket_domain_name is "43r4r8wn"
│
│ incorrect domain name
```