TT Lab
开始
学习 学习路径 课程

Terraform 实战

每次改模块都手动检查,直到漏掉了一个

在 TT Lab 中继续学习

目标

创建一个适合测试的模块,分别编写只做计划的廉价测试和真正创建资源的昂贵测试,把 run 串联起来,测试应该被拦截的内容是否真的被拦截,查看失败会给出什么消息,用 mock 只确认值,最后打包成供 CI 调用的一行命令。

为什么重要

可复用的模块,调用它的地方越多,就越让人不敢修改。因为每次都要靠人去确认“改了这个值,哪里会坏”。加上测试之后,这个确认就变成了一条命令,从那一刻起,模块才可以被修改。关键是把测试分成两类来用的感觉——只做计划的测试又快又便宜,命名规则、条件分支、默认值这类只看值就够的内容都能覆盖;真正创建资源的测试虽然慢,却是确认“它是否真的会那样生成”的唯一方法。再加上确认“应该被拦截的内容是否被拦截”的测试,就不会出现 validation 改错了却悄悄通过的情况。最后,这一切必须连接到 CI 的一个退出码上,才有价值。

步骤

  1. 在 /root/tfa-test/main.tf 中,接收 var.env(validation 只允许 dev、stage、prod)和 var.replicas(默认 1,只允许 1–10),放入向 out/app-<env>.conf 写入 name= 和 replicas= 两行的 local_file.conf(占位符为环境名称),并声明 name、conf_path、replicas 三个输出。执行 init,并用 tofu validate 确认。
  2. 在 /root/tfa-test/tests/basic.tftest.hcl 中用文件级 variables 放入 env = "dev",并在 command = plan 的 run "name_is_prefixed" 中断言输出 name 是否为 app-dev。只过滤这个文件运行 tofu test,并把输出保存到 /root/tfa-test/results/basic.txt。
  3. 在 /root/tfa-test/tests/apply.tftest.hcl 中,以 env = "stage"、replicas = 3 放入 command = apply 的 run "file_is_written",并断言 file(output.conf_path) 的内容恰好是 name=app-stage 和 replicas=3 两行。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/apply.txt。
  4. 在 /root/tfa-test/tests/chain.tftest.hcl 中放入 command = apply 的 run "make_dev",以及引用该 run 的输出来决定值、command = plan 的 run "reuse_previous_output"。第二个 run 断言输出 name 是否为 app-prod。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/chain.txt。
  5. 在 /root/tfa-test/tests/validation.tftest.hcl 中放入以 env = "qa" 运行的 run "bad_env_is_rejected" 和以 replicas = 99 运行的 run "too_many_replicas_is_rejected",并分别用 expect_failures 声明:由对应变量的 validation 拦截才是正常的。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/validation.txt。
  6. 在 /root/tfa-test/tests/fail.tftest.hcl 中放入一个故意写错的断言(输出 name 是否为 app-nope),并运行 tofu test。输出保存到 /root/tfa-test/results/fail.txt,退出码用一行保存到 /root/tfa-test/results/fail.rc。然后把这个文件移到 /root/tfa-test/broken/fail.tftest.hcl,使测试集重新通过。
  7. 在 /root/tfa-test/tests/mock.tftest.hcl 中放入 mock_provider "local" {},并在以 env = "prod" 运行、command = apply 的 run "mocked_apply" 中断言输出 name 是否为 app-prod。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/mock.txt。
  8. 创建 /root/tfa-test/ci.sh。它要切换到自己所在的目录,运行 tofu init -input=false(失败则以 2 退出),接着运行 tofu test,把输出保存到 /root/tfa-test/results/ci.txt,并且必须原样返回测试的退出码。创建之后运行一次,确认它以 0 退出。

参考

创建要添加测试的对象

在 /root/tfa-test/main.tf 中,接收 var.env(validation 只允许 dev、stage、prod)和 var.replicas(默认 1,只允许 1–10),放入向 out/app-<env>.conf 写入 name= 和 replicas= 两行的 local_file.conf(占位符为环境名称),并声明 name、conf_path、replicas 三个输出。执行 init,并用 tofu validate 确认。

