💡

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

2024/03/22に公開

前回の記事では、GCP上にVPCリソースを作成するTerraformモジュールのテストコードを実装する方法について説明した。

今回はCloud SQL(MySQL)の作成を行うdbモジュールのテストコードを実装していく。

なお自分がdbモジュールのテストコードの実装を行なった際に以下のような課題が出てきた。

  • dbモジュールはVPCに対して依存関係が存在するため依存解決が必要
  • Cloud SQLのインスタンス構築には約20分近くかかり、テスト1回の実行に相当な時間がかかる
  • google-betaプロバイダの特定バージョンでVPCネットワークピアリングがdestroy不可能となる問題が発生

これらの問題をどう扱うのかについて、テストコードの実装と共に説明していく。

そしてテストコードの依存関係の解決といえばMockの出番であるため、Terraform Mockを利用することでテストコードの実装を改善できないかについても検証していく。

前提知識

基本的に前編の記事に書いた前提知識を今回もそのまま踏襲することになるが、1点だけ追加で説明しておかなければいけない点がある。

前回の説明ではTerraformモジュール群を配置するmodulesディレクトリだけを使用して、environmentsディレクトリについては触れなかった。

しかし今回の記事ではテストを実装するにあたって、このenvironmentsディレクトリを使用する必要があり、更に自分のプロジェクトの微妙にクセのあるstate管理について伝えておく必要がある。

なお、基本的には以下の記事で説明した内容の再掲となる。

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

以下はinfra-testing-google-sampleプロジェクトのenvironmentsディレクトリの構造を説明のためにかなり簡略化したものになる。

$ cd /path/to/infra-testing-google-sample
$ tree
.
└── terraform
    ├── globals.tf
    ├── tier1.tf
    ├── tier2.tf
    ├── environments
    │   ├── sbx
    │   │   ├── tier1
    │   │   │   ├── globals.tf -> ../../../globals.tf
    │   │   │   ├── tier1.tf -> ../../../tier1.tf
    │   │   │   └── sbx-tier1.auto.tfvars
    │   │   └── tier2
    │   │       ├── globals.tf -> ../../../globals.tf
    │   │       ├── tier2.tf -> ../../../tier2.tf
    │   │       ├── data.tf
    │   │       └── sbx-tier2.auto.tfvars
    │   ├── test
    │   │   ├── tier1
    │   │   │   ├── globals.tf -> ../../../globals.tf
    │   │   │   ├── tier1.tf -> ../../../tier1.tf
    │   │   │   └── test-tier1.auto.tfvars
    │   │   └── tier2
    │   │       ├── globals.tf -> ../../../globals.tf
    │   │       ├── tier2.tf -> ../../../tier2.tf
    │   │       ├── data.tf
    │   │       └── test-tier2.auto.tfvars
    │   ├── stg
    │   │   ├── ...
    │   └── prod
    │       ├── ...
    ├── modules
    │   ├── db
    │   │   ├── ...
    │   └── network
    │       ├── ...
...

environments配下のsbx、test、stg、prodといったディレクトリは各環境(=GCPプロジェクト)ごとにstateを分離する働きを持つが、更にそれらのディレクトリ内でtier1とtier2に分割されている。

tier1とtier2のどちらも、サービスを構築するのに必要なリソースをHCLファイルに定義するという点は同じだが、分け方としてはテストコード実行時に依存関係の解決に必要なリソース(VPCなど)かどうかで判断する。

tier1にはGCPサービスAPIの有効化設定やVPCなどのネットワークリソースといった、テストコード実行時に常にあらかじめ存在してほしいリソースを定義して、一方tier2にはその他残りのサービス運用に必要なリソースを定義する。

(上記説明はサービスとテストが交錯していて少々混乱するかもしれないが、その場合はいったんここは流して先に進めば後々意図が分かるかと思う)

そしてtier1とtier2の設定内容は、それぞれtier1.tfとtier2.tfに定義して、全環境のディレクトリ内でシンボリックリンクを張ることでDRYに記述する。

全環境で同じtfファイルを使用するこの方法は、環境間で異なる値(たとえばVPCのCIDR)を設定できるようにする必要があるが、そのような値は変数化してtierディレクトリ内にあるsbx-tier1.auto.tfvarsファイルなどに以下のように定義する。

# sbx-e環境のtier1に定義されているモジュール/リソースに渡す変数の値一覧
subnet_ip = "10.4.1.0/24"

ちなみにtier2ディレクトリ内だけにdata.tfがあるが、これはtier1のstateで管理されているoutputの値(VPC ID等)を参照するための設定を記述するファイルで、今回は使用しないので説明は割愛する。

