基础设施代码也要有测试
一句话总结
.tftest.hcl 让你用同一套语法编写便宜的测试(只做计划)和昂贵的测试(真正创建资源)。关键在于把两者混合使用的感觉。
为什么需要它——模块变成改不动的过程
刚创建模块时,调用它的地方只有一个。改完运行一下就结束了。半年之后,有十二个地方在调用它。这时每改一行,都要确认这十二个地方会变成什么样,但没有那么多时间,于是大家选择了另一条路——不去改它。新需求来了,不是修改模块,而是在旁边再做一个相似的模块。就这样模块越来越多,没人知道哪个是最新的。
测试就是打破这个恶性循环的机制。因为它把“改了这个值,哪里会坏”变成一条命令。不过,基础设施代码的测试还有另一个烦恼:必须真正创建才知道的内容和只看值就知道的内容混在一起。
工作原理
测试文件使用 .tftest.hcl 扩展名,在当前目录或 tests 目录中查找。文件中按顺序放入 run 块。
variables { # 이 파일의 모든 run 이 쓰는 기본 입력
env = "dev"
}
run "name_is_prefixed" {
command = plan # 아무것도 만들지 않는다
assert {
condition = output.name == "app-dev"
error_message = "이름 규칙이 깨졌습니다"
}
}
command = plan 与 command = apply 的区别是关键。只做计划的测试很快,又不创建任何东西,所以命名规则、条件分支、默认值、计算得出的值这些只看值就够的内容都能覆盖。真正创建资源的测试很慢,还要消耗资源,但“那个文件是否真的以那个内容生成”,只有这种方法才能知道。结束后,工具会删除自己创建的东西。
run 可以串联。后面的 run 通过名称引用前面 run 的输出。“先创建网络,再用它的 ID 为应用制定计划”这样的步骤就是这样表达的。
expect_failures 是反向表述的机制。它声明这个 run 必须失败才算通过。在测试 validation 是否真的会拦截、错误输入是否真的会被拒绝时使用。如果做好了拦截机制却不去确认,某天把条件改错了,也没有人知道。
mock_provider 模拟 provider。它不会真正创建,而是返回看似合理的值,所以在测试缓慢或需要花钱的 provider 时价值很大。但因为是模拟,无法确认“是否真的会那样生成”。必须明确,它与真正的 apply 测试的作用不同。
失败时的消息也值得一看。它会同时显示断言所在的行,以及当时值实际是什么。因此不需要在 error_message 中重复写出值。
在现场相遇的样子
最常见的事故是CI 脚本吞掉了退出码。测试失败了,脚本却以 0 结束,流水线就会亮着绿灯通过。这比没有测试更糟——因为没有人确认,却让人相信已经确认过了。所以写好脚本之后,必须故意加入一个会失败的测试,看看红灯是否会亮。
第二种是只写昂贵的测试。如果全部写成 apply,一轮测试就会变长,变长之后人们就不会去运行。把大部分改成 plan,只保留必须真正创建的少数几个,才是实际工作中的平衡。
第三种是测试留下的残留物。工具虽然会删除它创建的东西,但如果测试中途崩溃,可能会残留。如果在共用账号上运行测试,在名称中加入区分标记更安全。
还有一个容易忘记的事实——测试文件也是代码。它要接受评审,与模块放在同一个仓库里,修改模块时一并修改。如果测试放在模块之外的某个地方,半年后就会没有人去运行它。
第四种是没有决定不测试什么。如果重复测试 provider 已经保证的内容(放入一个值,这个值就会进去),只会增加测试的数量,却毫无收获。基础设施代码的测试要守护的是我们自己制定的规则——命名规则、按环境的分支、不允许的输入、层与层之间的连接。测试对象是我们的决定,而不是 provider 的行为。
最后,记住测试是让模块可以被修改的机制,优先级就清楚了。最先添加测试的地方,是被调用最多的模块,其中又是最近出过事故的地方。如果以完美覆盖为目标,就无法起步,而为出过事故的那个地方添加一个测试,今天就能做到。
下一项实验要做什么
在 /root/tfa-test 中创建一个带有两个变量和三个输出的模块,分别为它添加只做计划的测试和真正创建资源的测试。把 run 串联起来,把前一个结果作为下一个输入,用 expect_failures 确认 validation 会拦截,运行一个故意失败的测试来查看消息和退出码,用 mock_provider 只确认值,最后打包成供 CI 调用的脚本,确认通过与失败两个方向都能正确结束。