Terraform Equivalence Testing 指南:用 E2E 快照对比守护命令输出行为
【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraform
导读
Equivalence testing(等价性测试)是 Terraform 仓库中一套独立于普通单元测试的端到端(E2E)测试体系,它通过固定运行环境下的 Terraform CLI 命令输出快照,验证「代码库变更不会导致命令输出以非预期方式发生变化」。本文将基于 testing/equivalence-tests/README.md 与其配套的 40+ 组真实测试用例、CI 工作流文件,完整讲解该框架的定位、目录组织、测试用例编写格式、本地diff/update运行方式以及 CI 自动化闭环,帮助你快速理解、运行乃至新增等价性测试用例。
Equivalence Testing 是什么:为什么 Terraform 需要它
Terraform 是一个将 API 声明式编排为配置文件的 IaC 工具,其输出(如plan、state、机器可读的plan.json)会被大量下游工具与自动化流程消费。当核心代码被重构、升级时,维护者最担心的问题是:逻辑没变,但输出悄悄变了。
Equivalence testing 正是为这一问题设计的。按仓库 README 的定义,它是一组 E2E 测试,用于验证Terraform 命令的输出不会以意外方式改变——测试通过「代码库变更前后各运行一次 Terraform 命令,然后对比输出」来判断行为是否保持一致。
与传统单元测试断言「某个函数返回值是否符合预期」不同,等价性测试断言的是**「变更前后输出是否等价」**,因此它:
- 覆盖
terraform plan、terraform apply等完整命令链路,而非单个内部函数; - 产出可被 diff 的基准文件(golden files),供人审阅;
- 天然适合捕捉重构导致的格式化、排序、序列化层面的隐性回归。
需要特别说明的是,等价性测试框架本身是独立于本仓库的工程,由 github.com/hashicorp/terraform-equivalence-testing(说明:该链接指向外部独立仓库,需另行下载二进制使用)提供,本仓库testing/equivalence-tests/目录只承载测试用例与基准输出。
目录结构:测试用例与基准输出的分离
从仓库目录布局看(testing/equivalence-tests),该体系由两个平级子目录构成,职责非常清晰:
testing/equivalence-tests/ ├── README.md # 本文主体文档 ├── tests/ # 测试用例定义(每个用例一个独立子目录) │ ├── basic_list/ │ ├── basic_list_update/ │ ├── drift_simple/ │ ├── moved_simple/ │ ├── ... └── outputs/ # 基准输出 golden files(与 tests 一一对应) ├── basic_list/ ├── basic_list_update/ ├── ...tests/存放每个测试的输入配置:.tf配置、.tfstate初始状态、spec.json元数据等;outputs/存放该测试的参考输出,例如 outputs/basic_list 目录下包含:
outputs/basic_list/ ├── plan # 人类可读的 plan 文本 ├── plan.json # 机器可读的 JSON plan ├── state # 人类可读的 state 文本 ├── state.json # 机器可读的 JSON state └── apply.json # apply 输出的 JSON 表达测试运行时,框架会用「当前构建的 Terraform 二进制」重新执行命令,产出新的输出并与outputs/下的基准逐项 diff,任何差异都会被报告。
从用例命名即可看出这套体系覆盖的场景矩阵非常广,按主题可归纳为:
- 基础类型与集合语义:
basic_list、basic_map、basic_set、basic_json_string_update、basic_multiline_string_update、nested_list、nested_map、nested_objects、nested_set、simple_object及各自的_empty/_null/_update变体; - 生命周期行为:
fully_populated_complex及其_destroy、_update; - 漂移(drift)处理:
drift_simple、drift_relevant_attributes、drift_refresh_only; - 资源迁移(moved):
moved_simple、moved_with_drift、moved_with_refresh_only、moved_with_update; - 本地 provider 与 null provider:
local_provider_basic、local_provider_update、local_provider_delete、null_provider_update、null_provider_delete; - 结构替换(replacement):
replace_within_list/map/object/set、simple_object_replace; - 其他:
data_read(数据源读取)、multiple_block_types、variables_and_outputs。
这些用例共同覆盖了 plan 中 replace(替换)、update-in-place(就地更新)、destroy(销毁)等动作的输出表达。
测试用例的构成:一份用例由哪些文件组成
以 tests/basic_list 为例,一个典型的用例目录包含:
tests/basic_list/ ├── main.tf # Terraform 配置 ├── spec.json # 测试元数据 └── dynamic_resources.json # 动态 Provider Schema 定义1.main.tf:测试配置
测试配置使用 HashiCorp 官方的tfcoremock(核心 mock)provider 来构造资源,无需真实云厂商。配置内容如下:
terraform { required_providers { tfcoremock = { source = "hashicorp/tfcoremock" version = "0.1.1" } } } provider "tfcoremock" {} resource "tfcoremock_list" "list" { id = "985820B3-ACF9-4F00-94AD-F81C5EA33663" list = [ "9C2BE420-042D-440A-96E9-75565341C994", "3EC6EB1F-E372-46C3-A069-00D6E82EC1E1", "D01290F6-2D3A-45FA-B006-DAA80F6D31F6", ] }可见测试资源的id与集合元素都采用固定的 UUID 风格字符串,目的是让输出确定性可复现,从而让 diff 具备意义。
2.dynamic_resources.json:为 mock provider 声明资源 Schema
该文件定义了tfcoremock_list这类资源的属性结构,例如声明一个名为list的可选属性,其元素类型为 string:
{ "tfcoremock_list": { "attributes": { "list": { "type": "list", "optional": true, "list": { "type": "string" } } } } }借助这份 JSON,等价性测试可以在不依赖任何真实 provider 的情况下,构造出任意嵌套深度(list/map/set/object)的资源类型,这正是nested_list、nested_set、replace_within_object等复杂用例得以成立的基础。
3.spec.json:测试元数据
每个用例的根目录都有一份spec.json,用于声明描述、额外携带文件与忽略字段。以 tests/basic_list/spec.json 为例:
{ "description": "basic test covering creation of a single list", "include_files": [], "ignore_fields": {} }三个字段含义如下:
description:用例的意图描述,供审阅者理解该用例覆盖的行为;include_files:需要随测试一同携带的附加文件列表(例如某些 provider 需要额外 schema 文件),空数组表示不需要;ignore_fields:声明需要忽略对比的字段路径集合,用于容忍输出中天然不稳定的字段(如随机 ID、时间戳)。当字段不可控时必须在此处显式忽略,否则测试会误报。
4.terraform.tfstate:更新/漂移类用例的初始状态
对于_update、drift_*、moved_*这类需要「先有存量再变更」的用例,目录中还会出现一份预置的 terraform.tfstate,例如 tests/basic_list_update 的用例先用旧状态的三个元素(9C2BE420…、3EC6EB1F…、D01290F6…)出发,再通过main.tf中新增/删除元素来驱动输出变化:
{ "version": 4, "terraform_version": "1.3.6", "serial": 1, "resources": [ { "mode": "managed", "type": "tfcoremock_list", "name": "list", "provider": "provider[\"registry.terraform.io/hashicorp/tfcoremock\"]", "instances": [ { "schema_version": 0, "attributes": { "id": "985820B3-ACF9-4F00-94AD-F81C5EA33663", "list": [ "9C2BE420-042D-440A-96E9-75565341C994", "3EC6EB1F-E372-46C3-A069-00D6E82EC1E1", "D01290F6-2D3A-45FA-B006-DAA80F6D31F6" ] }, "sensitive_attributes": [] } ] } ], "check_results": null }对比 tests/basic_list_update/main.tf 可以发现配置中移除了3EC6EB1F-…元素并新增了9B9F3ADF-…元素,从而精确构造出一个「集合元素增删」的 diff 场景。variables_and_outputs用例则额外使用.tfvars文件注入变量,覆盖变量与输出交互的输出表达。
本地运行:diff 与 update 命令
按 README 说明,执行测试前需要先从terraform-equivalence-testing工程下载对应平台的可执行二进制,然后运行其中的diff或update命令。
diff:对比当前与基准
diff命令执行测试并输出「当前运行结果」与「上一轮基准输出」之间的全部差异。它用于回答「这次代码改动是否改变了命令输出」。
update:刷新基准
update命令执行测试,并将新的运行结果写回outputs/基准文件。它用于确认「输出变化是预期内的」之后,把新输出固化为新的参照标准。
从仓库 CI 脚本 equivalence-test-diff.yml 可以看到框架二进制的下载与调用细节(当前仓库 CI 固定使用的框架版本为0.5.0,目标平台linux/amd64):
# 1) 下载测试框架二进制 ./.github/scripts/equivalence-test.sh download_equivalence_test_binary \ 0.5.0 \ ./bin/equivalence-tests \ linux \ amd64 # 2) 用当前源码构建 Terraform 二进制 ./.github/scripts/equivalence-test.sh build_terraform_binary ./bin/terraform # 3) 运行等价性测试(diff 模式) ./bin/equivalence-tests diff \ --tests=testing/equivalence-tests/tests \ --goldens=testing/equivalence-tests/outputs \ --binary=$(pwd)/bin/terraform关键的三个 CLI 参数:
--tests:指向testing/equivalence-tests/tests测试用例目录;--goldens:指向testing/equivalence-tests/outputs基准输出目录;--binary:指向本次被测的 Terraform 可执行文件(通常由 CI 用当前 PR 的源码现构建)。
此外从该 workflow 还可以得知框架的退出码语义:
| 退出码 | 含义 | CI 处理 |
|---|---|---|
0 | 输出与基准一致 | 无任何额外动作(对 PR 作者完全透明) |
1 | 测试失败 | 在 PR 上评论并令任务失败 |
2 | 输出有变化 | 在 PR 上评论提示「等价性测试将被更新,请人工核实」 |
这也印证了 README 中「如果框架未检测到变化,整个过程对 PR 作者不可见」的描述——只有当退出码非零(1 或 2)时 CI 才通过gh pr comment在 PR 上留下评论。
CI 自动化闭环:PR 生命周期中的两种模式
README 明确指出等价性测试由 Terraform 的 CI 系统在每个 PR 打开时与每个 PR 关闭时自动执行,仓库中对应的 workflow 文件完整落实了这一设计。
PR 打开/更新时:运行 diff 并评论
equivalence-test-diff.yml 监听pull_request的opened、synchronize、ready_for_review、reopened事件:
- checkout 源码 → 安装 Go 工具链;
- 下载框架二进制、用当前源码构建 Terraform;
- 执行
equivalence-tests diff并捕获退出码写入GITHUB_OUTPUT; - 退出码为
1:在 PR 上评论失败并链接到 CI run,任务以失败告终,PR 作者应核实变更并确保 diff 符合预期; - 退出码为
2:在 PR 上评论「等价性测试将被更新,请核实变更」; - 退出码为
0:静默通过,不留任何评论。
PR 合并到发布分支时:运行 update 并开启新 PR
equivalence-test-update.yml 基于pull_request_target的closed事件,并且加了「仅当 PR 已合并,且目标分支为main或形如vX.Y的发布分支时才执行」的前置判断:
merged='${{ github.event.pull_request.merged }}' target_branch='${{ github.event.pull_request.base.ref }}' targets_release_branch=false if [ "$target_branch" == "main" ]; then targets_release_branch=true elif [ "$target_branch" =~ ^v[0-9]+\.[0-9]+$ ]; then targets_release_branch=true fi满足条件后,任务调用 equivalence-test action(由 .github/scripts/equivalence-test.sh 实现核心逻辑)执行update,并自动基于合并目标分支创建名为equivalence-testing/<PR head 分支名>的新分支与新 PR,把更新后的基准文件提交上去,同时将合并者(merged_by)设为 reviewer:
new-branch: equivalence-testing/${{ github.event.pull_request.head.ref }} reviewers: ${{ github.event.pull_request.merged_by.login }} message: "Update equivalence test golden files after ${{ github.event.pull_request.html_url }}."这个自动开启的 PR 需要人工审阅——即 README 中强调的「PR 作者应在合并自动化 PR 前复核变更是否符合预期」。注意该模式默认假设「合并到发布分支的变更大多会改变输出」,因此用自动updatePR 承接基准刷新,把决策权交还给人类评审者。
手动触发:equivalence-tests-manual
除自动流程外,equivalence-test-manual-update.yml 提供了workflow_dispatch手动触发入口,用于对任意指定分支执行基准更新。它接收三个输入:
| 输入 | 是否必填 | 说明 |
|---|---|---|
target-branch | 是 | 要对哪个分支执行更新 |
new-branch | 是 | 为本次结果创建的新分支名 |
equivalence-test-version | 是(默认0.5.0) | 使用的框架版本(不带v前缀,如0.5.0) |
该 action 同样会创建一个承载更新后 golden files 的新 PR 供人审阅,适合在无法直接使用自动流程(如历史分支补跑)时使用。
编写新测试用例的指南
按 README 的要求,新增测试应写入tests目录,每个测试放在一个独立的子目录中,并遵循 equivalence testing 框架自身的规范。可以对照仓库中已有的用例来落地,具体步骤:
- 在
testing/equivalence-tests/tests/下新建一个语义清晰的目录,命名建议遵循既有约定:<行为主题>[_update|_empty|_null|_replace|_drift...]; - 编写
main.tf,声明tfcoremockprovider(版本与现有用例保持一致,如0.1.1),并用固定 UUID 值构造资源与集合元素,保证输出确定性; - 若用到
tfcoremock之外的属性结构,编写dynamic_resources.json声明资源 Schema;复杂用例(list/map/set/object 嵌套或替换)可参考 nested_set、replace_within_object 等现成写法; - 编写
spec.json,认真填写description;若配置中引入了不稳定字段(随机数、时间等),务必通过ignore_fields显式忽略; - 若用例需要先有存量资源(更新、漂移、迁移场景),预置一份
terraform.tfstate,并让main.tf与之形成预期的差异; - 先本地用
diff模式验证行为,确认差异符合预期后,再切换到update模式把基准写入outputs/对应目录。
新用例放入tests目录后,会被 CI 系统自动发现并纳入上述 PR 生命周期流程(equivalence-test-diff.yml 以整个testing/equivalence-tests/tests为扫描根),无需额外注册。
写在最后:输出变更的三类结局
综合 README 与仓库 CI 实现,一次代码变更对等价性测试的影响收敛为三种结局,开发者可以据此快速定位自己 PR 的处境:
- 输出无变化(退出码 0):CI 静默通过,作者无需任何操作;
- 输出有预期变化(退出码 2 / diff 有差异):CI 评论提示,作者应人工审视 diff,确认是本次改动带来的合理输出变化;
- 输出变化需固化(PR 合并后自动 update):CI 自动开启「golden files 更新」PR,作者评审后合并即可完成新一轮基准固化。
整套机制的精华在于:把「人类判断输出变化是否合理」这一不可自动化的环节保留给评审,而把「运行、对比、生成差异、提交基准」这类可重复劳动全部自动化。对于正在为 Terraform 贡献代码的开发者,当你的 PR 收到等价性测试评论时,检查 testing/equivalence-tests/outputs 对应目录下的 diff,确认输出变化符合预期即可——这就是该机制希望达到的协作形态。
【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考