以上がenvironmentsディレクトリの構造とstate管理の説明となるが、今回この情報が必要となる理由としては、dbモジュールのテストコードを実行すると生成されるCloud SQL(MySQL)インスタンスをtier1内で定義するVPC上に作成するためとなる。

この辺りの事情については後で説明する。

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

まずテストコードの説明を始める前に、前回作成したサンプルプロジェクトにdbモジュール用のリソース定義を追加する必要がある。

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

(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

dbモジュールは以下のコマンドと内容で作成する。

構成要素はnetworkモジュールとまったく同じである。

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

まずmain.tfだが、内容をすべて記載しないと説明が難しいために省略せずに書いているが、以下のようにかなり長い定義となってしまっている。

しかしこの記事の本題であるテストコードの解説を理解するにあたって、具体的な設定内容を知っている必要はまったくないので、以下の設定はCloud SQLインスタンスをVPC内に作成する定義がまとめられているとだけ認識しておけば、基本的に流し読みで問題ない。

(それでも設定の詳細に興味があるなら、main.tf内のコメントに記載されている各URLのページを参考にしてほしい)

ただしgoogle_service_networking_connectionリソースのdeletion_policy = "ABANDON"という設定は後々話題に上がるということだけは認識しておいてほしい。

/tmp/sample-project/terraform/modules/db/main.tf
# 長い設定だが、Cloud SQL(MySQL)インスタンスを作成しているだけと捉える
# 設定の詳細について知りたい場合は、コメントを記載した下記URLのコードを参照
# https://github.com/erueru-tech/infra-testing-google-sample/blob/0.1.1/terraform/modules/db/main.tf
# ここでもGoogleが提供するブループリントであるsql-dbを使用してCloud SQLインスタンスを構築している
# sql-dbブループリントの仕様は以下README.mdを参照
# https://github.com/terraform-google-modules/terraform-google-sql-db/tree/master/modules/mysql
module "sql_db" {
  source               = "GoogleCloudPlatform/sql-db/google//modules/mysql"
  version              = "19.0.0"
  project_id           = local.project_id
  region               = var.region
  zone                 = var.zone
  database_version     = "MYSQL_8_0_36"
  db_name              = var.db_name
  db_charset           = "utf8mb4"
  db_collation         = "utf8mb4_bin"
  availability_type    = var.availability_type
  name                 = var.db_instance_name
  random_instance_name = var.random_instance_name
  tier                 = var.tier
  user_name            = "sample-mysql-user"
  ip_configuration = {
    ipv4_enabled    = false
    private_network = var.vpc_id
  }
  backup_configuration = {
    binary_log_enabled = true
    enabled            = true
    start_time         = "21:00"
  }
  database_flags = [
    {
      name  = "slow_query_log"
      value = "on"
    },
    {
      name  = "long_query_time"
      value = "2"
    }
  ]
  deletion_protection         = var.deletion_protection
  deletion_protection_enabled = var.deletion_protection
  create_timeout              = "60m"
  module_depends_on           = [google_service_networking_connection.cloudsql_network_connection]
}

# 下記リソース群の定義はVPC内にCloud SQL(MySQL)インスタンスを作成するのに必要な設定
# 以下のURLのドキュメントを参考に定義
# https://cloud.google.com/sql/docs/mysql/samples/cloud-sql-mysql-instance-private-ip?hl=ja
# https://cloud.google.com/vpc/docs/configure-private-services-access?hl=ja
resource "google_compute_global_address" "cloudsql_ip_range" {
  name          = "sample-cloudsql-ip-range"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  address       = var.cloudsql_network_address
  prefix_length = 24
  network       = var.vpc_id
}

# destroy時に必ず削除に失敗するリソース
# https://github.com/hashicorp/terraform-provider-google/issues/16275
resource "google_service_networking_connection" "cloudsql_network_connection" {
  network                 = var.vpc_id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.cloudsql_ip_range.name]
  # エラー再現のために意図的にコメントアウトしているが、Terraform Testでテストコードを実行する上で必須となる設定(詳細は後述)
  # deletion_policy         = "ABANDON"
}

resource "google_compute_network_peering_routes_config" "cloudsql_peering_routes" {
  peering              = google_service_networking_connection.cloudsql_network_connection.peering
  network              = var.vpc_name
  import_custom_routes = true
  export_custom_routes = true
}

variables.tfについても基本流し読みで良く、定義されている変数は全てCloud SQLインスタンス作成に必要なもの程度で捉えて問題ない。

後々、applyコマンドやtestコマンドで必須の変数をTF_VARやtftest.hcl内のvariablesで指定する際に、serviceやenvの他にvpc_id, vpc_name, cloudsql_network_addressを指定する必要があることを簡単に覚えておくだけで良い。

