CLOVER🍀

That was when it all began.

Terraform 1.7で追加されたモック(Mocks)を試す

これは、なにをしたくて書いたもの?

Terraform 1.6でtesting frameworkが追加されたので、以前に試してみました。

Terraform 1.6で追加されたTerraform testing framework(terraform test)を試す - CLOVER🍀

そしてTerraform 1.7ではこれにモックが追加されました。

Terraform 1.7 adds test mocking and config-driven remove

今回は、このモックを試してみたいと思います。

モック(Mocks)

Terraform 1.7に関するリリースブログはこちら。

Terraform 1.7 adds test mocking and config-driven remove

Terraform 1.6ではHCLを使ってTerraformコードの単体テスト、結合テストを実行可能なtesting frameworkが導入されました。

この時点ではplanやapplyでは実際のプロバイダーを呼び出して実行していましたが、Terraform 1.7ではここにモックの利用が可能に
なっています。

利用方法としては、以下の2つがあるようです。

  • プロバイダー自体をモックにする
  • 特定のリソース、データソース、モジュールのうちの特定のインスタンスをオーバーライドしてモックにする

後者のオーバーライドの方は、テスト全体に適用することもできれば、runブロック内で定義して適用範囲を絞ることもできるようです。
また実際のプロバイダーとモックプロバイダーの両方で使用できるそうです。

プロビジョニングに時間のかかるリソースをモックにしたり、出力のみが欲しい場合など、様々なケースで使えるだろう、とされています。

より詳細な内容は、ドキュメントを参照、ということで。

Tests - Provider Mocking | Terraform | HashiCorp Developer

こちらは実際に使う時に、もう少し詳細に見ていこうと思います。

またチュートリアルにもモックが出てくるようです。

Write Terraform Tests | Terraform | HashiCorp Developer

その他、Terraform 1.7ではテストに関する機能強化が入っているようです。

  • 変数を参照して、test providerブロックで出力を実行する
  • variableおよびproviderブロックでHCL関数を使う
  • テストに関する値を*.tfvarsファイルからロードする

テストに関するブログはこちら。

Testing HashiCorp Terraform

今回は、モックについて見ていきます。

環境

今回の環境はこちら。

$ terraform version
Terraform v1.7.2
on linux_amd64

テスト対象のリソース作成は、LocalStackを使うことにします。

$ python3 --version
Python 3.10.12


$ localstack --version
3.1.0

起動。

$ localstack start

一部、AWS CLIも使います。

$ aws --version
aws-cli/2.15.17 Python/3.11.6 Linux/5.15.0-92-generic exe/x86_64.ubuntu.22 prompt/off

Terraformリソース定義

テストをするにも、Terraformで構築するリソースの定義がないと始まりません。

今回は、モックプロバイダーとオーバーライドの両方で試そうと思いますが、リソースの定義自体は同じものを使うことにしました。

versions.tf

terraform {
  required_version = "1.7.2"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.35.0"
    }
  }
}

main.tf

provider "aws" {
  access_key                  = "mock_access_key"
  region                      = "us-east-1"
  s3_use_path_style           = true
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    apigateway     = "http://localhost:4566"
    cloudformation = "http://localhost:4566"
    cloudwatch     = "http://localhost:4566"
    dynamodb       = "http://localhost:4566"
    es             = "http://localhost:4566"
    firehose       = "http://localhost:4566"
    iam            = "http://localhost:4566"
    kinesis        = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    route53        = "http://localhost:4566"
    redshift       = "http://localhost:4566"
    s3             = "http://localhost:4566"
    secretsmanager = "http://localhost:4566"
    ses            = "http://localhost:4566"
    sns            = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    stepfunctions  = "http://localhost:4566"
    sts            = "http://localhost:4566"
  }
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

resource "aws_s3_object" "this" {
  bucket  = aws_s3_bucket.this.bucket
  key     = var.content_key
  content = var.content_value
}

作成するのはAmazon S3バケットと、バケット内に作成するコンテンツです。

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

resource "aws_s3_object" "this" {
  bucket  = aws_s3_bucket.this.bucket
  key     = var.content_key
  content = var.content_value
}

variables.tf

variable "bucket_name" {
  type = string
}

variable "content_key" {
  type    = string
  default = "test.txt"
}

variable "content_value" {
  type    = string
  default = "Hello World"
}

入力変数は、バケット名だけで良かった気がします…。

outputs.tf

output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

output "object_key" {
  value = aws_s3_object.this.key
}

