👻

実用的なTerraformのテストコードのサンプルを実装(ValidationとかTestとかMockの話) ~ 前編 ~

2024/03/15に公開

Terraformバージョン1.6からネイティブのテスト機能だけでTerraformのテストコードの実装が可能になり、さらに1.7ではテストコードのMockに関する機能が追加された。

なおこれらのテスト機能は正式にリリースされてからまだ日が浅いこともあり、公開されている具体的なテスト実装例が少ない状況であるため、どのようなテストコードを書けばいいのかについては現状自分で体系化するしかないが、個人的にテストの実装方法についてある程度は見えてきたので、その方法についてこの記事を含め三編に渡って説明していくことにする。

なおTerraform Testに関連する以下の公式ドキュメントやブログは全部読んだが、もしかするともっと良い実装方法があるかもしれない点や、Terraform Test自体が現在絶賛改良中の機能であるため、あっという間にこの記事の実装方法が陳腐化する可能性がある点については容赦いただきたい。

関連記事

前提知識

テストコードの説明を始める前に、まず自分が想定しているインフラ環境等について話しておく必要がある。

基本的には以下の関連記事で説明したものとほぼ同じではあるが、ここで改めて説明を行う。

https://zenn.dev/erueru_tech/articles/03dc3c753ba54e

https://zenn.dev/erueru_tech/articles/e24a1db7a39142

インフラストラクチャ

クラウドサービスはGCPを使用していて、infra-testing-google-sampleという架空のサービスを以下の4つのGCPプロジェクトで環境を分けて運用していると仮定している。

プロジェクト CIDR
infra-testing-google-sample-prod 10.1.0.0/16
infra-testing-google-sample-stg 10.2.0.0/16
infra-testing-google-sample-test 10.3.0.0/16
infra-testing-google-sample-sbx-e 10.4.0.0/16

prodやstgは一般的な環境名で、それぞれ本番環境とstaging環境をあらわしている。

次にtest環境だが、infra-testing-google-sampleではCI/CD時にインフラのテストコードを実行するための専用環境として使用する。

sandbox環境はインフラ開発者1人1人が持つ開発/検証用環境で、sbx-eはerueru-tech用の環境という意味になる。

利用プロダクト

テストコードの説明を行うために、上記各GCPプロジェクト内にVPCとCloud SQL(MySQL)のプロビジョニングを行うことになる。

VPCはnetworkモジュールで、Cloud SQL(MySQL)はdbモジュールでそれぞれ定義する。

(詳細は後述するサンプルプロジェクト作成を参照)

使用変数

Terraformのテストコード内では以下のようなプロジェクト名に関する変数を使用している。

特にserviceとenvはterraform testコマンド実行の際に必ず渡す特別な変数となっている。

変数 詳細
service サービス名をあらわす変数で、この記事ではinfra-testing-google-sample固定。
env 環境名をあらわす変数で、prodstgtestsbx-eのいずれかを指定。
project_id serviceとenvの値を${var.service}-${var.env}のフォーマットで結合した値。
つまりはプロジェクト名。
localsブロックに定義。

サンプルプロジェクト作成

まずテストコードを実装するにあたって、必要となるTerraformのサンプルプロジェクトを作成する必要がある。

なお以降で作成するサンプルコードをすべてまとめたリポジトリをこちらで公開しているので、もし動作確認しながら記事の内容を試すつもりである場合は、以下のコマンドでプロジェクトのチェックアウトを行なってほしい。

(GCPのプロジェクトの用意やサービスAPIの有効化については各自で行う必要がある)

$ cd /path/to/work-dir
$ git clone https://github.com/erueru-tech/terraform-testing-example-01.git
# もしくは
$ gh repo clone erueru-tech/terraform-testing-example-01

基本的には現在設計中のinfra-testing-google-sampleプロジェクトからコードを流用していて、フォルダ構成についてはGoogleのTerraformを使用するためのベストプラクティスを参考にしたものとなっている。

このフォルダ構成では実際に稼働する各環境(本番、staging等)のインフラリソースを定義するenvironmentsディレクトリと、Terraformモジュールを格納するmodulesディレクトリの2つで構成されるが、テストコードはmodulesディレクトリ内に定義されている各モジュールに対して実装を行う。