なおvariables.tfの一番最後に宣言している変数cloudsql_network_addressでは、入力値がIPアドレスかを判定する正規表現のバリデーションが書いてあるので、今後必要になるケースがありそうなら確認してみると良い。

ちなみにIPアドレスかどうかを判定する関数をTerraformが用意していないため、冗長な正規表現でのチェックになっている。

/tmp/sample-project/terraform/modules/db/variables.tf
# Cloud SQLインスタンスが配置されるVPCのID
variable "vpc_id" {
  type    = string
  default = null
  validation {
    condition     = var.vpc_id != null
    error_message = "The var.vpc_id value is required."
  }
}

# Cloud SQLインスタンスが配置されるVPCの名前
variable "vpc_name" {
  type    = string
  default = null
  validation {
    condition     = var.vpc_name != null
    error_message = "The var.vpc_name value is required."
  }
}

# Cloud SQLインスタンスが配置されるZone
# Terraform Mockでテストコードを実装するためだけに宣言が必要になった変数
variable "zone" {
  type    = string
  default = "asia-northeast1-a"
}

# Cloud SQLインスタンス名
# 下記変数random_instance_nameをtrueにすることで、サフィックスにランダムな文字列が付与される
variable "db_instance_name" {
  type    = string
  default = "sample-instance"
}

# prod、stgといった実動環境では固定の名前でインスタンスを構築して問題ないのでfalseを設定
# testおよびsbx環境では、テスト時に短期間に同じ名前でCloud SQLインスタンスを再作成できない仕様を回避するためにtrueを設定(インスタンス名がランダマイズされる)
variable "random_instance_name" {
  type    = bool
  default = true
}

# Cloud SQLインスタンス内に構築されるデータベース(MySQLではスキーマ)名
variable "db_name" {
  type    = string
  default = "sample-db"
}

# テストの実行がスペックに依存しない場合はdb-f1-microにして、prod、stgのような実稼働環境でインスタンスを作成する場合は要件にあったtierを選択できるように変数化
variable "tier" {
  type     = string
  default  = "db-f1-micro"
  nullable = false
  validation {
    condition     = contains(["db-f1-micro", "db-n1-standard-1"], var.tier)
    error_message = "The var.tier value must be either 'db-f1-micro' or 'db-n1-standard-1', but it is '${var.tier}'."
  }
}

# prod環境や可用性のテストを行いたい場合以外では、コスト面の理由から高可用性を無効にしたいケースがあるため変数化
# 通常availability_typeは'ZONAL'もしくは'REGIONAL'を設定するが、sql-dbモジュールでは'ZONAL'を指定したい場合、代わりにnullを指定する
variable "availability_type" {
  type    = string
  default = null
  validation {
    condition     = var.availability_type == null || var.availability_type == "REGIONAL"
    error_message = "The var.availability_type value must be either null or 'REGIONAL', but it is '${var.availability_type}'."
  }
}

# ブループリントのsql-dbモジュールの削除保護設定のデフォルト値が有効となっているために、テストでdestroyできない問題を解決するために変数化
variable "deletion_protection" {
  type    = bool
  default = false
}

# Cloud SQLインスタンスに付与されるIPアドレスの範囲はcloudsql_network_addressで指定(サブネットマスクは24で固定)
# そもそも何故CIDR表記で設定出来るようにしないのかだが、これはブループリントモジュール(sql-db)がCIDR表記ではなくネットワークアドレスとサブネットマスクに分けて渡すことを要求しているため
variable "cloudsql_network_address" {
  type        = string
  default     = null
  description = "This variable can accept a network address, e.g. '10.3.1.0'."
  validation {
    # 10.x.x.0のフォーマットに従っているかチェック
    condition     = can(regex("^10\\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-4])\\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-4])\\.0$", var.cloudsql_network_address))
    error_message = "The var.cloudsql_network_address value must be network address."
  }
}

outputs.tfではMySQLサーバの接続に必要な接続名や接続ユーザのパスワードなどを出力している。

他にもリグレッションテストを行いたい値があれば、出力値をこのファイルに追記していくことになる。

/tmp/sample-project/terraform/modules/db/outputs.tf
output "mysql_main_user_password" {
  value     = module.sql_db.generated_user_password
  sensitive = true
}

output "mysql_main_connection_name" {
  value = module.sql_db.instance_connection_name
}

output "mysql_main_private_ip_address" {
  value = module.sql_db.private_ip_address
}

output "mysql_main_public_ip_address" {
  value = module.sql_db.public_ip_address
}

以上でdbモジュールの作成は完了となる。

