1.8.1. 测试
-> 注意: 该测试框架在 Terraform v1.6.0 及以后版本中可用。
Terraform 测试功能允许模块作者验证配置变更不会引入破坏性更改。测试针对特定的、临时的资源进行,防止对现有的基础设施或状态产生任何风险。
1.8.1.1. 集成测试或单元测试
默认情况下,Terraform 测试会创建真实的基础设施,并可以对这些基础设施进行断言和验证。这相当于集成测试,它通过调用 Terraform 创建基础设施并对其进行验证来测试 Terraform 的核心功能。
你可以通过更新 run
块中的 command
属性(下面有示例)来覆盖默认的测试行为。默认情况下,每个 run
块都会执行 command = apply
,命令 Terraform 对你的配置执行完整的 apply
操作。将 command
值替换为 command = plan
会告诉 Terraform 不为这个 run
块创建新的基础设施。这将允许测试作者验证他们的基础设施中的逻辑操作和自定义条件,相当于编写了单元测试。
Terraform v1.7.0 引入了在 terraform test
执行期间模拟 Provider 返回数据的能力。这可以用于编写更详细和完整的单元测试。
1.8.1.2. 语法
每个 Terraform 测试都保存在一个测试文件中。Terraform 根据文件扩展名发现测试文件:.tftest.hcl
或 .tftest.json
。
每个测试文件包含以下根级别的属性和块:
Terraform 按顺序执行 run
块,模拟一系列直接在配置目录中执行的 Terraform 命令。 variables
和 provider
块的顺序并不重要,Terraform 在测试操作开始时处理这些块中的所有值。我们建议首先在测试文件的开头定义你的 variables
和 provider
块。
1.8.1.2.1. 示例
以下示例演示了一个简单的 Terraform 配置,该配置创建了一个 AWS S3 存储桶,并使用输入变量来修改其名称。我们将创建一个示例测试文件(如下)来验证存储桶的名称是否如预期那样被创建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # main.tf provider "aws" { region = "eu-central-1" } variable "bucket_prefix" { type = string } resource "aws_s3_bucket" "bucket" { bucket = "${var.bucket_prefix}-bucket" } output "bucket_name" { value = aws_s3_bucket.bucket.bucket }
以下测试文件运行了一个单独的Terraform plan
命令,该命令创建了S3存储桶,然后通过检查实际名称是否与预期名称匹配,来验证计算名称的逻辑是否正确。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # valid_string_concat.tftest.hcl variables { bucket_prefix = "test" } run "valid_string_concat" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == "test-bucket" error_message = "S3 bucket name did not match expected" } }
1.8.1.3. run 块
每个 run
块都有以下字段和块:
字段或块名称
描述
默认值
command
一个可选属性,可以是 apply
或 plan
。
apply
plan_options.mode
一个可选属性,可以是 normal
或 refresh-only
。
normal
plan_options.refresh
一个可选的 bool
属性。
true
plan_options.replace
一个可选属性,包含一个资源地址列表,引用测试配置中的资源。
plan_options.target
一个可选属性,包含一个资源地址列表,引用测试配置中的资源。
variables
一个可选的 variables
块。
module
一个可选的 module
块。
providers
一个可选的 providers
属性。
assert
可选的 assert
块。
expect_failures
一个可选属性。
command
属性和 plan_options
块告诉 Terraform 对于每个 run
块执行哪个命令和选项。如果您没有指定 command
属性或 plan_options
块,那么默认操作是普通的 terraform apply
操作。
command
属性指明操作应该是一个 plan
操作还是一个 apply
操作。
plan_options
块允许测试的作者定义他们通常需要通过命令行标志和选项定义的 plan mode 和 选项 。我们将在 变量 部分介绍 -var
和 -var-file
选项。
1.8.1.3.1. 断言
Terraform 测试的 run
块断言是自定义条件 ,由条件 和错误消息 组成。
在 Terraform 测试命令执行结束时,Terraform 会将所有失败的断言作为测试通过或失败状态的一部分展示出来。
断言中的引用
测试中的断言可以引用主 Terraform 配置中的其他自定义条件可用的任何现有命名值 。
此外,测试断言可以直接引用当前和先前 run
块的输出。比如引用了上一个示例 中的输出的一个合法的表达式条件:condition = output.bucket_name == "test_bucket"
。
1.8.1.4. variable 块
你可以直接在你的测试文件中为 输入变量 设置值。
你可以在测试文件的根级别或者 run
块内部定义 variables
块。Terraform 将测试文件中的所有变量值传递到文件中的所有 run
块。你可以通过在某个 run
块中直接设置变量值来覆盖从根部继承的值。
在上述 示例 的测试文件中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # variable_precedence.tftest.hcl variables { bucket_prefix = "test" } run "uses_root_level_value" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == "test-bucket" error_message = "S3 bucket name did not match expected" } } run "overrides_root_level_value" { command = plan variables { bucket_prefix = "other" } assert { condition = aws_s3_bucket.bucket.bucket == "other-bucket" error_message = "S3 bucket name did not match expected" } }
我们添加了第二个 run
块,该块指定 bucket_prefix
变量值为 other
,覆盖了测试文件提供的,并在第一个 run
块中使用的值 —— test
。
1.8.1.4.1. 通过命令行或定义文件指定变量
除了通过测试文件指定变量值外,Terraform test
命令还支持指定变量值的其他方法。
您可以通过 命令行 和 变量定义文件 为所有测试指定变量值。
像普通的 Terraform 命令一样,Terraform 会自动加载测试目录中定义的任何变量文件。自动变量文件包括 terraform.tfvars
、terraform.tfvars.json
,以及所有以 .auto.tfvars
或 .auto.tfvars.json
结尾的文件。
注意: 从测试目录中的自动变量文件加载的变量值只适用于在同一测试目录中定义的测试。以所有其他方式定义的变量将适用于给定测试运行中的所有测试。
这在使用敏感变量值和设置 Provider 配置时特别有用。否则,测试文件可能会直接暴露这些敏感值。
1.8.1.4.2. 变量定义优先级
除了测试文件中设置的变量值,变量定义优先级 在测试中保持不变。在测试文件中定义的变量具有最高优先级,可以覆盖环境变量、变量文件或命令行输入。
对于在测试目录中定义的测试,任何在测试目录的自动变量文件中定义的变量值都将覆盖主配置目录的自动变量文件中定义的值。
1.8.1.4.3. 变量中的引用
在 run
块中定义的 variable
中可以引用在先前 run
块中执行的模块的输出和在更高优先级定义的变量。
例如,以下代码块显示了变量如何引用更高优先级的变量和先前的 run
块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 variables { global_value = "some value" } run "run_block_one" { variables { local_value = var.global_value } # ... # 这里应该有一些测试断言 # ... } run "run_block_two" { variables { local_value = run.run_block_one.output_one } # ... # 这里应该有一些测试断言 # ... }
上面,run_block_one
中的 local_value
从 global_value
变量获取值。如果你想给多个变量分配相同的值,这种模式很有用。你可以在文件级别一次指定一个变量的值,然后与不同的变量共享它。
相比之下,run_block_two
中的 local_value
引用了 run_block_one
的 output_one
的输出值。这种模式对于在 run
块之间传递值特别有用,特别是如果 run
块正在执行模块 部分中详细描述的不同模块。
1.8.1.5. provider 块
您可以通过使用 provider
和 providers
块和属性,在测试文件中设置或覆盖 Terraform 代码所需的 Provider。
您可以在 Terraform 测试文件的根级别,定义 provider
块 ,就像在 Terraform 配置代码中 创建它们一样。然后,Terraform 会将这些 provider
块传递到其配置中,每个 run
块执行时都是如此。
默认情况下,您指定的每个 Provider 都直接在每个 run
块中可用。您可以通过使用 providers
属性在特定 run
块中设置 Provider 的可用性。这个块的行为和语法与 providers meta-argument 的行为相匹配。
如果您在测试文件中不提供 Provider 配置,Terraform 会尝试使用 Provider 的默认设置初始化其配置中的所有 Provider。例如,任何旨在配置 Provider 的环境变量仍然可用,并且 Terraform 可以使用它们来创建默认 Provider。
下面,我们将扩展我们之前的 示例 ,用测试代码而不是 Terraform 配置代码来指定 region
。在这个示例中,我们将测试以下配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # main.tf terraform { required_providers { aws = { source = "hashicorp/aws" } } } variable "bucket_prefix" { type = string } resource "aws_s3_bucket" "bucket" { bucket = "${var.bucket_prefix}-bucket" } output "bucket_name" { value = aws_s3_bucket.bucket.bucket }
我们现在可以在以下测试文件中定义如下的 provider
块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # customised_provider.tftest.hcl provider "aws" { region = "eu-central-1" } variables { bucket_prefix = "test" } run "valid_string_concat" { command = plan assert { condition = aws_s3_bucket.bucket.bucket == "test-bucket" error_message = "S3 bucket name did not match expected" } }
现在我们也可以创建一个更复杂的示例配置,使用多个 Provider 以及别名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # main.tf terraform { required_providers { aws = { source = "hashicorp/aws" configuration_aliases = [aws.secondary] } } } variable "bucket_prefix" { default = "test" type = string } resource "aws_s3_bucket" "primary_bucket" { bucket = "${var.bucket_prefix}-primary" } resource "aws_s3_bucket" "secondary_bucket" { provider = aws.secondary bucket = "${var.bucket_prefix}-secondary" }
在我们的测试文件中,我们可以设定多个 Provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # customised_providers.tftest.hcl provider "aws" { region = "us-east-1" } provider "aws" { alias = "secondary" region = "eu-central-1" } run "providers" { command = plan assert { condition = aws_s3_bucket.primary_bucket.bucket == "test-primary" error_message = "invalid value for primary S3 bucket" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == "test-secondary" error_message = "invalid value for secondary S3 bucket" } }
我们也可以在特定 run
块中声明特定的 Provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # main.tf terraform { required_providers { aws = { source = "hashicorp/aws" configuration_aliases = [aws.secondary] } } } data "aws_region" "primary" {} data "aws_region" "secondary" { provider = aws.secondary } variable "bucket_prefix" { default = "test" type = string } resource "aws_s3_bucket" "primary_bucket" { bucket = "${var.bucket_prefix}-${data.aws_region.primary.name}-primary" } resource "aws_s3_bucket" "secondary_bucket" { provider = aws.secondary bucket = "${var.bucket_prefix}-${data.aws_region.secondary.name}-secondary" }
我们的测试文件可以为不同的 run
块配置的 Provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 # customised_providers.tftest.hcl provider "aws" { region = "us-east-1" } provider "aws" { alias = "secondary" region = "eu-central-1" } provider "aws" { alias = "tertiary" region = "eu-west-2" } run "default_providers" { command = plan assert { condition = aws_s3_bucket.primary_bucket.bucket == "test-us-east-1-primary" error_message = "invalid value for primary S3 bucket" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == "test-eu-central-1-secondary" error_message = "invalid value for secondary S3 bucket" } } run "customised_providers" { command = plan providers = { aws = aws aws.secondary = aws.tertiary } assert { condition = aws_s3_bucket.primary_bucket.bucket == "test-us-east-1-primary" error_message = "invalid value for primary S3 bucket" } assert { condition = aws_s3_bucket.secondary_bucket.bucket == "test-eu-west-2-secondary" error_message = "invalid value for secondary S3 bucket" } }
注意: 在使用 command = apply
运行测试时,run
块之间切换 Provider 可能会导致运行和测试失败,因为由一个 Provider 定义创建的资源在被另一个修改时将无法使用。
从 Terraform v1.7.0 开始,provider
块也可以引用测试文件变量和 run
块输出。这意味着测试框架可以从一个 Provider 获取凭证和其他设置信息,并在初始化第二个 Provider 时使用这些信息。
在下面的示例中,首先初始化 vault
Provider,然后在一个设置模块中使用它来提取 aws
Provider 的凭证。有关 setup 模块的更多信息,请参阅 模块 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 provider "vault" { # ... vault configuration ... } provider "aws" { region = "us-east-1" # The `aws` provider can reference the outputs of the "vault_setup" run block. access_key = run.vault_setup.aws_access_key secret_key = run.vault_setup.aws_secret_key } run "vault_setup" { module { # This module should only include reference to the Vault provider. Terraform # will automatically work out which providers to supply based on the module # configuration. The tests will error if a run block requires access to a # provider that references outputs from a run block that has not executed. source = "./testing/vault-setup" } } run "use_aws_provider" { # This run block can then use both the `aws` and `vault` providers, as the # previous run block provided all the data required for the `aws` provider. }
1.8.1.6. module 块
您可以修改特定的 run
块执行的模块。
默认情况下,Terraform 针对正在测试的配置代码,依次执行所有 run
块中设定的命令。Terraform 在您执行 terraform test
命令的目录(或者您用 -chdir
参数指向的目录)内测试配置。每个 run
块也允许用户使用 module
块更改目标配置。
与传统的 module
块 不同,测试文件中的 module
块 仅 支持 source
属性和 version
属性。通常通过传统的 module
块提供的其余属性应由 run
块内的替代属性和块提供。
注意: Terraform 测试文件只支持 source
属性中的 本地 和 注册表 模块。
在执行其他模块时,run
块内的所有其他块和属性都受支持,assert
块执行时使用来自其他模块的值。这在 模块状态 中有更详细的说明。
测试文件中 modules
块的两个示例用例是:
一个设置模块,为待测 Terraform 配置代码创建测试所需的基础设施。
一个加载模块,用于加载和验证 Terraform 配置代码未直接创建的次要基础设施(如数据源)。
以下示例演示了这两种用例。
首先,我们有一个模块,它将创建并将多个文件加载到已创建的 S3 存储桶中。这是我们要测试的配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # main.tf variable "bucket" { type = string } variable "files" { type = map(string) } data "aws_s3_bucket" "bucket" { bucket = var.bucket } resource "aws_s3_object" "object" { for_each = var.files bucket = data.aws_s3_bucket.bucket.id key = each.key source = each.value etag = filemd5(each.value) }
然后,我们使用配置模块创建这个 S3 存储桶,这样在测试时就可以使用它:
1 2 3 4 5 6 7 8 9 # testing/setup/main.tf variable "bucket" { type = string } resource "aws_s3_bucket" "bucket" { bucket = var.bucket }
第三步,我们使用一个加载模块,读取 S3 存储桶中的文件。这是一个比较牵强的例子,因为我们完全可以直接在创建这些文件的模块中创建这些数据源,但它在这里可以很好地演示如何编写测试:
1 2 3 4 5 6 7 8 9 # testing/loader/main.tf variable "bucket" { type = string } data "aws_s3_objects" "objects" { bucket = var.bucket }
最后,我们使用测试文件把刚才创建的多个助手模块以及待测模块编织在一起形成一个有效的测试配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 # file_count.tftest.hcl variables { bucket = "my_test_bucket" files = { "file-one.txt": "data/files/file_one.txt" "file-two.txt": "data/files/file_two.txt" } } provider "aws" { region = "us-east-1" } run "setup" { # Create the S3 bucket we will use later. module { source = "./testing/setup" } } run "execute" { # This is empty, we just run the configuration under test using all the default settings. } run "verify" { # Load and count the objects created in the "execute" run block. module { source = "./testing/loader" } assert { condition = length(data.aws_s3_objects.objects.keys) == 2 error_message = "created the wrong number of s3 objects" } }
1.8.1.6.1. 模块状态
当 Terraform 执行 terraform test
命令时,Terraform 会为每个测试文件在内存中维护一个或多个状态文件。
总是至少有一个状态文件维护在测试下的 Terraform 配置代码的状态。这个状态文件由所有没有 module
块指定要加载的替代模块的 run
块共享。
此外,Terraform 加载的每个替代模块都有一个状态文件。一个替代模块的状态文件被执行给定模块的所有 run
块共享。
Terraform 团队对任何需要手动状态管理或在 test
命令中对同一状态执行不同配置的用例感兴趣。如果你有一个用例,请提交一个 issue 并与我们分享。
以下示例使用注释来解释每个 run
块的状态文件的来源。在下面的示例中,Terraform 创建并管理了总共三个状态文件。第一个状态文件是针对测试下的主模块,第二个是针对设置模块,第三个是针对加载模块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 run "setup" { # This run block references an alternate module and is the first run block # to reference this particular alternate module. Therefore, Terraform creates # and populates a new empty state file for this run block. module { source = "./testing/setup" } } run "init" { # This run block does not reference an alternate module, so it uses the main # state file for the configuration under test. As this is the first run block # to reference the main configuration, the previously empty state file now # contains the resources created by this run block. assert { # In practice we'd do some interesting checks and tests here but the # assertions aren't important for this example. } # ... more assertions ... } run "update_setup" { # We've now re-referenced the setup module, so the state file that was created # for the first "setup" run block will be reused. It will contain any # resources that were created as part of the other run block before this run # block executes and will be updated with any changes made by this run block # after. module { source = "./testing/setup" } variables { # In practice, we'd likely make some changes to the module compared to the # first run block here. Otherwise, there would be no point recalling the # module. } } run "update" { # As with the "init" run block, we are executing against the main configuration # again. This means we'd load the main state file that was initially populated # by the "init" run block, and any changes made by this "run" block will be # carried forward to any future run blocks that execute against the main # configuration. # ... updated variables ... # ... assertions ... } run "loader" { # This run block is now referencing our second alternate module so will create # our third and final state file. The other two state files are managing # resources from the main configuration and resources from the setup module. # We are getting a new state file for this run block as the loader module has # not previously been referenced by any run blocks. module { source = "./testing/loader" } }
模块的清理
在测试文件执行结束时,Terraform 会试图销毁在该测试文件执行过程中创建的每个资源。当 Terraform 加载替代模块时,Terraform 销毁这些对象的顺序很重要。例如,在第一个 模块 示例中,Terraform 不能在 “execute” run
块中创建的对象之前销毁在 “setup” run
块中创建的资源,因为我们在 “setup” 步骤中创建的 S3 桶在包含对象的情况下无法被销毁。
Terraform 按照 run
块的反向顺序销毁资源。在最近的 例子 中,有三个状态文件。一个用于主状态,一个用于 ./testing/loader
模块,还有一个用于 ./testing/setup
模块。由于 ./testing/loader
状态文件最近被最后一个运行块引用,因此首先被销毁。主状态文件将被第二个销毁,因为它被 “update” run
块引用。然后 ./testing/setup
状态文件将被最后销毁。
请注意,前两个 run
块 “setup” 和 “init” 在销毁操作中不做任何事情,因为它们的状态文件被后续的 run 块使用,并且已经被销毁。
如果你使用单个设置模块作为替代模块,并且它首先执行,或者你不使用任何替代模块,那么销毁顺序不会影响你。更复杂的情况可能需要仔细考虑,以确保资源的销毁可以自动完成。
1.8.1.7. 预期失败
默认情况下,如果在执行 Terraform 测试文件期间,任何自定义条件 ,包括 check
块断言失败,则整体命令会将测试报告为失败。
然而,我们经常想要测试代码运行失败时的行为。Terraform 为此用例提供了 expect_failures
属性。
在每个 run
块中,expect_failures
属性可以设置应该导致自定义条件检查失败的可检查对象(资源,数据源,检查块,输入变量和输出)的列表。如果您指定的可检查对象报告问题,测则试通过,如果没有报告错误,那么测试总体上失败。
您仍然可以在 expect_failures
块附近编写断言,但您应该注意,除了 check
块断言外,所有自定义条件都会停止 Terraform 的执行。这在测试执行期间仍然适用,所以这些断言应该只考虑你确定会在可检查对象应该失败之前可知的值。您可以使用引用或在主配置中的 depends_on
元参数来管理这一点。
这也意味着,除了 check
块,你只能可靠地包含一个可检查的对象。我们支持在 expect_failures
属性中列出可检查对象的列表,仅用于 check
块。
下面的一个快速示例演示了测试输入变量的 validation
块。配置文件接受一个必须是偶数的单一输入变量。
1 2 3 4 5 6 7 8 9 10 # main.tf variable "input" { type = number validation { condition = var.input % 2 == 0 error_message = "must be even number" } }
测试文件包含了两个 run
块。一个验证了我们的自定义条件在偶数条件下是通过的,另一个验证输入奇数时会失败。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # input_validation.tftest.hcl variables { input = 0 } run "zero" { # The variable defined above is even, so we expect the validation to pass. command = plan } run "one" { # This time we set the variable is odd, so we expect the validation to fail. command = plan variables { input = 1 } expect_failures = [ var.input, ] }
注意 :Terraform 只期望在 run
块的 command
属性指定的操作中出现失败。
在使用 command = apply
的 run
块中使用 expect_failures
时要小心。一个 run
块中的 command = apply
如果期望自定义条件失败,那么如果该自定义条件在 plan
期间失败,整体将会失败。
这在逻辑上是正确的,因为 run
块期望能够运行应用操作,但由于 plan
失败而不能运行,但这也可能会引起混淆,因为即使那个失败被标记为预期的,你还是会在诊断中看到失败。
有时,Terraform 在计划阶段不执行自定义条件,因为该条件依赖于只有在 Terraform 创建引用资源后才可用的计算属性。在这些情况下,你可以在设置 command = apply
时使用 expect_failures
块。然而,大多数情况下,我们建议只在 command = plan
时使用 expect_failures
。
注意 :预期的失败只适用于用户定义的自定义条件。
除了在可检查对象中指定的预期失败之外的其他种类的失败仍会导致整体测试失败。例如,一个期望布尔值作为输入的变量,如果 Terraform 收到的是错误的值类型,即使该变量包含在 expect_failures
属性中,也会导致周围的测试失败。
expect_failures
属性包含在其中是为了允许作者测试他们的配置和任何定义的逻辑。像前面的例子中的类型不匹配错误,不是 Terraform 作者应该担心和测试的事情,因为 Terraform 本身会处理强制类型约束。因此,你只能在自定义条件中 expect_failures
。