Terraformで単体テストのようなものを実施する

# はじめに
Google CloudのインフラをTerraformで記述しており、コードの堅牢性を高めるために単純でも単体テストのようなものを作った方がよいとなりました。そこで、初めてでも最低限単体テストのようなもの作ってみるというのが今回の記事になります。

# .tftest.hcl
Terraformのテストは通常`*.tftest.hcl`という名前のファイルに記載します。「`*`」は任意の文字列を意味しますので、「`test.tftest.hcl`」や「`validate_variables.tftest.hcl`」などのようなファイル名になります。
以下はTerraformのスタイルガイドの構成にテストファイルを追加したものです。`modules`の各フォルダ内にテストファイルを追加しています。

```
.
├── modules
│   ├── function
│   │   ├── main.tf      # contains aws_iam_role, aws_lambda_function
│   │   ├── outputs.tf
│   │   └── variables.tf
|   |   └── test.tftest.hcl # 追加したテストファイル
│   ├── queue
│   │   ├── main.tf      # contains aws_sqs_queue
│   │   ├── outputs.tf
│   │   └── variables.tf
|   |   └── test.tftest.hcl # 追加したテストファイル
│   └── vpc
│       ├── main.tf      # contains aws_vpc, aws_subnet
│       ├── outputs.tf
│       └── variables.tf
|       └── test.tftest.hcl # 追加したテストファイル
├── main.tf 
├── outputs.tf
└── variables.tf
```

https://developer.hashicorp.com/terraform/language/style#module-repository-names


## .tftest.hclの書き方

次のようなコードを仮定しますGoogle CloudでCloud Storageを作成するものです。
```terraform
# main.tf
resource "google_storage_bucket" "data" {
  name          = "cs-${var.env}"
  location      = var.location
  force_destroy = true

  storage_class = "STANDARD"
}
```

```terraform
# variable.tf
variable "env" {
  description = "Environment name"
  type        = string
  # 1文字以上3文字以内の半角英小文字とするバリデーション
  validation {
    condition     = can(regex("^[a-z]{1,3}$", var.env))
    error_message = "The environment name must be 1 to 3 length a-z characters."
  }
}

variable "location" {
  description = "Location for the Cloud Storage"
  type        = string

  # GCPで定義されているリージョンのバリデーション(簡易版：[文字列]-[文字列][数字列])
  validation {
    condition     = can(regex("^([a-z]+-[a-z]+[0-9]+)$", var.location))
    error_message = "region must be GCP region(ex. asia-northeast1)."
  }
}
```

このコードに対してテストを作成してみます。

```terraform
# test.tftest.hcl
variables {
  env      = "dev"
  location = "asia-northeast1"
}

provider "google" {
  project = "project-${var.env}"

}

run "vaild_variables" {
  command = plan

  variables {
    env      = "dev"
    location = "asia-northeast1"
  }
  assert {
    condition     = can(regex("^[a-z]{1,3}$", var.env))
    error_message = "The environment name must be 1 to 3 length a-z characters."
  }
  assert {
    condition     = can(regex("^([a-z]+-[a-z]+[0-9]+)$", var.location))
    error_message = "region must be GCP region(ex. asia-northeast1)."
  }
}

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


# フォーマットが不正なロケーション(ハイフンなし)
run "invalid_location2" {
  command = plan

  variables {
    env      = "dev"
    location = "asianortheast2"
  }
  expect_failures = [var.location]
}


~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

```

## provider
このテストファイルを実行される際に用いるproviderの設定を行います。
注意点として、後述する`command`ブロックで値を`apply`にした場合、テストの実行時に実際にリソースがproviderで指定したプロジェクトに作成されるということです。実運用環境を指定して稼働中のサービスを破壊してしまわないように注意する必要があります。

また、基本的にはテスト終了時にここで作成されたリソースは削除されますが、（削除保護がされていたり、リソース間の依存関係の問題で）削除に失敗してしまった場合には、手動でリソースを削除する必要があります。

## mock
providerで述べたように、テスト時に`command`の値を`apply`にした場合は実際に環境にリソースが作成されます。これを避ける方法として`mock`の機能が提供されています。
基本的な使い方は、`provider`ブロックを置き換えるだけです。

```terraform
mock_provider "google" {}
```

## run
`run`ブロックがテストを実行する単位です。この中にテストしたい処理を書いていきます。

### command
`command`はその`run`がterraformのどの部分の実行を行うかを指定するものです。
`command`には`plan`または`apply`の引数を受け取り、デフォルト値は`apply`です。
つまり、`command`でこのテストが`terraform plan`と`terraform apply`のどちらを実行するのかを指定するものです。

### assert
`assert`はある条件を検証してそれが真であることを期待する動作をするブロックです。
`assert` には`condition`と`error_message `を指定でき、`condition`には検証する条件を記載します。
`assert`はこの`condition`に書かれた条件が真になることを期待します。

```terraform
  variables {
    env      = "dev"
    location = "asia-northeast1"
  }
  assert {
    condition     = can(regex("^[a-z]{1,3}$", var.env))
    error_message = "The environment name must be 1 to 3 length a-z characters."
  }
```


例で、上記のテストを考えてみます。変数`env`のバリデーション条件として、値が「1~3文字の小文字アルファベット」であることを期待しています。
このとき`variables`で指定した値は`dev`であるため、この条件は成功します

では、emvの値を`prod`に変更してみます。これは変数のバリデーションに違反します。