output "object_content_type" {
  value = aws_s3_object.this.content_type
}

outputはちょっとわざとらしいですが、モックの確認で使っていきます。

まずはこれでリソースが構築できることを確認しましょう。

$ terraform init
$ terraform plan -var 'bucket_name=my-bucket'
$ terraform apply -var 'bucket_name=my-bucket'

結果。

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

Outputs:

bucket_arn = "arn:aws:s3:::my-bucket"
bucket_name = "my-bucket"
object_content_type = "application/octet-stream"
object_key = "test.txt"

リソースが構築されたことを確認します。

$ aws --endpoint-url http://localhost:4566 s3 ls
2024-02-04 20:49:10 my-bucket


$ aws --endpoint-url http://localhost:4566 s3 ls my-bucket/
2024-02-04 20:49:10         11 test.txt


$ aws --endpoint-url http://localhost:4566 s3 cp s3://my-bucket/test.txt -
Hello World

OKですね。

確認できたら、リソースを破棄しておきます。

$ terraform destroy -var 'bucket_name=my-bucket'

モックプロバイダーを使う

では、まずはモックプロバイダーを使うところから始めていきます。

最初にベースになるテストを書きましょう。

tests/create_s3_bucket.tftest.hcl

run "create_bucket" {
  variables {
    bucket_name = "my-bucket"
  }

  assert {
    condition     = aws_s3_bucket.this.bucket == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = aws_s3_object.this.key == "test.txt"
    error_message = "content key did not match expected"
  }
}

tests/create_s3_bucket_outputs.tftest.hcl

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::my-bucket"
    error_message = "S3 arn did not match expected"
  }

  assert {
    condition     = output.object_key == "test.txt"
    error_message = "content key did not match expected"
  }

  assert {
    condition     = output.object_content_type == "application/octet-stream"
    error_message = "content type did not match expected"
  }
}

確認。

$ terraform test

テストが通りました。

Success! 2 passed, 0 failed.

それでは、このテストにモックプロバイダーを導入してみます。

モックプロバイダーは、プロバイダー自体をモックにします。

In Terraform tests, you can mock a provider with the mock_provider block.

Mocks / Mock Providers

モックプロバイダーを使用して構築されるリソースおよびデータソースは、すべてモックになります。

All resources and data sources retrieved by a mock provider will set the relevant values from the configuration, and generate fake data for any computed attributes.

最小構成としては、テスト内にmock_providerを定義します。

tests/create_s3_bucket.tftest.hcl

mock_provider "aws" {}

run "create_bucket" {
  variables {
    bucket_name = "my-bucket"
  }

〜省略〜

tests/create_s3_bucket_outputs.tftest.hcl

mock_provider "aws" {}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

〜省略〜

これでAWSプロバイダーがモックになりました。

ではテストしてみます。

$ terraform test

すると、outputを見ているテストが失敗します。

tests/create_s3_bucket.tftest.hcl... in progress
  run "create_bucket"... pass
tests/create_s3_bucket.tftest.hcl... tearing down
tests/create_s3_bucket.tftest.hcl... pass
tests/create_s3_bucket_outputs.tftest.hcl... in progress
  run "create_bucket_assert_output"... fail
╷
│ Error: Test assertion failed
│
│   on tests/create_s3_bucket_outputs.tftest.hcl line 14, in run "create_bucket_assert_output":
│   14:     condition     = output.bucket_arn == "arn:aws:s3:::my-bucket"
│     ├────────────────
│     │ output.bucket_arn is "mcjyansb"
│
│ S3 arn did not match expected
╵
╷
│ Error: Test assertion failed
│
│   on tests/create_s3_bucket_outputs.tftest.hcl line 24, in run "create_bucket_assert_output":
│   24:     condition     = output.object_content_type == "application/octet-stream"
│     ├────────────────
│     │ output.object_content_type is "0z6b8a56"
│
│ content type did not match expected
╵
tests/create_s3_bucket_outputs.tftest.hcl... tearing down
tests/create_s3_bucket_outputs.tftest.hcl... fail

Failure! 1 passed, 1 failed.

これは、モックプロバイダーから生成されたリソースの値とテストの期待値が合わないからですね。

モックプロバイダーによるリソースやデータソースは、値を指定しない限り以下のルールで自動的に値を生成するようです。

  • number型の場合は0
  • boolean型の場合はfalse
  • string型の場合は8文字のランダム文字列(アルファベットと数字)
  • set、list、mapを含むコレクションの場合、空のコレクション
  • object型の場合は、上記ルールに従ってメンバーの値を設定する

Mocks / Mock Providers / Generated data

では、モックプロバイダーが生成するリソースの値を指定すればよい、ということで以下のようにmock_resourceで各リソースの
属性がどのような値を返すのかを指定します。

tests/create_s3_bucket_outputs.tftest.hcl

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::my-bucket"
    }
  }

  mock_resource "aws_s3_object" {
    defaults = {
      content_type = "application/octet-stream"
    }
  }
}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