なお、前回のnetworkモジュールではそのままテストコードの実装を開始できたのに対して、今回テスト対象となるdbモジュールはVPCに対して依存関係を持つため、テストコードを実装する前にこの点をどう解決するのか整理する必要がある。

まず最初に思いついたのが、networkモジュールのテストコードを実行してVPCを作成した後に、そのVPCをdbモジュールのテストにも使うといった案だったが、よく考えるとTerraform Testではテスト実行が完了したら有無を言わさずdestroyが実行されてVPCが消えるので即却下となった。

次にnetworkモジュール内でtestコマンドではなくapplyコマンドを実行することでVPCを作成して、dbモジュールはそのVPCを使ってテストを実行し、完了したらdestroyコマンドを発行してクリーンアップする方法はどうかといった案を考えた。

しかしこの方法は何百、何千回と実行されるテストの度にVPCを作成することになり、1分程度とはいえテスト実行時間が長くなる点や、テストが更にflakyになる点、dbモジュールのテストコードが失敗してエラーとなった場合のnetworkモジュール側のdestroyのハンドリングが面倒といった問題が考えられるため、これもあまり採用したくない案となる。

何よりVPCはdbモジュールに限らず、あらゆるモジュールが依存するリソースであるため、常にtest環境やsandbox環境に存在していた方が上記問題を考えなくて済む。

このような過程を経て生まれたのが、前提知識で紹介したtierによるstate管理方法となる。

つまりdbモジュールのテストは、test環境や個人のsandbox環境で常時存在しているVPCを使用してテストを行う

以下はdbモジュールのテストコードの実行に必要なenvironments配下の設定の作成手順となる。

なお今回のテストで必要なのはtier1の設定のみであるため、tier2の設定の作成は行なわない。

$ mkdir -p /tmp/sample-project/terraform/environments/sbx/tier1

$ vi /tmp/sample-project/terraform/tier1.tf
# 設定内容は下記

$ cd /tmp/sample-project/terraform/environments/sbx/tier1
$ ln -s ../../../globals.tf .
$ ln -s ../../../tier1.tf .
$ vi sbx-tier1.auto.tfvars
# 設定内容は下記

tier1.tfではTerraformのstate管理をGCS上で行うための設定や、TerraformがGCPサービスを操作するために必要なAPIの有効化設定、そしてVPCの作成を行う設定が定義されている。

/tmp/sample-project/terraform/tier1.tf
# terraform.tf
terraform {
  backend "gcs" {
    prefix  = "terraform/tier1-state"
  }
}

# tier1-variables.tf
variable "subnet_ip" {
  type    = string
  default = null
  validation {
    condition     = var.subnet_ip != null
    error_message = "The var.subnet_ip value is required."
  }
}

# tier1-main.tf
# TerraformがGCPの各種サービスのAPIに接続するために必要な設定
module "project_services" {
  source     = "terraform-google-modules/project-factory/google//modules/project_services"
  version    = "14.4.0"
  project_id = local.project_id
  activate_apis = [
    "bigquery.googleapis.com",
    "bigquerymigration.googleapis.com",
    "bigquerystorage.googleapis.com",
    "cloudapis.googleapis.com",
    "cloudbilling.googleapis.com",
    "cloudresourcemanager.googleapis.com",
    "cloudtrace.googleapis.com",
    "datastore.googleapis.com",
    "iam.googleapis.com",
    "iamcredentials.googleapis.com",
    "logging.googleapis.com",
    "monitoring.googleapis.com",
    "servicemanagement.googleapis.com",
    "serviceusage.googleapis.com",
    "sql-component.googleapis.com",
    "storage-api.googleapis.com",
    "storage-component.googleapis.com",
    "storage.googleapis.com",
    "servicenetworking.googleapis.com",
    "compute.googleapis.com"
  ]
  # destroy発行時に上記APIが全て無効化されないようにする設定
  disable_services_on_destroy = false
}

# VPCを作成
module "network" {
  source       = "../../../modules/network"
  service      = var.service
  env          = var.env
  subnet_ip    = var.subnet_ip
}

# tier1-outputs.tf
output "vpc_id" {
  value = module.network.vpc_id
}

output "vpc_name" {
  value = module.network.vpc_name
}

sbx-tier1.auto.tfvarsでは、先ほども説明したように環境固有のCIDRである"10.4.1.0/24"をplanやapplyコマンド実行時に自動的に渡すようにしている。

(自動的に変数が読み込まれるのは.auto.tfvarsファイルの性質によるもの)

/tmp/sample-project/terraform/environments/sbx/tier1/sbx-tier1.auto.tfvars
subnet_ip = "10.4.1.0/24"