これはHashiCorp社のテストコード設計のコンセプトを踏襲したものであり、Terraform CloudのModule Tests Generationというテストコード自動生成機能においても、モジュール単位でテストコードを生成している。

なおenvironments側、たとえばtest環境用のHCLファイル群に対してテストコードを実行することも出来なくはないが、

  • 基本的にenvironments側にはモジュールの利用を定義するだけなので、同じ対象を二重でテストするような状態になる可能性が高い
  • テストを実行するために、サービスを構成する全リソース(モジュール)を生成する必要があり、テストの実行に非常に時間がかかる
  • 環境全体に対するテストはunitテストやintegrationテスト用の機能であるTerraform Testではなく、サービスが正しく稼働していることを確認するe2eテスト用の機能であるCheckなどを使った方が住み分け的にも良いと個人的には考えている

といった理由から、少なくとも現時点ではenvironments側でTerraform Testによるテストコードを実行するつもりはない。

前置きが長くなったが、プロジェクト作成は以下の手順で行う。

(基本的にコピペするだけで作成が完了する)

$ mkdir -p /tmp/sample-project/terraform/modules/network/tests \
  /tmp/sample-project/terraform/modules/db/tests
$ vi /tmp/sample-project/terraform/globals.tf
# 設定内容は下記

globals.tfはnetworkモジュールとdbモジュールで共通する定義をまとめている。

各モジュール内にこのファイルのシンボリックリンクを作成することで設定を参照出来るようになる。

/tmp/sample-project/terraform/globals.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "5.19.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "5.19.0"
    }
  }
  required_version = "1.7.4"
}

provider "google" {
  project = local.project_id
  region  = var.region
}

provider "google-beta" {
  project = local.project_id
  region  = var.region
}

locals {
  project_id = join("-", [var.service, var.env])
}

# サービス名(この記事ではinfra-testing-google-sampleで固定)
variable "service" {
  type    = string
  default = null
}

# prod(本番)、stg(staging)、test(CI専用)、sbx-e(個人開発/検証用)などのサービスの環境を表す変数
variable "env" {
  type    = string
  default = null
  validation {
    condition     = contains(["prod", "stg", "test"], var.env) || startswith(var.env, "sbx-")
    error_message = "The value of var.env must be 'prod', 'stg', 'test' or start with 'sbx-', but it is '${var.env}'."
  }
}

# 以下の定義ならlocalsでリテラルの値を定義したほうがいいが、将来的に許可リージョンを増やすことを想定
variable "region" {
  type    = string
  default = "asia-northeast1"
  validation {
    condition     = var.region == "asia-northeast1"
    error_message = "The var.region value must be 'asia-northeast1', but it is '${var.region}'."
  }
}

続いてnetworkモジュールを作成する。

ここでは、globals.tfのシンボリックリンクを作成して、さらにTerraformモジュールの基本構成要素となるmain.tf、variables.tf、outputs.tfを作成している。

他にもtestsディレクトリを作成しているが、この中にTerraform Testのテストコードを作成していくことになる。

(testsというディレクトリ名にすることでterraform testコマンド実行時にテストコードが自動的に認識されるようになる)

$ cd /tmp/sample-project/terraform/modules/network
# globals.tfの定義をモジュールに取り込み
$ ln -s ../../globals.tf .
$ vi main.tf
# 設定内容は下記
$ vi variables.tf
# 設定内容は下記
$ vi outputs.tf
# 設定内容は下記

$ cd /tmp/sample-project
$ tree -a
.
└── terraform
    ├── globals.tf
    └── modules
        ├── db
        │   └── tests
        └── network
            ├── tests
            ├── globals.tf -> ../../globals.tf
            ├── main.tf
            ├── outputs.tf
            └── variables.tf

7 directories, 5 files

main.tfには、Google公式から提供されているブループリントモジュールを使用してVPCを作成する定義を行なっている。

(ブループリントの詳細はこちら)

/tmp/sample-project/terraform/modules/network/main.tf
module "vpc" {
  source       = "terraform-google-modules/network/google"
  version      = "9.0.0"
  project_id   = local.project_id
  network_name = var.network_name
  subnets = [
    {
      subnet_name            = var.subnet_name
      subnet_ip              = var.subnet_ip
      subnet_region          = var.region
      subnet_private_access = "true"
    }
  ]
}