〜省略〜

これで、各リソースに対してデフォルト値としてどのような値を返すのか設定できました。

これでテストが通るようになります。

$ terraform test
tests/create_s3_bucket.tftest.hcl... in progress
  run "create_bucket"... pass
tests/create_s3_bucket.tftest.hcl... tearing down
tests/create_s3_bucket.tftest.hcl... pass
tests/create_s3_bucket_outputs.tftest.hcl... in progress
  run "create_bucket_assert_output"... pass
tests/create_s3_bucket_outputs.tftest.hcl... tearing down
tests/create_s3_bucket_outputs.tftest.hcl... pass

Success! 2 passed, 0 failed.

ちなみに、実行速度としてはモックなのでとても高速になります。

モックプロバイダーの使い方は基本的にはこんな感じで、データソースを使っても同じですね。

また、モック内で定義するモックリソースやモックデータソースの定義は以下のようにモジュールとしても共有可能なようです。

mock_provider "aws" {
  source = "./testing/aws"
}

注意点としては、mock_providerはトップレベルでしか書けない(providerと同じ)なので、HCLファイル内で書かれた各テストで
モックプロバイダーの適用有無を切り替えることはできません。
その場合は、テストファイル自体を分ける必要があります。

また、見てのとおりモックのリソースやデータソースがどのような値を指定するかは決められますが、同じ種類のリソースやデータソースが
複数あっても画一的な値が返ることになります。
ここをなんとかしたかったら、オーバーライドを使うのかなと思います。

オーバーライド

もうひとつのテーマがオーバーライドです。

In addition to mocking providers, you can use the following block types to override specific resources, data sources, and modules:

この機能では、リソース、データソース、モジュールをオーバーライドしてモックにできます。

Mocks / Overrides

オーバーライドした箇所については、Terraformは元になったプロバイダーの呼び出しを行わず、オーバーライドの定義に従うように
なります。

ドキュメントを見ていると誤解しやすい気がしますが、この機能自体はモックプロバイダーとは独立したものになっています。

ドキュメントの例が常にモックプロバイダーと組み合わせているのでちょっと混乱しましたが、独立して使える機能です。
また、runブロック内で使用することもできます。

いくつかバリエーションを見ていってみます。

まずは特定のリソースのみをオーバーライドするパターン。モックプロバイダーは使用していません。

tests/create_s3_bucket_outputs.tftest.hcl

override_resource {
  target = aws_s3_object.this
  values = {
    content_type = "application/octet-stream"
  }
}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::my-bucket"
    error_message = "S3 arn did not match expected"
  }

  assert {
    condition     = output.object_key == "test.txt"
    error_message = "content key did not match expected"
  }

  assert {
    condition     = output.object_content_type == "application/octet-stream"
    error_message = "content type did not match expected"
  }
}

オーバーライドでは、対象となるリソースやデータソース、モジュールをtargetで個々に指定します。

override_resource {
  target = aws_s3_object.this
  values = {
    content_type = "application/octet-stream"
  }
}

この例では、Amazon S3バケットは本物のAWSプロバイダーによって作成されますが、バケット内に配置するオブジェクトだけが
モックになったということになりますね。

次はAmazon S3バケットもオブジェクトもオーバーライドでモックにします。

tests/create_s3_bucket_outputs2.tftest.hcl

override_resource {
  target = aws_s3_bucket.this
  values = {
    arn = "arn:aws:s3:::test-bucket"
  }
}

override_resource {
  target = aws_s3_object.this
  values = {
    content_type = "application/octet-stream"
  }
}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::test-bucket"
    error_message = "S3 arn did not match expected"
  }

  assert {
    condition     = output.object_key == "test.txt"
    error_message = "content key did not match expected"
  }

  assert {
    condition     = output.object_content_type == "application/octet-stream"
    error_message = "content type did not match expected"
  }
}

ちょっとわざとらしいですが、variableで指定したバケット名を無視したarnを設定するようにしました。

override_resource {
  target = aws_s3_bucket.this
  values = {
    arn = "arn:aws:s3:::test-bucket"
  }
}