要添加测试,首先得有适合测试的形态——要有从外部传入值的位置(变量)和取出结果的位置(输出),才能编写断言。validation 之后会成为用 expect_failures 测试的对象。

只做计划的廉价测试

在 /root/tfa-test/tests/basic.tftest.hcl 中用文件级 variables 放入 env = "dev",并在 command = plan 的 run "name_is_prefixed" 中断言输出 name 是否为 app-dev。只过滤这个文件运行 tofu test,并把输出保存到 /root/tfa-test/results/basic.txt。

测试文件在当前目录或 tests 目录中查找。run 块的 command 为 plan 时,不创建任何东西,只制定计划并做断言,所以很快——命名规则或条件分支这类只看值就够的内容,全部都用这种方式编写。

真正创建并确认的昂贵测试

在 /root/tfa-test/tests/apply.tftest.hcl 中,以 env = "stage"、replicas = 3 放入 command = apply 的 run "file_is_written",并断言 file(output.conf_path) 的内容恰好是 name=app-stage 和 replicas=3 两行。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/apply.txt。

apply 测试会真正创建,结束后工具会自己删除。所以它并不便宜,但“那个文件是否真的以那个内容生成”,只能这样确认。测试结束后,请打开 out/ 看看留下了什么。

后一个测试使用前一个测试的结果

在 /root/tfa-test/tests/chain.tftest.hcl 中放入 command = apply 的 run "make_dev",以及引用该 run 的输出来决定值、command = plan 的 run "reuse_previous_output"。第二个 run 断言输出 name 是否为 app-prod。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/chain.txt。

可以通过 run 的名称引用前一个 run 的输出。在实际工作中,“先创建网络,再用它的 ID 为应用制定计划”这类步骤相连的测试,就是这样写的。文件中的 run 从上到下依次运行。

测试应该被拦截的内容是否被拦截

在 /root/tfa-test/tests/validation.tftest.hcl 中放入以 env = "qa" 运行的 run "bad_env_is_rejected" 和以 replicas = 99 运行的 run "too_many_replicas_is_rejected",并分别用 expect_failures 声明:由对应变量的 validation 拦截才是正常的。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/validation.txt。

expect_failures 是反向表述“这个 run 必须失败才算通过”的机制。如果做好了拦截机制却不去确认它是否真的会拦截,某天把条件改错了,也没有人知道。列表中写入应该失败的对象的地址。

查看失败的测试会显示什么

在 /root/tfa-test/tests/fail.tftest.hcl 中放入一个故意写错的断言(输出 name 是否为 app-nope),并运行 tofu test。输出保存到 /root/tfa-test/results/fail.txt,退出码用一行保存到 /root/tfa-test/results/fail.rc。然后把这个文件移到 /root/tfa-test/broken/fail.tftest.hcl,使测试集重新通过。

重要的是失败消息显示了什么——它会同时显示断言所在的行,以及当时值实际是什么。因此不需要在 error_message 中重复写出值。退出码是 CI 读取的唯一信号。

模拟 provider,只测试值

在 /root/tfa-test/tests/mock.tftest.hcl 中放入 mock_provider "local" {},并在以 env = "prod" 运行、command = apply 的 run "mocked_apply" 中断言输出 name 是否为 app-prod。把只过滤这个文件运行的输出保存到 /root/tfa-test/results/mock.txt。

mock provider 不会真正创建,而是返回看似合理的值。在测试缓慢或需要花钱的 provider 时价值很大。不过因为是模拟,无法确认“那个文件是否真的会生成”——它与第 3 步的真正 apply 测试的作用不同。

打包成供 CI 调用的一行命令

创建 /root/tfa-test/ci.sh。它要切换到自己所在的目录,运行 tofu init -input=false(失败则以 2 退出),接着运行 tofu test,把输出保存到 /root/tfa-test/results/ci.txt,并且必须原样返回测试的退出码。创建之后运行一次,确认它以 0 退出。

CI 读取的信号只有退出码一个。测试失败了,脚本却以 0 结束,流水线就会亮着绿灯通过——比没有测试更糟。评分器会在副本中运行这个脚本两次:一次是保持现状,一次是加入一个会失败的测试之后。