次にvariables.tfだが、ここではVPC名やサブネット名、サブネットのIPv4アドレス範囲といったプロジェクトやVPC内で一意に設定しなければいけない値を、VPC作成時に動的に変更出来るように変数で定義している。

/tmp/sample-project/terraform/modules/network/variables.tf
variable "network_name" {
  type    = string
  default = "sample-vpc"
}

variable "subnet_name" {
  type    = string
  default = "sample-subnet"
}

# cidrhost関数の第二引数に2を指定しているが、これはGCPの仕様で第4オクテットのホスト部の値が0と1のアドレスが予約されているため
# (別に0でも1でもバリデーションでの挙動は変わらないものの)
# https://cloud.google.com/vpc/docs/subnets?hl=ja#unusable-ip-addresses-in-every-subnet
variable "subnet_ip" {
  type    = string
  default = null
  validation {
    condition     = can(cidrhost(var.subnet_ip, 2))
    error_message = "The var.subnet_ip value must be given in CIDR notation."
  }
}

一応var.subnet_ipのバリデーション式condition = can(cidrhost(var.subnet_ip, 2))について触れておくと、まずcidrhost関数はCIDRとアドレス番号を渡すことでホストIPアドレスを生成してくれる関数になる。

terraform consoleコマンドなどでcidrhost関数を試すと以下のようになる。

$ terraform console
> cidrhost("10.4.101.0/24", 0)
"10.4.101.0"
> cidrhost("10.4.101.0/24", 1)
"10.4.101.1"
> cidrhost("10.4.101.0/24", 2)
"10.4.101.2"
> cidrhost("10.4.101.0/32", 2)
╷
│ Error: Error in function call
│
│   on <console-input> line 1:
│   (source code not available)
│
│ Call to function "cidrhost" failed: prefix of 32 does not accommodate a host
│ numbered 2.
╵

> Ctrl+D

次にcan関数だが、これは引数で渡された式がエラーを発生させずに値を返した場合trueが返り、エラーが発生した場合にはfalseが返る関数である。

つまりcan(cidrhost(var.subnet_ip, 2))はvar.subnet_ipの値がCIDR表記でなければバリデーションエラーを発生させるという意味になる。

最後にoutputs.tfだが、通常用途であれば他のリソースやモジュールが参照する値だけを定義すればいいのに対して、テストコードを実装する際にはapply後の状態をチェックしたい値(たとえばsubnets_private_accessなど)も定義する必要がある。

/tmp/sample-project/terraform/modules/network/outputs.tf
output "vpc_id" {
  value = module.vpc.network_id
}

output "vpc_name" {
  value = module.vpc.network_name
}

output "subnets_ids" {
  value = module.vpc.subnets_ids
}

output "subnets_ips" {
  value = module.vpc.subnets_ips
}

output "subnets_private_access" {
  value = module.vpc.subnets_private_access
}

以上で最低限テストコードを実装する準備が完了した。

それではnetworkモジュールに対して、Terraformのテストコードを実装していく。

networkモジュールのテストコード実装

アプリケーション、インフラ問わずテストコードは、そもそも何を書けばいいのか決めるという課題から始まる。

個人的にテストコードは、作法が分からないなどで悩むくらいなら、後々の保守が面倒にならない限りはまず自分が試したいと思ったことを自由に書く方針で始めた方がよいと思っている。

そうは言われても、取っ掛かりのアイディアを出すのは何であれ難しいものなので、その場合は簡単なものから始めていけば良い。

Terraformのテストコードの場合、variableブロックに型を定義するのも立派なテストコードの1つである。

(型定義はテストコードの領域ではなく、バリデーションチェックや静的チェックの領域であるといった分類上の話は今は置いておく)

型を指定することで、terraform planコマンド実行時に意図しない値が変数に渡された場合に検知することが出来るようになる。

さらに バリデーションを定義 して入力値の妥当性をチェックすることで、入力可能な値をより詳細に絞り込むことも出来る。

例として、globals.tfで定義されているenv変数はprodstgtest、あるいはsbx-から始まる値しか受け付けないというバリデーションを定義している。