これですべての準備完了となる。

あとは以下のコマンドでsandbox環境上にVPCを作成すれば、テストコードの実装を開始できる。

$ cd /tmp/sample-project/terraform/environments/sbx/tier1/

# terraform init実行の際には、-backend-configオプションを使用して動的にstate管理用GCSバケット名を指定
# これはterraformブロック内では変数が使えないのと、ブログやGithubでコードを公開している性質上、プロジェクト名をファイル内にリテラルで記述できないため
# GCSバケット名は'$PROJECT_ID-terraform'という命名ルールとなっている
$ terraform init -backend-config="bucket=infra-testing-google-sample-sbx-e-terraform"

$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_subnet_ip=10.4.101.0/24 \
  terraform plan

$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_subnet_ip=10.4.101.0/24 \
  terraform apply # -auto-approve

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

あまりに前置きが長くなってしまったが、ここからは本題のテストコードの実装を行なっていく。

入力値の仕様確認やブラックボックステストを行うvariables.tftest.hclの書き方については、networkモジュールの時と特に変わり映えはないためここでは説明を省略して、早速本題のmain.tftest.hclについて説明していく。

/tmp/sample-project/terraform/modules/db/tests/main.tftest.hcl
$ vi /tmp/sample-project/terraform/modules/db/tests/main.tftest.hcl
variables {
  # 以下のassertでは使用されないが、variables.tfで必須として宣言されている変数
  # 先ほどsandbox環境に作成したVPCの名前を指定
  vpc_name = "sample-vpc"
}

run "apply_db" {
  variables {
    # 以下のassertでは使用されないが、variables.tfで必須として宣言されている変数
    # 上のvariablesで宣言しないのは${var.vpc_name}のように変数の参照をトップレベルで宣言するとエラーになるため
    vpc_id = "projects/${var.service}-${var.env}/global/networks/${var.vpc_name}"
  }
  # Cloud SQLインスタンスの接続エンドポイント名が意図する値であることを確認
  assert {
    condition     = can(regex("^${var.service}-${var.env}:asia-northeast1:sample-instance-[a-z0-9]{8}$", output.mysql_main_connection_name))
    error_message = "The output.mysql_main_connection_name value isn't expected. Please see the above values."
  }
  # Cloud SQLインスタンスが配置されたサブネットのネットワークアドレスが意図する範囲であることを確認
  assert {
    condition     = can(regex("^10.4.102.([2-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-3])$", output.mysql_main_private_ip_address))
    error_message = "The output.mysql_main_private_ip_address value isn't expected. Please see the above value."
  }
  # Cloud SQLインスタンスにPublic IPが割り当てられていないことを確認
  assert {
    condition     = output.mysql_main_public_ip_address == ""
    error_message = "Cloud SQL instance can be accessed from public network."
  }
  # MySQL接続用ユーザのパスワードが32文字であることを確認
  assert {
    condition     = length(nonsensitive(output.mysql_main_user_password)) == 32
    error_message = "The length of the output.mysql_main_user_password must be 32."
  }
}

基本的にはoutputs.tfで定義された出力値の値を、等式や正規表現を使って意図した値になるかチェックしているだけとなる。

1点だけ注目するとすれば、最後のassertでsensitiveな値を復号してその内容をチェックしている点くらいである。

なお、冒頭にも書いたようにCloud SQLインスタンスの作成は実行1回につき20分近くを要する。

Cloud SQLの起動を課金で高速化出来ればいいのだが、残念ながらそのようなサービスはない。

つまりassert文を1つ1つ追加しながらtestコマンドを実行して、トライ&エラーを繰り返すといったテスト開発は時間的に無理であり、もしtypoのような単純なミスでテストが失敗しようものなら思わず変な声が出る。

この問題に関しては、dbモジュールに対してterraform testコマンドを実行するのではなく、まずterraform applyコマンドを実行して、すべてのoutputs値をカンニングした状態でassertを実装する方法で対処することにした。

コマンドとしては以下のようになる。

$ cd /tmp/sample-project/terraform/modules/db
$ terraform init

# globals.tfとvariables.tfで定義されている必須の変数の値を指定する必要がある
# TF_VAR_cloudsql_network_addressはCloud SQLインスタンスを構築する先のネットワークアドレス
$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_vpc_name=sample-vpc \
  TF_VAR_vpc_id=projects/infra-testing-google-sample-sbx-e/global/networks/sample-vpc \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform plan

$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_vpc_name=sample-vpc \
  TF_VAR_vpc_id=projects/infra-testing-google-sample-sbx-e/global/networks/sample-vpc \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform apply # -auto-approve

...

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

