「测试框架与 Terratest」

系统讲解 Terraform 基础设施代码的测试体系:terraform test 原生测试框架与断言写法、Terratest 与 Go 集成测试、单元与集成测试的边界划分、plan 断言、资源清理与成本控制,以及 CI 中的分层测试策略。

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隔离慢资源与外部依赖,让单元测试可离线跑
TerratestGo 驱动真实 apply,验证资源「创建后能用」
plan 断言show -json + jq / OPA,免费的计划级验证
资源清理defer destroy、唯一前缀、TTL 标签、定时巡检
CI 分层PR 跑单元,合并后跑集成,独立账号 + 预算告警

一句话收尾:测试是基础设施代码从「个人手稿」走向「团队资产」的通行证。当 plan 断言、mock 单元测试与 Terratest 集成测试各就各位,每一次 apply 都建立在被验证过的假设之上,而不是对云厂商 API 的一厢情愿。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. 模块注册表与分发:版本、文档与测试
  3. DNS 与证书编排:托管区域与自动验证