/tmp/sample-project/terraform/globals.tf
...
variable "env" {
  type    = string
  default = null
  validation {
    condition     = contains(["prod", "stg", "test"], var.env) || startswith(var.env, "sbx-")
    error_message = "The value of var.env must be 'prod', 'stg', 'test' or start with 'sbx-', but it is '${var.env}'."
  }
}
...

このように変数の型やバリデーションを記述するだけでも、テスト関連のコードを一切書かないのに比べればコードは大分堅牢になる。

しかしこれだけではモジュールが意図通りのリソースを作成してくれるかまでは確認できないのも事実である。

そこでTerraformのテストコードの次段階として、以下のような基本的なテストを実装していくことになる。

  • 変数の仕様確認や入力値のブラックボックステスト(同値分割・限界値分析等)
  • モジュールに特定の入力値(variables)を渡してプロビジョニング(apply)を実行した場合に、意図した出力値(outputs)が返るかを確認するテスト

まずはnetworkモジュールの変数の仕様確認や入力値のテストを実装してみる。

variables.tftest.hcl

変数の仕様確認や入力値をテストするコードは以下のようになる。

(コードの詳細は後で1つずつ説明するので流し読みでも良い)

/tmp/sample-project/terraform/modules/network/tests/variables.tftest.hcl
$ vi /tmp/sample-project/terraform/modules/network/tests/variables.tftest.hcl
# var.network_nameのデフォルト値は'sample-vpc'である
run "assert_network_name_1" {
  command = plan
  variables {
    subnet_ip = "10.3.101.0/24"
  }
  assert {
    condition     = var.network_name == "sample-vpc"
    error_message = "The default var.network_name value must be 'sample-vpc'."
  }
}

# var.subnet_nameのデフォルト値は'sample-subnet'である
run "assert_subnet_name_1" {
  command = plan
  variables {
    subnet_ip = "10.3.101.0/24"
  }
  assert {
    condition     = var.subnet_name == "sample-subnet"
    error_message = "The default var.subnet_name value must be 'sample-subnet'."
  }
}

# var.subnet_ipは必ず値を指定しなければいけない
run "assert_subnet_ip_1" {
  command = plan
  expect_failures = [
    var.subnet_ip,
  ]
}

# var.subnet_ipはCIDR表記の値を渡す必要がある
run "assert_subnet_ip_2" {
  command = plan
  variables {
    subnet_ip = "10.3.101.2"
  }
  expect_failures = [
    var.subnet_ip,
  ]
}

# var.subnet_ipはCIDR範囲を渡す必要がある
run "assert_subnet_ip_3" {
  command = plan
  variables {
    subnet_ip = "10.3.101.2/32"
  }
  expect_failures = [
    var.subnet_ip,
  ]
}

一番最初のテストでは、変数network_nameのdefaultの値がsample-vpcであることをチェックするテストを行なっている。

test、sandbox環境ではenvironments側とmodules側のそれぞれでVPCを作成する可能性があるため、VPC名が衝突しないよう変数で設定できるようにしているが、実稼働環境(environments)では全環境共通でsample-vpcというVPC名を使用することから、デフォルト値として指定している。

つまり以下のデフォルト値がミスなどで意図せず変更された場合に、planコマンドやうっかりapplyコマンドを実行する前にこのテストによって気づけるようになる。

variable "network_name" {
  type    = string
  default = "sample-vpc"
}
# var.network_nameのデフォルト値は'sample-vpc'である
run "assert_network_name_1" {
  command = plan
  variables {
    subnet_ip = "10.3.101.0/24"
  }
  assert {
    condition     = var.network_name == "sample-vpc"
    error_message = "The default var.network_name value must be 'sample-vpc'."
  }
}

テストはrunブロック内にassertを1つ以上記述することで、terraform testコマンド実行時に実行される。

runブロックの名前はassert_network_name_1としているが、命名に迷うくらいならシンプルに変数名とテスト連番だけでいいとの考えからこのような名前にしている。

command = planはterraform planコマンドのように、GCP上に実際のリソースを作成せずに行うテストという意味になる。

デフォルトはcommand = applyで、テストを実行すると実際のリソースが作成されるため、変数の仕様確認だけを行うテストの場合は、時間短縮の観点からplanで実行することになる。

次にvariablesだが、terraform planコマンド実行時に変数の設定が不足しているとエラーが発生するのと同様に、テストでも必須の値を定義する必要がある。

