1. 为什么基础设施代码也要测试
一句话总结: 基础设施代码的错误代价远高于应用代码,一次误删数据库或错开安全组就是线上事故,而测试是唯一能在 apply 之前把这类错误拦下来的手段。
应用代码有单元测试、集成测试、灰度发布兜底,基础设施却常常「写完直接 apply」。terraform plan 只验证语法正确、Provider 契约匹配、依赖图可解,它不验证语义:把 RDS 的 multi_az 关掉、把子网 CIDR 写到别的 VPC 段、把安全组入口开成 0.0.0.0/0,plan 全都绿灯通过。
# 最基础的三件事:格式、语法、静态检查
terraform fmt -check -recursive
terraform validate
tflint --recursive
测试要覆盖三个层次,越往下越贵、越真:
| 层次 | 手段 | 耗时 | 跑在何时 |
|---|---|---|---|
| 静态检查 | fmt / validate / tflint / checkov | 秒级 | 每次提交 |
| 单元测试 | terraform test + mock provider、plan 断言 | 秒到十秒级 | 每个 PR |
| 集成测试 | 真实 apply 后验证资源行为 | 分钟级 | 合并后 / nightly |
一句话:基础设施测试不是「追求覆盖率」,而是「把已知的灾难场景写成断言」,让它们在流水线上自动重放。
2. terraform test 原生测试框架
一句话总结:
terraform test是 1.6 起内置的测试框架,用.tftest.hcl文件描述 run 与 assert,无需任何外部语言即可对模块做 plan 级与 apply 级验证。
测试文件与被测模块放在同一目录(或 tests/ 子目录),以 .tftest.hcl 结尾。一个文件由若干 run 块组成,每个 run 指定执行 plan 还是 apply,并用 assert 表达期望。
# tests/basic.tftest.hcl
variables {
environment = "test"
vpc_cidr = "10.42.0.0/16"
}
run "plan_creates_two_subnets" {
command = plan
assert {
condition = length(aws_subnet.private) == 2
error_message = "私有子网数量应为 2"
}
assert {
condition = aws_vpc.main.cidr_block == var.vpc_cidr
error_message = "VPC 网段应与输入一致"
}
}
2.1 用 plan 模式做零成本验证
command = plan 是最省钱的测试方式:只走 plan,不创建任何真实资源。它适合验证命名规则、标签、数量、条件分支等纯逻辑。
run "tags_are_consistent" {
command = plan
assert {
condition = aws_s3_bucket.data.tags["Environment"] == "test"
error_message = "存储桶缺少 Environment 标签"
}
}
terraform test # 跑全部 .tftest.hcl
terraform test -verbose # 打印每个 run 与断言
terraform test -filter=tests/basic.tftest.hcl
2.2 用 mock provider 隔离外部依赖
对于昂贵或不可控的资源,用 mock_provider 提供假数据,让单元测试不碰真实云、也不依赖凭证。
mock_provider "aws" {
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mock-bucket"
}
}
}
run "uses_mocked_bucket_arn" {
command = plan
assert {
condition = aws_s3_bucket.data.arn == "arn:aws:s3:::mock-bucket"
error_message = "应使用 mock 提供的 arn"
}
}
一句话:
terraform test把「HCL 写 HCL 测」变成现实,成本最低的断言应该最先写。
3. Terratest 与 Go 集成测试
一句话总结: Terratest 是用 Go 写的基础设施测试库,能真正 apply 资源、发起 HTTP 请求、查询云 API 验证行为,是「集成测试」的事实标准。
单元测试保证「配置写对了」,集成测试保证「资源真的能用」。Terratest 提供 terraform.InitAndApply、terraform.Output、http_helper 以及各云厂商的辅助包,用普通 Go 测试函数串起来。
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestWebServerServesHTTP(t *testing.T) {
t.Parallel()
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/web",
Vars: map[string]interface{}{
"environment": "test",
},
})
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
url := terraform.Output(t, opts, "instance_url")
http_helper.HttpGetWithRetry(t, url, nil, 200, "Hello", 30, 5)
}
3.1 测试目录与执行方式
约定把集成测试放在模块的 test/ 目录,用 go test 驱动;模块与测试分离,避免把 Go 依赖带进 IaC 仓库根目录。
cd test
go mod tidy
go test -v -timeout 30m ./...
go test -run TestWebServerServesHTTP -v
-timeout 30m 很关键:云资源创建动辄几分钟,Go 默认 10 分钟超时会误报失败。
3.2 常用断言工具
Terratest 的辅助包把「等待资源就绪」这类易错逻辑封装好,避免手写 sleep。
// 等待 S3 对象可读、等待 ALB 健康、等待 SSH 可连
aws.AssertS3BucketExists(t, region, bucketName)
http_helper.HttpGetWithRetryWithCustomValidation(t, url, nil, 200, validate)
ssh.CheckSshCommand(t, host, user, key, "systemctl is-active nginx")
一句话:Terratest 的价值在「真实验证」——它回答的是「资源创建后能不能用」,这是 plan 永远回答不了的问题。
4. 单元测试与集成测试的边界
一句话总结: 单元测试用 mock 与 plan 快速覆盖逻辑分支,集成测试用真实云验证端到端行为,二者成本差一个数量级,必须在流水线里分时分层执行。
| 维度 | 单元测试 | 集成测试 |
|---|---|---|
| 工具 | terraform test / plan 断言 | Terratest / 真实 apply |
| 依赖 | mock provider | 真实云账号与凭证 |
| 耗时 | 秒级 | 分钟到十几分钟 |
| 成本 | 接近零 | 按资源计费 |
| 频率 | 每个 PR | 合并后 / 每日 |
| 失败含义 | 配置逻辑错 | 环境或资源行为异常 |
4.1 什么时候必须用 mock
三类场景优先 mock:一是资源创建极慢(如 RDS 实例、EKS 集群);二是会产生持续费用;三是依赖外部账号或第三方系统。把这三类挡在单元层,集成测试只验证少数关键路径。
# 只 mock 慢资源,其余真实 plan
mock_provider "aws" {}
run "cluster_name_follows_convention" {
command = plan
assert {
condition = can(regex("^app-(dev|stg|prod)-eks$", aws_eks_cluster.main.name))
error_message = "集群命名不符合约定"
}
}
一句话:判断标准很简单——「这个断言失败了,我需要真实云才能修吗」,不需要就放单元层。
5. plan 断言与策略校验
一句话总结: 把 plan 导出成 JSON 后用 jq 或 OPA 断言,是在不创建资源的前提下验证「最终会变成什么」的最强手段,也是策略门禁的同一套数据源。
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
# 断言:本次变更不会销毁任何有状态资源
jq -e '[.resource_changes[]
| select(.change.actions | index("delete"))
| select(.type | test("aws_(db_instance|s3_bucket|rds_)"))] | length == 0' plan.json
Terraform 1.5 起还支持原生的 check 块,把不变量写进配置本身,plan 与 apply 阶段都会评估。
check "no_public_buckets" {
assert {
condition = aws_s3_bucket_acl.data.acl != "public-read"
error_message = "数据桶不允许公开读取"
}
}
# 结合 OPA 做计划级策略裁决
opa eval --data policy/ --input plan.json "data.terraform.deny"
一句话:plan 断言是「免费的集成测试」——数据已经拿在手里,只差把它断言出来。
6. 资源清理与成本控制
一句话总结: 集成测试最大的坑是「测试通过但资源没删」,必须在测试函数里注册销毁逻辑、用唯一命名避免冲突,并用标签与 TTL 兜底。
func TestModuleEndToEnd(t *testing.T) {
uniqueID := random.UniqueId()
opts := &terraform.Options{
TerraformDir: "../examples/app",
Vars: map[string]interface{}{
"name_prefix": fmt.Sprintf("tt-%s", uniqueID),
},
}
// 注册清理,即使断言失败也会执行
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
// 断言失败时也不会泄漏资源
output := terraform.Output(t, opts, "endpoint")
require.Contains(t, output, "https://")
}
三重保险减少漏网资源:
# 1. 命名前缀唯一,便于批量识别
# 2. 打上 Owner / TTL 标签,供巡检脚本清理
# 3. 每日定时巡检:删除超过 24h 的测试资源
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Owner,Values=terratest \
--query 'ResourceTagMappingList[].ResourceARN'
locals {
common_tags = {
Owner = "terratest"
TTL = "24h"
ManagedBy = "terraform-test"
}
}
一句话:清理逻辑要写在「申请资源之前」,而不是「测试之后」——因为测试失败时,后面那行永远不会执行。
7. CI 中的测试策略
一句话总结: CI 里把静态检查、单元测试、集成测试分成三个 stage,PR 只跑便宜的两层,集成测试放到合并后或夜间,用独立账号与预算上限兜底成本。
# .github/workflows/iac-test.yml
name: iac-test
on:
pull_request:
paths: ["**.tf", "**.tftest.hcl"]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
- run: terraform test
integration:
# 仅在合并到 main 后运行,使用受限测试账号
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: test-account
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.22" }
- run: cd test && go test -v -timeout 45m ./...
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TEST_AWS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TEST_AWS_SECRET }}
7.1 环境隔离与预算护栏
集成测试必须用独立账号或独立订阅,避免误删生产资源;同时设置预算告警,防止测试代码 bug 导致资源失控。
# 测试账号专用的 OIDC 角色,权限限定在沙箱 OU
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/iac-test-sandbox \
--role-session-name gha-iac-test
一句话:CI 中的测试策略本质是「成本与信心的权衡」——用便宜的层挡住 90% 的错误,用昂贵的层守住关键路径。
8. 总结
基础设施测试的目标不是覆盖率数字,而是把已知事故场景变成可重放的断言:
| 环节 | 要点 |
|---|---|
| 静态检查 | fmt / validate / tflint / checkov,秒级、每次提交 |
| terraform test | .tftest.hcl 的 run 与 assert,plan 模式零成本 |
| mock provider | 隔离慢资源与外部依赖,让单元测试可离线跑 |
| Terratest | Go 驱动真实 apply,验证资源「创建后能用」 |
| plan 断言 | show -json + jq / OPA,免费的计划级验证 |
| 资源清理 | defer destroy、唯一前缀、TTL 标签、定时巡检 |
| CI 分层 | PR 跑单元,合并后跑集成,独立账号 + 预算告警 |
一句话收尾:测试是基础设施代码从「个人手稿」走向「团队资产」的通行证。当 plan 断言、mock 单元测试与 Terratest 集成测试各就各位,每一次 apply 都建立在被验证过的假设之上,而不是对云厂商 API 的一厢情愿。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。