テストコードを見ると、Amazon S3バケット側もモックになっていることがわかります。

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::test-bucket"
    error_message = "S3 arn did not match expected"
  }

今回使用しているリソースは、aws_s3_bucketとaws_s3_objectなので、結果的にはすべてをモックにしたことになります。
AWSプロバイダー自体は使っていますが、それを使って作成されるリソースはないですね。ただ、AWSプロバイダーがAWS(今回は
LocalStackですが)に接続する処理などはそのままなので、モックプロバイダーを使った時に比べると速度差はそれなりにあります。

最後は、モックプロバイダーとの組み合わせです。

tests/create_s3_bucket_outputs3.tftest.hcl

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::xxxxx"
    }
  }

  override_resource {
    target = aws_s3_bucket.this
    values = {
      arn = "arn:aws:s3:::my-bucket"
    }
  }

  override_resource {
    target = aws_s3_object.this
    values = {
      content_type = "application/octet-stream"
    }
  }
}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::my-bucket"
    error_message = "S3 arn did not match expected"
  }

  assert {
    condition     = output.object_key == "test.txt"
    error_message = "content key did not match expected"
  }

  assert {
    condition     = output.object_content_type == "application/octet-stream"
    error_message = "content type did not match expected"
  }
}

AWSプロバイダーをモックプロバイダーに差し替え、aws_s3_bucketリソース全体に対してデフォルトの振る舞いを設定しつつ、
特定のaws_s3_bucketおよびaws_s3_objectリソースについてはオーバーライドするという設定にしています。

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::xxxxx"
    }
  }

  override_resource {
    target = aws_s3_bucket.this
    values = {
      arn = "arn:aws:s3:::my-bucket"
    }
  }

  override_resource {
    target = aws_s3_object.this
    values = {
      content_type = "application/octet-stream"
    }
  }
}

オーバーライドの設定は、run内に書いてもよいですね。

そのパターンはこちら。

tests/create_s3_bucket_outputs4.tftest.hcl

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::xxxxx"
    }
  }
}

run "create_bucket_assert_output" {
  variables {
    bucket_name = "my-bucket"
  }

  override_resource {
    target = aws_s3_bucket.this
    values = {
      arn = "arn:aws:s3:::my-bucket"
    }
  }

  override_resource {
    target = aws_s3_object.this
    values = {
      content_type = "application/octet-stream"
    }
  }

  assert {
    condition     = output.bucket_name == "my-bucket"
    error_message = "S3 bucket name did not match expected"
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::my-bucket"
    error_message = "S3 arn did not match expected"
  }

  assert {
    condition     = output.object_key == "test.txt"
    error_message = "content key did not match expected"
  }

  assert {
    condition     = output.object_content_type == "application/octet-stream"
    error_message = "content type did not match expected"
  }
}

今回は扱いませんが、モジュールの場合はこんな感じのオーバーライドになるようです。

override_module {
  target = module.credentials
  outputs = {
    data = { username = "username", password = "password" }
  }
}

これでモックプロバイダーとは違って、特定のリソースやデータソースに対して固有の振る舞いを指定できる、ように思えますが
繰り返しやネストしたもの対しては注意が必要です。

Mocks / Repeated blocks and nested attributes

こういったもの(replica内にregionがネストしており、リソース定義内に2回登場する)に、それぞれ異なる値を指定することが
できません。指定した場合は、それぞれに同じ値が設定されることになります。

resource "aws_dynamodb_table" "my_table" {
  name     = "my_table"
  hash_key = "key"

  attribute {
    name = "key"
    type = "S"
  }

  replica {
    region_name = "eu-west-2"
  }

  replica {
    region_name = "us-east-1"
  }
}

繰り返し構文を使って作成するリソースなどにも、同じことが言えます。

おわりに

Terraform 1.7で追加されたモックの機能を試してみました。

ブログエントリーやドキュメントを見ている間はちょっとピンと来ない(特にオーバーライド)ところもあったのですが、実際に
動かしてみたらだいぶイメージつきました。

便利は便利なのですが、モックにしすぎるとなにをテストしているのかわからなくなっていくのは通常のプログラミング言語の
テストにおけるモックと同じです。というか、プロバイダーの裏にはクラウドのAPIがいたりするわけで、その結果を踏まえた
リソースのoutputを設定するには…?と考えたりすると、ある意味もっと大変でモックにどのような値を設定するのかを
確認するのも一苦労という感じがします。

使うところは考えていきたいですね。