globals.tfとvariables.tfで定義されている変数のうち、値の設定が必須の変数はservice、env、subnet_ipの3つになる。

(一方でregion、network_name、subnet_nameはdefaultが設定されているので設定不要)

serviceとenvについてはterraform testコマンド実行時に常にTF_VAR_serviceなどの環境変数で設定する方針で、テストコード内に記述は不要であることから、ここではsubnet_ipのみを設定している。

(serviceやenvを変数化しているのは、ブログやGithubでコードを公開している性質上、実際のプロジェクト名をリテラルで定義できないため)

なおtest環境のIPv4アドレス範囲である10.3.101.0/24を指定しているのは、実際のリソースを作らないテストなので実際のところ何でもよいものの、本番環境やstaging環境用のCIDRは万が一のリスクを考えると使用するべきではないのと、個人環境用のCIDRは誰のものを使うのか判断に迷うことから、このような指定になっている。

2番目の変数subnet_nameのテストも同様の読み方で読める。

3~5番目のテストは変数subnet_ipに対するテストだが、subnet_ipはdefault値がnullであることから入力必須となり、かつ変数の使用方法からCIDR表記文字列でなければいけないことを確認している。

以下は変数とテストの再掲となる。

variable "subnet_ip" {
  type    = string
  default = null
  validation {
    condition     = can(cidrhost(var.subnet_ip, 2))
    error_message = "The var.subnet_ip value must be given in CIDR notation."
  }
}
# var.subnet_ipは必ず値を指定しなければいけない
run "assert_subnet_ip_1" {
  command = plan
  expect_failures = [
    var.subnet_ip,
  ]
}

# var.subnet_ipはCIDR表記の値を渡す必要がある
run "assert_subnet_ip_2" {
  command = plan
  variables {
    subnet_ip = "10.3.101.2"
  }
  expect_failures = [
    var.subnet_ip,
  ]
}