mysql_main_connection_name = "infra-testing-google-sample-sbx-e:asia-northeast1:sample-instance-624646be"
mysql_main_private_ip_address = "10.4.102.3"
mysql_main_public_ip_address = ""
mysql_main_user_password = <sensitive>

applyが成功すると標準出力にoutputs値が表示されるが、sensitiveな値までは表示されない。

なお、terraform init時にbackendが指定されていない場合、dbモジュールディレクトリ内にterraform.tfstateファイルが作成されるため、その中身を見ればoutputsの値がsensitiveな値も含めてすべて確認できる。

$ view terraform.tfstate

以上の方法でoutputsの値を全て把握した状態で一気にassert文を書いて、出来れば一発でパスさせるようにすれば最短でapply1回、test1回の約40分でテストの実装を終わらせることが出来る。

なおこのカンニング的なやり方は、テストを試行錯誤する間にさまざまな問題点に気づけるチャンスが無くなる方法なので、assert文を書く際にはいつも以上に違和感に対して敏感になる必要がある。

ここで一つ余談だが、インフラのテストコードの実行時間は可能な限り短縮する工夫が必要になるが、特に1つのモジュール内に作成に時間のかかるリソースを複数同居させてはいけない点を常に意識しておく必要がある。

例えばCloud SQLは社内向け管理システム用、BigtableやAloyDB等はユーザサービス用といったように1つのサービス内で複数のDBを持つことは普通にあるが、dbモジュール内にすべてのDBリソースを定義すると、このdbモジュールが常にテスト実行時間の最大のボトルネックとなり、テストの並列実行等といった努力をしても全く時間短縮できなくなるため、モジュールの単位を設計する際にはリソース生成にかかる合計時間を常に意識する必要がある。

以上でテストに必要なoutputsの情報が得られたので、一度環境をクリーンな状態に戻すためにdestroyを実行して、あとはtestコマンドでテストコードを実行するだけーー

で本来は良いはずなのだが、何回実行しても以下のエラーが発生してdestroyが失敗するといった問題が発生する。

$ TF_VAR_service=infra-testing-google-sample \
  TF_VAR_env=sbx-e \
  TF_VAR_vpc_name=sample-vpc \
  TF_VAR_vpc_id=projects/infra-testing-google-sample-sbx-e/global/networks/sample-vpc \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform destroy

...
│ Error: Unable to remove Service Networking Connection, err: Error waiting for Delete Service Networking Connection: Error code 9, message: Failed to delete connection; Producer services (e.g. CloudSQL, Cloud Memstore, etc.) are still using this connection.

このエラーはこちらのISSUEを確認する限り、Cloud SQLインスタンスが削除された後にもかかわらず、VPCネットワークピアリングをCloud SQLがまだ使用していると誤認識して削除できないといったバグが原因らしい。

ワークアラウンドとして、まず先に以下のコマンドを実行して、VPCネットワークピアリングを手動削除してからdestroyを実行すると正常終了するようになる。

$ gcloud compute networks peerings delete \
  servicenetworking-googleapis-com \
  --network sample-vpc \
  --project infra-testing-google-sample-sbx-e

しかし、apply -> assert -> destroyの一連の処理を中断なく実行するterraform testコマンドでは、上記コマンドをassertとdeproyの間に割り込ませて実行できないために、テストが常に失敗することになる。

この問題の本質的な解決方法として、google-betaプロバイダのバージョン5.12以降はdeletion_policy = "ABANDON"という設定を記述することで、destroyを実行するとstateからは削除されるが実際のVPCネットワークピアリングは削除しないといった挙動に変更することができるようになったので、現時点での最新バージョンである5.19にバージョンアップして上記設定を追加することで、テストを正常終了させることができるようになる。

なおテスト実行後にVPCネットワークピアリングを上記コマンドで手動削除しないといけないのは結局変わらないので、dbモジュールのテストコード実行をシェルなどで自動化する際には、terraform testコマンド実行後にgcloud compute networks peerings deleteコマンドを必ず実行するようにしなければいけない。

しかし、この問題はTerraform Testの運用にとって割と重大なリスクとなる気がしていて、プロバイダをバージョンアップした途端、上記のような問題が発生してテストできなくなる、そしてそれが長期間に渡るといった事態は現実に発生しうると考えておかなければいけない。

最後にdbモジュールのtest実行コマンドは以下のようになる。

(実行すると約20分間、ログも表示されずにただ待つことになる)

$ TF_VAR_service=infra-testing-google-example \
  TF_VAR_env=sbx-e \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform test -filter=tests/main.tftest.hcl

Terraform Mock

