Chapter 08

测试:Terratest 与 tf-validate

为基础设施代码建立完整测试体系——从静态分析到真实部署验证

为什么 IaC 需要测试?

基础设施代码的特殊挑战

传统软件可以在本地运行单元测试,毫秒内得到反馈。但基础设施代码的问题是:不运行就无法真正验证。一段 Terraform 代码在语法上完全正确,但运行时可能因为 AMI ID 不存在、IAM 权限不足或 CIDR 冲突而失败。

更危险的是,Terraform 代码的错误代价高昂:一次错误的 apply 可能导致生产数据库被删除、安全组规则被清空或负载均衡器被意外替换(触发零停机部署失败)。

IaC 测试金字塔

IaC 测试金字塔(从底层到顶层,成本和耗时递增) ┌───────────────┐ │ E2E 测试 │ ← 完整环境验证(最贵,几小时) ├───────────────┤ │ 集成测试 │ ← Terratest(真实云资源,几分钟) ├───────────────┤ │ 单元/合同测试 │ ← terraform test(mock,秒级) ├───────────────┤ │ 静态分析 │ ← TFLint/Checkov(不运行,毫秒级) ├───────────────┤ │ 语法验证 │ ← terraform validate/fmt(最快) └───────────────┘ 越底层:越快、越便宜、越应该大量编写 越顶层:越慢、越贵、越接近真实环境

实践原则:大量底层测试(静态分析 + 单元测试),少量顶层测试(集成测试只针对核心路径)。

第一层:语法与格式检查

terraform fmt — 统一代码格式

terraform fmt 使用 Terraform 官方格式规范格式化 .tf 文件,类似于 Go 语言的 gofmt。它解决了团队协作中代码风格不一致的问题。

# 格式化当前目录(递归处理子目录)
terraform fmt -recursive

# 仅检查是否需要格式化(不修改文件,用于 CI)
# 返回非零退出码表示存在格式问题
terraform fmt -check -recursive

# 查看具体的格式差异(类似 git diff)
terraform fmt -diff -check -recursive
在 pre-commit hook 中自动格式化

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 配置的语法正确性和内部一致性。它会检查:

语法错误
HCL 语法错误,如缺少括号、引号不匹配等。
引用错误
引用了不存在的资源属性、变量或 local 值。如 aws_instance.web.non_existent_attr 会报错。
类型错误
变量类型与实际传入值不匹配,如 number 类型变量传入了字符串。
必填参数缺失
Provider 资源的必填字段(required attribute)未指定时报错。
# 必须先 init(下载 provider)才能 validate
terraform init -backend=false   # -backend=false 跳过 backend 初始化(适合 CI)
terraform validate

# 输出 JSON 格式(方便程序解析)
terraform validate -json
validate 不能验证的内容

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 代码中的安全配置错误。

CKV_AWS_18
S3 Bucket 必须启用访问日志。未记录访问日志的 S3 Bucket 无法追溯数据泄露路径。
CKV_AWS_21
S3 Bucket 必须启用版本控制。没有版本控制的 Bucket 一旦数据被覆盖或删除无法恢复。
CKV_AWS_23
RDS 实例必须有多可用区(Multi-AZ)。生产数据库必须有 HA 保障。
CKV_AWS_24
安全组不应允许入站 SSH(端口 22)对 0.0.0.0/0 开放。
CKV2_AWS_5
安全组必须关联到资源(孤立安全组可能被误用)。
# 安装
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 进行轻量集成测试。

run 块
一个独立的测试用例。每个 run 块可以指定 command = plan 或 command = apply,并包含多个 assert 断言。
assert 块
验证某个条件为真。condition 可以使用任何 Terraform 表达式(如检查字符串格式、长度、包含特定字段等)。
mock_provider
在不调用真实 AWS API 的情况下模拟 Provider 行为,让测试秒级完成且无需云账号。
variables 块
在测试中覆盖模块的变量值,模拟不同的输入场景(如边界值、不同环境)。
# 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 的测试生命周期

terraform test 执行流程 对于每个 .tftest.hcl 文件: 1. 初始化(加载 provider,或使用 mock_provider) 2. 按顺序执行每个 run 块: a. 解析 variables 块中的变量覆盖 b. 执行 plan(command=plan 时)或 apply(command=apply 时) c. 对每个 assert 求值,记录通过/失败 3. 自动销毁(apply 模式下创建的所有资源) 4. 报告结果 注意:多个 run 块共享 state(前一个 apply 的资源可以被后续 run 引用)

第四层:Terratest — 真实部署集成测试

Terratest 的设计理念

Terratest 是 Gruntwork 开发的 Go 测试库。它的核心理念是:基础设施的终极验证是实际运行。Terratest 测试会真正在云账号中创建资源,发送 HTTP 请求验证服务可访问性,然后销毁所有资源。

为什么用 Go?
Go 有成熟的测试框架(testing 标准库)、强大的并发支持(测试可以并行运行)、丰富的 AWS SDK,以及 Terratest 库本身提供的大量辅助函数。
InitAndApply
调用 terraform init 和 terraform apply,自动处理变量传递、超时和错误捕获。
defer Destroy
使用 Go 的 defer 机制确保即使测试失败也能清理云资源,避免孤儿资源产生费用。
Output/OutputRequired
读取 Terraform output 值(如负载均衡 URL、数据库端点),供后续验证使用。OutputRequired 在输出为空时会让测试直接失败。
// 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 费用与最佳实践

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

本章小结

本章核心要点