# ほぼassert_subnet_ip_2と同じテスト
run "assert_subnet_ip_3" {
...

テストassert_subnet_ip_1ではvariablesでsubnet_ipの値を設定していないことからエラーとなるが、エラーが発生することを期待する場合はexpect_failuresにエラーが発生する変数を定義することでテストできる。

このテストによって、意図せずsubnet_ipにデフォルト値が設定されるような変更が行われた可能性を示唆してくれるようになる。

テストassert_subnet_ip_2とassert_subnet_ip_3では、CIDRではなく不正なアドレスが指定された場合にエラーが発生することをチェックしている。

variables.tftest.hclの説明は以上となる。

あとはterraform testコマンドを実行して、このテストコードを実行するだけになる。

なおterraform testコマンドの実行前に、必ずterraform initコマンドを実行する必要がある。

-filterオプションは特定のテストファイル内のテストだけを実行するのに使用するオプションで、渡すべき環境変数が他のテストファイルと異なる場合に使用することになる。

$ cd /tmp/sample-project/terraform/modules/network/
$ terraform init
# TF_VAR_env=sbx-eでも良い
$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=test \
  terraform test -filter=tests/variables.tftest.hcl

tests/variables.tftest.hcl... in progress
  run "assert_network_name_1"... pass
  run "assert_subnet_name_1"... pass
  run "assert_subnet_ip_1"... pass
  run "assert_subnet_ip_2"... pass
  run "assert_subnet_ip_3"... pass
tests/variables.tftest.hcl... tearing down
tests/variables.tftest.hcl... pass

Success! 5 passed, 0 failed.

以上で変数の仕様確認や入力値のブラックボックステストの基本的な書き方は理解できたかと思う。

次は実際にリソースをGCP上に作成して意図した設定でリソースが作成されていることを確認するテストを実装する。

main.tftest.hcl

早速だが、リソース作成を伴うテストコードは以下のようになる。

/tmp/sample-project/terraform/modules/network/tests/main.tftest.hcl
$ vi /tmp/sample-project/terraform/modules/network/tests/main.tftest.hcl
variables {
  network_name = "test-sample-vpc"
  subnet_name = "test-sample-subnet"
}

run "apply_vpc" {
  # var.network_nameで指定したVPC名を元にVPCのIDが生成されていることを確認
  assert {
    condition     = output.vpc_id  == "projects/${var.service}-${var.env}/global/networks/${var.network_name}"
    error_message = "The output.vpc_id value isn't expected. Please see the above values."
  }
  # 以下のアサーションは上記アサーションと実質同じで、${var.network_name}がtest-sample-vpcというリテラル文字列に置き換わっているだけとなっている(理由は後述)
  assert {
    condition     = output.vpc_id  == "projects/${var.service}-${var.env}/global/networks/test-sample-vpc"
    error_message = "The output.vpc_id value isn't expected. Please see the above values."
  }
  # var.network_nameで指定した名前でVPCが作成されていることを確認
  assert {
    condition     = output.vpc_name  == "test-sample-vpc"
    error_message = "The output.vpc_name value isn't expected. Please see the above values."
  }
  # var.subnet_nameで指定したサブネット名を元にサブネットのIDが生成されていることを確認
  assert {
    condition     = length(output.subnets_ids) == 1 && output.subnets_ids[0] == "projects/${var.service}-${var.env}/regions/${var.region}/subnetworks/test-sample-subnet"
    error_message = "The output.subnets_ids value isn't expected. Please see the above values."
  }
  # var.subnet_ipで指定したネットワークアドレスの値がサブネットのIP範囲になっていることを確認
  assert {
    condition     = length(output.subnets_ips) == 1 && output.subnets_ips[0] == var.subnet_ip
    error_message = "The output.subnets_ips value isn't expected. Please see the above values."
  }
  # VPCのsubnets_private_access(Global IPなしでGoogleのAPIに接続するための設定)が有効になっていることを確認
  assert {
    condition     = length(output.subnets_private_access) == 1 && output.subnets_private_access[0]
    error_message = "The subnets_private_access status is expected 'enabled', but it is 'disabled'."
  }
}

上から順に解説していくと、まずvariablesがグローバルで定義されている。

variables {
  network_name = "test-sample-vpc"
  subnet_name = "test-sample-subnet"
}

先ほどのvariables.tftest.hclではrunブロック内にvariablesを定義していたが、グローバルに定義することによってすべてのrunブロックに同じ変数を設定したのと同じような状態になる。

network_nameとsubnet_nameにはenvironments側で使用しているVPC名(sample-vpc)やサブネット名(sample-subnet)と重複しないようにtest-をプレフィックスとして付与している。

subnet_ipについてはテストが実行される環境がtest環境やsandbox環境のように複数考えられるため、ここで固定値を定義せず、terraform testコマンド実行時に環境変数TF_VAR_subnet_ipで値を渡すことを想定している。

ちなみにtfファイルでは同じディレクトリ内に定義されている情報は他のtfファイルと共有されるが、tftest.hclでは定義したファイルでのみ有効となるため、variables.tftest.hclはmain.tftest.hclで定義されているvariablesの影響を受けない。

あとグローバルvariables内では他の変数の値を${var.xxx}で参照できないのに対して、runブロック内のvariablesであればグローバルやTF_VAR_xxxで定義された変数を参照出来るといった違いがある。

次にrunブロック内を見ると以下のようなコードになっている。

run "apply_vpc" {
  # var.network_nameで指定したVPC名を元にVPCのIDが生成されていることを確認
  assert {
    condition     = output.vpc_id  == "projects/${var.service}-${var.env}/global/networks/${var.network_name}"
    error_message = "The output.vpc_id value isn't expected. Please see the above values."
  }
  # 以下のアサーションは上記アサーションと実質同じで、${var.network_name}がtest-sample-vpcというリテラル文字に置き換わっているだけとなっている(理由は後述)
  assert {
    condition     = output.vpc_id  == "projects/${var.service}-${var.env}/global/networks/test-sample-vpc"
    error_message = "The output.vpc_id value isn't expected. Please see the above values."
  }
...
}

variables.tftest.hclのテストとは異なり、commandの指定がないことからデフォルトのcommand = apply、つまりはmain.tfに定義されたモジュールやリソースが実際に作成される。

そしてoutputs.tfで定義している値に対してassertを記述することでテストを行なっている。

ちなみにassertを一切書かなくても、applyが正常終了してリソースが作成されることを確認するだけでも立派なテストである。

しかしassertを記述することで、その過程からさまざまな知見が得られ、考慮していなかった問題に気付くことが出来るという点からも書いておいた方がよい。

1番最初のassertではcondition = output.vpc_id == "projects/${var.service}-${var.env}/global/networks/${var.network_name}"という条件のテストを行なっているが、これはapply完了後に作成されたVPCのIDが変数network_nameで指定された値通りになっているかを確認している。

なおテスト可能な値は基本的にoutputs.tfで宣言されたものだけとなり、第三者が開発したmoduleを使用してリソースを作成する設定となっている場合、そのmoduleがoutputsで定義している値だけしかテストできないというリスクがある点は自作モジュール設計時に考慮する必要がある。

2番のassertではcondition = output.vpc_id == "projects/${var.service}-${var.env}/global/networks/test-sample-vpc"とVPC名(test-sample-vpc)をリテラルで記述している以外は同じテストを行なっている。

これは、"前回apply時点と異なる変数network_nameの値を使用した場合"にエラーとするか否かの違いがある。

つまりリテラルで書いた方が、より厳密なリグレッションテストとなる。

(なお、terraform testコマンドはapply後に必ずdestroyが行われるため、"前回apply時点"という状態は基本的に存在しないはずなのでそこまで考えなくてもいいかもしれない)

ちなみに変数network_nameで指定した値を元にVPC IDが作成されるのは当たり前で、別にテストを書かなくてもいいのではと思うかもしれないが、長期に渡って運用を行うにあたって、Terraform自身のバージョンアップやリファクタリング作業を行なった場合に、この当たり前が崩れる可能性がある。

個人的に最近遭遇した事例として、Javaのjsonシリアライズライブラリであるjacksonのバージョンアップを行なった際に、日付の値がうまく変換されない上、エラーも起きずにただnullになるという問題に遭遇した。

もし仮にこの問題に気づかずにシリアライズしたjsonをDBに書き込んだ場合、データ消失の問題が発生したことになるが、幸い単純なリグレッションテストを書いてあったことで気づくことができた。

他にもリファクタリング作業の際にまったく意図しなかった箇所で問題を検知出来たりするのは、大抵上記のような地味なテストのおかげだったりすることがほとんどである。

以上で実際にリソースを作成するテストコードの説明は完了となる。

最後にテストコードは以下のコマンドで実行する。

なおapplyが行われることから、変数envとsubnet_ipには個人開発環境の値を指定する必要がある。

# terraform initはすでに実行済みの想定
$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_subnet_ip=10.4.101.0/24 \
  terraform test -filter=tests/main.tftest.hcl

tests/main.tftest.hcl... in progress
  run "apply_vpc"... pass
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... pass

Success! 1 passed, 0 failed.

ブループリントのvpcモジュールを使ってVPCを作成した場合、約1分程度実行に時間がかかる。

その間terraform applyコマンドとは異なり、進捗について何も表示してくれない(将来的には見直されそう)。

なおapplyを行なっているにもかかわらず、stateファイルがカレントディレクトリに作成されないが、terraform test実行時のstate情報はメモリ上で管理されていて、testコマンドが正常/異常問わず終了すると自動的にdestroyが実行され、メモリ上に存在していたstateは跡形もなく消える。

もしdestroyに途中で失敗した場合は、削除し損ねたリソースの一覧が標準エラーに出力されるので、GCPコンソール上からそれらリソースを手動で削除する必要がある。

おわり

以上がTerraformのテストコードの実装方法の一例となる。

ちなみにTerraform CloudのPlus Edition以上を使える人は、Module Tests Generationの機能でどんなテストが実装されるのかを参考にした方がいいと思う。

次回の記事では引き続きdbモジュールに対してテストコードを実装していく。

なおタイトルでTerraform Mockと書かれているにも関わらずこの記事では一切言及していなが、これについては次回で触れる。

この記事をリアルタイムで見ていて、今すぐMockの情報が欲しい人向けに一応情報を先出しすると、現時点でMockは使用する必要がないと考えている。

基本的には上記のテストだけをモジュールに行うだけでも十分なテストであり、更に欲張って余計なことをしようとしても、大抵Mockの機能不足や自分の設定と噛み合わないなどが原因で徒労に終わる。

(次の記事を書くまでに劇的な機能進化や、ひらめきがあれば意見が変わるかもしれない)

追記

続き。

https://zenn.dev/erueru_tech/articles/577b0bbd4f65b0

Discussion