Terraformのテストコードの体系化の最後のお題として、ここからはTerraform Mockを使用して、テストコード開発の効率化やテスト品質向上が可能かどうか、dbモジュールを通して行った検証の記録を紹介したい。

なお前回の記事のおわりでMockは不要と書いたが、結果として何をやってもうまくいかないという失敗談のようなものであると先に断っておきたい。

(とはいえこの検証の過程から得られた情報のおかげで、テストの実装方法の方向性が明確化出来たのも事実である)

あとドキュメントが乏しいこともあり、Terraform Mockの本来の利用方法を自分が曲解している可能性が十分に高いためその点は容赦いただきたい。

まず初めにTerraform Mockの機能について簡単に紹介する。

以下は現時点(バージョン1.7.4)でテストコードのモックで使用可能なブロックの一覧となる。

ブロック 機能
mock_resource クラウド上にリソースの作成を行わないが、テスト用の属性値(VPCのIDやAWSのリソースのarnなど)を返すresourceを定義するためのブロック。
mock_data mock_resourceのdata source版。
override_resource main.tfなどに宣言済みのresourceの属性値をテスト用の値に上書きするためのブロック。
上書きされたresourceはクラウド上にプロビジョニングされない。
override_data override_resourceのdata source版。
override_module main.tfなどに宣言済みのmoduleのoutputsの値をテスト用のものに上書きするためのブロック。
上書きされたmoduleはクラウド上にプロビジョニングされない。

mock_dataoverride_dataについてはdbモジュールではdata sourceを定義していないのと、日本語、英語、他全言語で情報がない上にTerraform自体のソースコードをGithubで検索しても現時点で有益な情報がヒットしないためここでは触れないことにする。

それでは手始めにmock_resourceの利用方法について考えてみる。

dbモジュール内でモックを活用する方法としてまず真っ先に思いつくのが、Cloud SQLインスタンスの構築を行っているモジュールをモックすることでインスタンス起動時間をゼロにして、VPCネットワークピアリングなどのリソースのテストだけを短いサイクルで反復実行できないか、といったアイディアである。

dbモジュールのmain.tfを以下に再掲するが、sql_dbモジュールをモックして、その他のVPC関連の3つのリソースだけを部分的にテスト実行できないかという話になる。

/tmp/sample-project/terraform/modules/db/main.tf
module "sql_db" {
  source = "GoogleCloudPlatform/sql-db/google//modules/mysql"
...
}

resource "google_compute_global_address" "cloudsql_ip_range" {
...
}

resource "google_service_networking_connection" "cloudsql_network_connection" {
...
}

resource "google_compute_network_peering_routes_config" "cloudsql_peering_routes" {
...
}

しかしsql_dbはモジュールであるため、mock_resourceではモックできない。

それでも諦めずにsql_dbモジュールが使用しているブループリントモジュール内のソースコードを追うと、google-betaプロバイダで、google_sql_database_instanceリソースを作成していることが分かる。

明らかに使い方を間違っているのは承知だが、実験も兼ねてmain.tftest.hclに以下のような試し打ちのモックコードを追加して、アイディアを実現できないか確認してみる。

/tmp/sample-project/terraform/modules/db/tests/main.tftest.hcl
# Mockを使用する場合、mock_providerを定義する必要がある
# ブロック内にmock_resourceやmock_dataを記述していく
mock_provider "google-beta" {
  alias = "fake"
  mock_resource "google_sql_database_instance" {
    defaults = {
      name = "mock-instance"
    }
  }
}

variables {
  vpc_name = "sample-vpc"
}

