每次改模块都手动检查,直到漏掉了一个
目标
创建一个适合测试的模块,分别编写只做计划的廉价测试和真正创建资源的昂贵测试,把 run 串联起来,测试应该被拦截的内容是否真的被拦截,查看失败会给出什么消息,用 mock 只确认值,最后打包成供 CI 调用的一行命令。
为什么重要
可复用的模块,调用它的地方越多,就越让人不敢修改。因为每次都要靠人去确认“改了这个值,哪里会坏”。加上测试之后,这个确认就变成了一条命令,从那一刻起,模块才可以被修改。关键是把测试分成两类来用的感觉——只做计划的测试又快又便宜,命名规则、条件分支、默认值这类只看值就够的内容都能覆盖;真正创建资源的测试虽然慢,却是确认“它是否真的会那样生成”的唯一方法。再加上确认“应该被拦截的内容是否被拦截”的测试,就不会出现 validation 改错了却悄悄通过的情况。最后,这一切必须连接到 CI 的一个退出码上,才有价值。
步骤
- 在
/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确认。 - 在
/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。 - 在
/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。 - 在
/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。 - 在
/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。 - 在
/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,使测试集重新通过。 - 在
/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。 - 创建
/root/tfa-test/ci.sh。它要切换到自己所在的目录,运行tofu init -input=false(失败则以 2 退出),接着运行tofu test,把输出保存到/root/tfa-test/results/ci.txt,并且必须原样返回测试的退出码。创建之后运行一次,确认它以 0 退出。
参考
- Pod 中有 OpenTofu 1.9.0 以及 local provider 的 mirror,无需联网即可运行。tofu test 在离线状态下也能直接工作。
- 测试文件在当前目录或 tests 目录中查找。要只运行特定文件,请使用 filter 选项。
- 以 apply 运行的 run 会真正创建资源,结束后由工具删除。请检查 out/,确认测试没有留下残留物。
- 常见错误:CI 脚本吞掉了测试的退出码。亮着绿灯通过的流水线,比没有测试更糟。
- Command: test · Command: validate · Input Variables
创建要添加测试的对象
在 /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 结束,流水线就会亮着绿灯通过——比没有测试更糟。评分器会在副本中运行这个脚本两次:一次是保持现状,一次是加入一个会失败的测试之后。