```terraform
  variables {
    env      = "prod"
    location = "asia-northeast1"
  }
  assert {
    condition     = can(regex("^[a-z]{1,3}$", var.env))
    error_message = "The environment name must be 1 to 3 length a-z characters."
  }
```

このとき、`assert`は失敗してしまいます。以下のようなメッセージが確認できます
```terminal
test.tftest.hcl... in progress
  run "vaild_variables"... fail
╷
│ Error: Invalid value for variable
│ 
│   on test.tftest.hcl line 15, in run "vaild_variables":
│   15:     env      = "prod"
│     ├────────────────
│     │ var.env is "prod"
│ 
│ The environment name must be 1 to 3 length a-z characters.
│ 
│ This was checked by the validation rule at variables.tf:5,3-13.
```

### expect_failures

`expect_failures`は`terraform test`において、テストが失敗することを期待する際に用いるプロパティです。
`expect_failures`の引数にはresourceやvariables、outputやdata sourceなどのオブジェクトをリストで与えることができ、これらをチェック対象として、問題が発生することを期待するものです。

例えば、ここで`location`変数は「[文字列]-[文字列][数字列]」といった形式であるように条件が設定されています。
そしてテストでは「asianortheast2」という条件に反する値が設定されています。
`expect_failures = [var.location]`で指定したようにこのテストでは`var.location`が問題を返すことが期待されているためこのテストは成功します。（逆説的に、`location`のバリデーションがこのパターンでは正しく機能することがテストできています。）

```terraform
variable "location" {
  description = "Location for the Artifact Registry repository"
  type        = string

  # GCPで定義されているリージョンのバリデーション(簡易版：[文字列]-[文字列][数字列])
  validation {
    condition     = can(regex("^([a-z]+-[a-z]+[0-9]+)$", var.location))
    error_message = "region must be GCP region(ex. asia-northeast1)."
  }
}
```

```terraform
# フォーマットが不正なロケーション(ハイフンなし)
run "invalid_location2" {
  command = plan

  variables {
    env      = "dev"
    location = "asianortheast2"
  }
  expect_failures = [var.location]
}

```

逆に、`location`にバリデーションチェックを通過する正しい値を設定してテストした場合は、このテストケースは失敗します。

```terraform
run "invalid_location2" {
  command = plan

  variables {
    env      = "dev"
    location = "asia-northeast2"
  }
  expect_failures = [var.location]
}

```

```terminal
╷
│ Error: Missing expected failure
│ 
│   on test.tftest.hcl line 80, in run "invalid_location2":
│   80:   expect_failures = [var.location]
│ 
│ The checkable object, var.location, was expected to report an error but did not.
╵
  run "invalid_location3"... skip
  run "invalid_location4"... skip
test.tftest.hcl... tearing down
test.tftest.hcl... fail

Failure! 5 passed, 1 failed, 2 skipped.
```


# テストの実行について
テスト実行は`terraform test`というコマンドで実行することができますが、いくつか注意点があります。

## terraform initが必要
`terraform test`コマンドを実行するためには、そのテストファイルがあるディレクリで`terraform init`が必要です。
つまり、例のフォルダ構成の場合に`function`モジュールのテストを実施したいならば`./modules/function`ディレクトリで`terraform init`が必要です。
そのため、以下のようなコマンドを実行して再帰的にinitを行っておくことをお勧めします。

コマンドでは拡張子が`.tf`のファイルを含むディレクトリを探して重複を排除し、それぞれのディレクトリに対して`terraform init`を行っています。
`find . -name "*.tf" -exec dirname {} \; | sort -u | xargs -I {} sh -c 'cd "{}" && terraform init'`


```
.
├── modules
│   ├── function
│   │   ├── main.tf      # contains aws_iam_role, aws_lambda_function
│   │   ├── outputs.tf
│   │   └── variables.tf
|   |   └── test.tftest.hcl # 追加したテストファイル
│   ├── queue
│   │   ├── main.tf      # contains aws_sqs_queue
│   │   ├── outputs.tf
│   │   └── variables.tf
|   |   └── test.tftest.hcl # 追加したテストファイル
│   └── vpc
│       ├── main.tf      # contains aws_vpc, aws_subnet
│       ├── outputs.tf
│       └── variables.tf
|       └── test.tftest.hcl # 追加したテストファイル
├── main.tf 
├── outputs.tf
└── variables.tf
```

## terraform testは再帰的に実行するオプションがない
`terraform test`は再帰的に実行するオプションがないので、例のディレクトリ構造のような場合で`./`がカレントディレクトリの状態で実行すると、何のテストケースも実行されず結果が以下のようになります。

```terminal
Success! 0 passed, 0 failed.
```

そのため、このコマンドも以下のように拡張子が`.tftest.hcl`のファイルを探し出してきて再帰的に実行するようにすることをお勧めします

`find . -name "*.tftest.hcl" -exec dirname {} \; | sort -u | xargs -I {} sh -c 'cd "{}" && terraform test'`

# まとめ
今回は変数のバリデーションにのみ着目したようなテストでしたが、このように最低限でもテストケースを作成することでterraformの安全性を高めることができるので、触れてみてよかったと感じています。

`command = apply`にした場合は実際にリソースが作成されるという点については意識する必要があるので、テスト用の環境を作っておくなどするとより安全にテストが実施できると感じます。ありがとうございました！

# 参考文献

https://developer.hashicorp.com/terraform/language/tests

https://developer.hashicorp.com/terraform/tutorials/configuration-language/test

https://developer.hashicorp.com/terraform/language/tests/mocking