run "apply_db" {
  # テストコード内でモックを使用するための定義
  providers = {
    google-beta = google-beta.fake
  }
  variables {
    vpc_id = "projects/${var.service}-${var.env}/global/networks/${var.vpc_name}"
  }
  assert {
...
}

このテストコードを実行した結果は以下となる。

$ TF_VAR_service=infra-testing-google-example \
  TF_VAR_env=sbx-e \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform test -filter=tests/main.tftest.hcl

tests/main.tftest.hcl... in progress
  run "apply_db"... fail
╷
│ Error: Provider produced inconsistent result after apply
│
│ When applying changes to module.sql_db.google_sql_user.default[0], provider
│ "provider[\"registry.terraform.io/hashicorp/google\"]" produced an unexpected new
│ value: Root object was present, but now absent.
│
│ This is a bug in the provider, which should be reported in the provider's own
│ issue tracker.
╵
╷
│ Error: Error creating Database: googleapi: Error 403: The client is not authorized to make this request., notAuthorized
│
│   with module.sql_db.google_sql_database.default[0],
│   on .terraform/modules/sql_db/modules/mysql/main.tf line 206, in resource "google_sql_database" "default":206: resource "google_sql_database" "default" {
│
╵
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... fail

Failure! 0 passed, 1 failed.

providerへのバグ報告要求や認証エラーなど全く意味不明なエラーが表示される。

ここから更に

  • mock_providerをgoogle-betaではなくgoogleに変更
  • mock_providerとしてgoogle-beta、googleの2つを定義
  • mock_providerにmock_resourceを定義せずに空設定のモックで実行

といった変更を行なって再試験してみたが、すべての実行で解決方法がわからないようなエラーが発生する結果となった。

というか、そもそもこのような筋の悪い遠回しなことをするくらいなら、override_moduleを使ってモックした方が良いに決まっているため、今度は以下のようにコードを変更してみた。

/tmp/sample-project/terraform/modules/db/tests/main.tftest.hcl
# ここではgoogle-betaではなくgoogleとしている
# ブループリントモジュール内ではリソースごとに使用しているプロバイダが異なるため、
# どちらかといえばgoogleの方が動きそうという理由による
mock_provider "google" {
  alias = "fake"
}

# ドキュメントを確認する限りoverride_resourceやoverride_dataはmock_provider内に
# 定義出来るようだが、override_moduleはmock_provider内に定義するとエラーが発生する
override_module {
  target = module.sql_db
  outputs = {
    generated_user_password = "12345678901234567890123456789012"
    instance_connection_name = "infra-testing-google-sample-sbx-e:asia-northeast1:sample-instance-12345678"
    private_ip_address = "10.4.102.3"
    public_ip_address = ""
  }
}

run "apply_db" {
  providers = {
    google = google.fake
  }
  ...
}

この方法については上手くいきそうだと思って実行したのだが、結果は以下のようなエラーの大量発生だった。

$ TF_VAR_service=infra-testing-google-example \
  TF_VAR_env=sbx-e \
  TF_VAR_cloudsql_network_address=10.4.102.0 \
  terraform test -filter=tests/main.tftest.hcl
...
╷
│ Error: Invalid for_each argument
│
│   on .terraform/modules/sql_db/modules/mysql/read_replica.tf line 33, in resource "google_sql_database_instance" "replicas":33:   for_each             = local.replicas
│     ├────────────────
│     │ local.replicas will be known only after apply
│
│ The "for_each" map includes keys derived from resource attributes that cannot be
│ determined until apply, and so Terraform cannot determine the full set of keys
│ that will identify the instances of this resource.
│
│ When working with unknown values in for_each, it's better to define the map keys
│ statically in your configuration and place apply-time results only in the map
│ values.
│
│ Alternatively, you could use the -target planning option to first apply only the
│ resources that the for_each value depends on, and then apply a second time to
│ fully converge.
╵
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... fail

Failure! 0 passed, 1 failed.

出力にはブループリントモジュール内のコードのfor_eachで参照している値がapply後でないと分からないためエラーと書いているが、これに関しては正直なところ自分にはどうしようもない問題である。

(おそらくこのエラーに関連しているであろうissueはこちら)

これもまたブループリントなどの第三者が作成したモジュールを使用することのリスクとして覚えておく必要があるのかもしれない。

override_moduleブロックがdbモジュールでは基本出番がないことが分かったので、せめてmock_resourceoverride_resourceをVPC関連の3つのリソースに適用してテスト開発が良くなる可能性を考えたが、これらのリソースはプロビジョニングにそこまで時間がかからないため、わざわざ偽物のリソースを交えてテストをするくらいなら、command = applyのテストを実行した方がシンプルかつテストの質的にも圧倒的に上なのでこれまた出番がない。

最後のチャンスとして、モックの本質は依存解決時の代替であるため、VPCとCloud SQL間の依存関係で役に立つ使い方がないか考えたが、dbモジュールではこれらの依存をtier1と変数による値渡しだけで既に解決出来ているため、自分のプロジェクトではTerraform Mockは不要という結論に至った。

そもそもの話だが、Cloud SQLインスタンスの生成をモックをしたところで、テスト対象として必ず1回はapplyしないといけないため、それであればapplyテストを行うrunブロックを1つだけ用意してそこにすべてのassertを記述するのが現状の最適解な気がしている。

おわり

以上でValidation、Test、Mockを実際に使って、どのようにテストコードを実装するべきかを説明出来たかと思う。

Mockはまだ誕生したばかりなので、半年〜1年後くらいにまた様子を見て使い所を探ってみたいとは思っている。

次でテストコード体系化の話は最後になるが、後編ではこれまで説明してきた検証の中で得られた知見を少々紹介する予定。

追記

次で完。

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

Discussion