为什么 IaC 需要测试?
基础设施代码的特殊挑战
传统软件可以在本地运行单元测试,毫秒内得到反馈。但基础设施代码的问题是:不运行就无法真正验证。一段 Terraform 代码在语法上完全正确,但运行时可能因为 AMI ID 不存在、IAM 权限不足或 CIDR 冲突而失败。
更危险的是,Terraform 代码的错误代价高昂:一次错误的 apply 可能导致生产数据库被删除、安全组规则被清空或负载均衡器被意外替换(触发零停机部署失败)。
IaC 测试金字塔
实践原则:大量底层测试(静态分析 + 单元测试),少量顶层测试(集成测试只针对核心路径)。
第一层:语法与格式检查
terraform fmt — 统一代码格式
terraform fmt 使用 Terraform 官方格式规范格式化 .tf 文件,类似于 Go 语言的 gofmt。它解决了团队协作中代码风格不一致的问题。
# 格式化当前目录(递归处理子目录)
terraform fmt -recursive
# 仅检查是否需要格式化(不修改文件,用于 CI)
# 返回非零退出码表示存在格式问题
terraform fmt -check -recursive
# 查看具体的格式差异(类似 git diff)
terraform fmt -diff -check -recursive
将 terraform fmt -recursive 加入 Git pre-commit hook,确保每次提交前代码都已格式化,避免 CI 中因格式问题失败。
# .git/hooks/pre-commit
#!/bin/bash
terraform fmt -check -recursive
if [ $? -ne 0 ]; then
echo "请先运行 terraform fmt -recursive 格式化代码"
exit 1
fi
terraform validate — 语法与逻辑验证
terraform validate 在不访问云 API 的情况下验证 Terraform 配置的语法正确性和内部一致性。它会检查:
aws_instance.web.non_existent_attr 会报错。# 必须先 init(下载 provider)才能 validate
terraform init -backend=false # -backend=false 跳过 backend 初始化(适合 CI)
terraform validate
# 输出 JSON 格式(方便程序解析)
terraform validate -json
terraform validate 是纯离线验证,无法检查:资源 ID 是否存在(如 AMI ID、Subnet ID)、IAM 权限是否足够、区域是否支持特定实例类型、资源命名是否冲突。这些需要 plan 或实际运行才能发现。
第二层:静态分析工具
TFLint — Terraform 专用 Linter
TFLint 在 terraform validate 的基础上增加了更丰富的规则检查,特别是 AWS/Azure/GCP Provider 特定的规则(如废弃的实例类型、不存在的 AMI region 等)。
# macOS 安装
brew install tflint
# 初始化(下载 Provider 插件)
tflint --init
# 运行检查
tflint
# 递归检查所有模块
tflint --recursive
# 指定格式输出
tflint --format=json
TFLint 使用配置文件 .tflint.hcl 控制规则:
# .tflint.hcl — TFLint 配置
plugin "aws" {
enabled = true
version = "0.28.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
# 规则配置
rule "aws_instance_invalid_type" {
enabled = true # 检测不合法的实例类型(如 t1.micro 已废弃)
}
rule "aws_instance_previous_type" {
enabled = true # 警告使用了上一代实例类型(建议升级)
}
rule "terraform_naming_convention" {
enabled = true # 强制命名规范(下划线分隔,小写)
}
rule "terraform_documented_variables" {
enabled = true # 变量必须有 description
}
rule "terraform_documented_outputs" {
enabled = true # output 必须有 description
}
rule "terraform_required_version" {
enabled = true # terraform 块必须指定版本约束
}
rule "terraform_required_providers" {
enabled = true # required_providers 必须声明 source 和 version
}
Checkov — 安全与合规扫描
Checkov 是 Bridgecrew(现为 Prisma Cloud)开源的策略即代码工具,内置 1000+ 安全规则,扫描 Terraform 代码中的安全配置错误。
# 安装
pip install checkov
# 扫描当前目录
checkov -d .
# 只扫描 Terraform 文件
checkov -d . --framework terraform
# 扫描单个文件
checkov -f main.tf
# 输出 JSON(CI 集成用)
checkov -d . --output json --output-file checkov-results.json
# 跳过特定规则(有合理原因时)
checkov -d . --skip-check CKV_AWS_18,CKV_AWS_21
在代码中针对特定资源跳过规则(需注释说明原因):
resource "aws_s3_bucket" "public_assets" {
bucket = "my-public-assets"
# checkov:skip=CKV_AWS_21: 公共静态资源不需要版本控制,降低成本
# checkov:skip=CKV_AWS_18: 公共 Bucket 访问日志已在 CDN 层记录
}
Trivy — 漏洞与配置综合扫描
# 安装(macOS)
brew install aquasecurity/trivy/trivy
# 扫描 Terraform 配置(IaC 模式)
trivy config .
# 同时扫描 Docker 镜像(如果 Terraform 代码中引用了容器)
trivy image nginx:latest
# 严格模式:有任何 HIGH/CRITICAL 风险就返回非零退出码
trivy config . --exit-code 1 --severity HIGH,CRITICAL
第三层:terraform test(内置单元测试,v1.6+)
什么是 terraform test?
Terraform 1.6 引入了内置测试框架,允许用 .tftest.hcl 文件编写测试,在不创建真实资源的情况下(使用 mock)验证模块逻辑,或者用真实 Provider 进行轻量集成测试。
# tests/vpc.tftest.hcl — 测试 VPC 模块
# 使用 mock_provider 替代真实 AWS(不产生费用,秒级运行)
mock_provider "aws" {
mock_resource "aws_vpc" {
defaults = {
id = "vpc-mock12345"
arn = "arn:aws:ec2:us-east-1:123456789:vpc/vpc-mock12345"
cidr_block = "10.0.0.0/16"
}
}
}
# 测试用例 1:验证 VPC 使用了正确的 CIDR
run "vpc_uses_correct_cidr" {
command = plan # plan 模式:只验证 plan 输出,不真正创建
variables {
vpc_cidr = "10.0.0.0/16"
environment = "test"
}
assert {
# 验证 VPC CIDR 与输入变量一致
condition = aws_vpc.main.cidr_block == var.vpc_cidr
error_message = "VPC CIDR 应与输入变量一致,实际为 ${aws_vpc.main.cidr_block}"
}
assert {
# 验证 VPC 有 Name 标签
condition = contains(keys(aws_vpc.main.tags), "Name")
error_message = "VPC 必须有 Name 标签"
}
}
# 测试用例 2:验证生产环境启用了 DNS 支持
run "prod_vpc_has_dns_enabled" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
environment = "prod"
}
assert {
condition = aws_vpc.main.enable_dns_hostnames == true
error_message = "生产环境 VPC 必须启用 DNS Hostnames"
}
}
# 测试用例 3:验证非法 CIDR 会被拒绝(测试变量验证逻辑)
run "invalid_cidr_rejected" {
command = plan
variables {
vpc_cidr = "256.0.0.0/8" # 非法 IP 地址
}
# 期望这个 plan 失败(expect_failures 声明期望失败)
expect_failures = [var.vpc_cidr]
}
# 运行所有测试(在 terraform/ 目录下)
terraform test
# 只运行指定测试文件
terraform test -filter=tests/vpc.tftest.hcl
# 详细输出(显示每个 run 块的 plan/apply 日志)
terraform test -verbose
# 测试输出示例:
# tests/vpc.tftest.hcl... in progress
# run "vpc_uses_correct_cidr"... pass
# run "prod_vpc_has_dns_enabled"... pass
# run "invalid_cidr_rejected"... pass
# tests/vpc.tftest.hcl... tearing down
# tests/vpc.tftest.hcl... pass
# Success! 3 passed, 0 failed.
terraform test 的测试生命周期
第四层:Terratest — 真实部署集成测试
Terratest 的设计理念
Terratest 是 Gruntwork 开发的 Go 测试库。它的核心理念是:基础设施的终极验证是实际运行。Terratest 测试会真正在云账号中创建资源,发送 HTTP 请求验证服务可访问性,然后销毁所有资源。
// test/web_module_test.go — 测试一个 Web 应用模块
package test
import (
"fmt"
"testing"
"time"
"net/http"
// Terratest 核心模块
"github.com/gruntwork-io/terratest/modules/terraform"
// AWS 辅助函数
"github.com/gruntwork-io/terratest/modules/aws"
// HTTP 辅助函数(重试、等待)
"github.com/gruntwork-io/terratest/modules/http-helper"
// 断言库
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWebAppModule(t *testing.T) {
// t.Parallel() 允许多个测试并行运行,缩短总时间
t.Parallel()
// 随机生成唯一前缀,避免多次测试资源名称冲突
uniqueId := fmt.Sprintf("test-%d", time.Now().UnixNano())
// 配置 Terraform Options
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
// 指向模块所在目录
TerraformDir: "../modules/web-app",
// 传入变量(相当于 -var)
Vars: map[string]interface{}{
"app_name": uniqueId,
"instance_type": "t3.micro", // 测试用最小实例
"environment": "test",
},
// 在指定 AWS 区域运行测试
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
},
})
// defer 保证无论测试结果如何,最终都会销毁资源
// 避免孤儿资源产生费用
defer terraform.Destroy(t, terraformOptions)
// 执行 terraform init + apply
terraform.InitAndApply(t, terraformOptions)
// ===== 第一步:验证 Terraform 输出值 =====
// 读取负载均衡器 URL(OutputRequired 会在输出为空时让测试失败)
albDnsName := terraform.OutputRequired(t, terraformOptions, "alb_dns_name")
assert.NotEmpty(t, albDnsName, "ALB DNS 名称不能为空")
// 读取 EC2 实例 ID
instanceId := terraform.OutputRequired(t, terraformOptions, "instance_id")
assert.Regexp(t, "^i-[0-9a-f]+", instanceId, "实例 ID 格式不正确")
// ===== 第二步:通过 AWS API 验证资源属性 =====
// 验证 EC2 实例处于运行状态
instanceState := aws.GetInstanceStateById(t, "us-east-1", instanceId)
require.Equal(t, "running", instanceState, "EC2 实例应处于 running 状态")
// ===== 第三步:通过 HTTP 验证应用可访问性 =====
appUrl := fmt.Sprintf("http://%s", albDnsName)
// http_helper.HttpGetWithRetry 在失败时自动重试(等待服务启动)
// 最多重试 30 次,每次间隔 10 秒(共等待最多 5 分钟)
http_helper.HttpGetWithRetry(
t,
appUrl,
nil, // TLS 配置(HTTP 用 nil)
200, // 期望的 HTTP 状态码
"Hello World", // 期望响应体包含的字符串
30, // 最大重试次数
10 * time.Second, // 每次重试间隔
)
// 验证健康检查端点
statusCode, body := http_helper.HttpGet(t, appUrl+"/health", nil)
assert.Equal(t, 200, statusCode)
assert.Contains(t, body, "ok")
}
# 初始化 Go 模块(第一次)
mkdir test && cd test
go mod init github.com/my-company/terraform-tests
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/gruntwork-io/terratest/modules/http-helper
go get github.com/stretchr/testify
# 运行测试(-v 显示详细日志,-timeout 设置超时时间)
go test -v -timeout 30m ./...
# 并行运行多个测试文件(需要测试内部调用 t.Parallel())
go test -v -timeout 60m -parallel 4 ./...
# 只运行指定测试函数
go test -v -run TestVpcModule ./...
- 成本控制:Terratest 在真实云账号中创建资源,即使测试后销毁,也会产生费用。建议在专用的隔离 AWS 测试账号中运行,并配置 AWS Cost Anomaly Detection 预警。
- 随机化命名:始终使用随机后缀命名资源(如
fmt.Sprintf("test-%d", time.Now().UnixNano())),避免并行测试冲突。 - 超时设置:大型 Terratest 测试(创建 EKS、RDS 等)可能需要 20-40 分钟,务必设置足够长的
-timeout。 - 清理孤儿资源:如果测试进程被强制杀死,defer Destroy 不会执行。定期使用 cloud-nuke 清理测试账号中的孤儿资源。
CI 集成:完整测试流水线
# .github/workflows/terraform-test.yml
name: Terraform Test Pipeline
on:
pull_request:
paths: ['terraform/**']
jobs:
# 第一步:静态分析(快速,无需云凭证)
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: terraform
- name: Terraform Init
run: terraform init -backend=false
working-directory: terraform
- name: Terraform Validate
run: terraform validate -no-color
working-directory: terraform
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v4
- name: TFLint
run: |
tflint --init
tflint --recursive --format=compact
working-directory: terraform
- name: Checkov Security Scan
uses: bridgecrewio/checkov-action@master
with:
directory: terraform
framework: terraform
output_format: sarif
output_file_path: checkov.sarif
# 将 Checkov 结果上传到 GitHub Security 面板
- name: Upload Checkov SARIF
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov.sarif
# 第二步:内置单元测试(需要 terraform test,秒级)
unit-test:
runs-on: ubuntu-latest
needs: static-analysis
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Terraform Test (with mock providers)
run: |
terraform init
terraform test -verbose
working-directory: terraform
env:
# 提供 mock 凭证(不访问真实 AWS)
AWS_ACCESS_KEY_ID: mock
AWS_SECRET_ACCESS_KEY: mock
AWS_DEFAULT_REGION: us-east-1
# 第三步:集成测试(只在特定条件下运行)
integration-test:
runs-on: ubuntu-latest
needs: unit-test
# 只在 PR 打上 "run-integration-tests" 标签时运行
if: contains(github.event.pull_request.labels.*.name, 'run-integration-tests')
permissions:
id-token: write # 使用 OIDC 获取临时 AWS 凭证
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/TerratestRole
aws-region: us-east-1
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Terratest Integration Tests
run: go test -v -timeout 30m ./...
working-directory: terraform/test
本章小结
- IaC 测试金字塔:从底层到顶层依次是语法验证 → 静态分析 → terraform test(mock)→ Terratest(真实云)。越底层越快,越应该大量编写。
- terraform fmt -check:在 CI 中强制代码格式一致;terraform validate:验证语法和引用正确性,但无法检查资源 ID 是否存在。
- TFLint:Provider 特定规则(废弃实例类型、命名规范、缺少 description);需要 .tflint.hcl 配置文件激活 AWS 插件规则。
- Checkov:1000+ 安全规则扫描(S3 加密、安全组规则、RDS Multi-AZ 等);可以输出 SARIF 格式集成到 GitHub Security 面板。
- terraform test(v1.6+):内置测试框架,支持 mock_provider(不访问真实 AWS),run 块 + assert 断言,expect_failures 测试错误路径,秒级运行。
- Terratest:真实部署集成测试;必须随机化资源名称防冲突;defer Destroy 清理资源;适合测试核心模块(VPC、EKS、应用部署)。