Terraform 測試框架與驗證

Terraform Testing Framework and Validation

在現代基礎設施即程式碼(Infrastructure as Code, IaC)的實踐中,測試已成為確保部署品質和可靠性的關鍵環節。Terraform 提供了多種測試機制,從原生測試框架到第三方整合工具,讓開發團隊能夠在部署前驗證基礎設施程式碼的正確性。本文將深入探討 Terraform 的測試框架與驗證機制。

Terraform 測試概述

為什麼需要測試基礎設施程式碼?

基礎設施程式碼與應用程式碼一樣,需要經過嚴格的測試才能確保:

測試目標說明
正確性確保資源配置符合預期
安全性驗證安全設定和權限配置
合規性確保符合組織政策和法規要求
可重複性保證相同程式碼產生一致的結果
降低風險在部署前發現潛在問題

Terraform 測試層次

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
┌─────────────────────────────────────────────────────────────┐
│                    End-to-End Tests                         │
│              (完整部署後的端對端測試)                          │
├─────────────────────────────────────────────────────────────┤
│                   Integration Tests                         │
│           (Terratest - 實際部署並驗證資源)                    │
├─────────────────────────────────────────────────────────────┤
│                     Unit Tests                              │
│        (terraform test - Mock Provider 測試)                 │
├─────────────────────────────────────────────────────────────┤
│                   Static Analysis                           │
│       (terraform validate, fmt, tflint, checkov)            │
└─────────────────────────────────────────────────────────────┘

測試策略選擇

測試類型執行速度成本信心度適用場景
Static Analysis秒級CI 每次提交
Unit Tests (Mock)秒級模組邏輯驗證
Integration Tests分鐘級中高PR 合併前
E2E Tests小時級最高發布前

原生測試框架(terraform test)

Terraform 1.6 版本引入了原生測試框架,允許您直接在 Terraform 中撰寫測試。

啟用測試功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 確認 Terraform 版本 >= 1.6
terraform version

# 執行測試
terraform test

# 指定測試目錄
terraform test -test-directory=tests

# 詳細輸出
terraform test -verbose

測試框架基本概念

原生測試框架包含以下核心組件:

  • Test Files(測試檔案):以 .tftest.hcl 結尾的檔案
  • Run Blocks(執行區塊):定義測試步驟和命令
  • Assert Blocks(斷言區塊):驗證期望結果
  • Mock Providers(模擬提供者):模擬 Provider 行為

建立第一個測試

建立測試目錄結構:

1
2
3
4
5
6
7
my-module/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
    ├── basic.tftest.hcl
    └── validation.tftest.hcl

測試檔案語法與結構

基本測試檔案範例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# tests/basic.tftest.hcl

# 定義測試用的變數
variables {
  vpc_name = "test-vpc"
  vpc_cidr = "10.0.0.0/16"
  environment = "test"
}

# 第一個測試執行區塊
run "verify_vpc_creation" {
  command = plan  # 或 apply

  # 斷言區塊
  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block does not match expected value"
  }

  assert {
    condition     = aws_vpc.main.enable_dns_hostnames == true
    error_message = "DNS hostnames should be enabled"
  }
}

# 第二個測試執行區塊
run "verify_subnet_count" {
  command = plan

  assert {
    condition     = length(aws_subnet.public) == 2
    error_message = "Expected 2 public subnets"
  }

  assert {
    condition     = length(aws_subnet.private) == 2
    error_message = "Expected 2 private subnets"
  }
}

測試命令類型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Plan 命令 - 只執行計畫,不實際建立資源
run "plan_test" {
  command = plan

  assert {
    condition     = output.vpc_id != ""
    error_message = "VPC ID should not be empty"
  }
}

# Apply 命令 - 實際建立資源
run "apply_test" {
  command = apply

  assert {
    condition     = aws_instance.web.instance_state == "running"
    error_message = "Instance should be running"
  }
}

使用 Variables 區塊

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 全域變數定義
variables {
  region      = "ap-northeast-1"
  environment = "test"
}

# 在特定 run 區塊中覆寫變數
run "test_with_custom_vars" {
  command = plan

  variables {
    environment = "staging"  # 覆寫全域變數
    instance_count = 3
  }

  assert {
    condition     = var.environment == "staging"
    error_message = "Environment should be staging"
  }
}

使用 Module 區塊

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 測試特定模組
run "test_vpc_module" {
  command = plan

  module {
    source = "./modules/vpc"
  }

  variables {
    vpc_name = "test-vpc"
    vpc_cidr = "10.0.0.0/16"
  }

  assert {
    condition     = output.vpc_id != null
    error_message = "VPC ID should be created"
  }
}

測試期望錯誤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 測試預期會失敗的情況
run "test_invalid_cidr" {
  command = plan

  variables {
    vpc_cidr = "invalid-cidr"  # 故意使用無效的 CIDR
  }

  # 期望這個測試會因為驗證規則而失敗
  expect_failures = [
    var.vpc_cidr
  ]
}

# 測試期望資源建立失敗
run "test_resource_failure" {
  command = plan

  expect_failures = [
    aws_instance.web
  ]
}

依賴其他測試執行結果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 第一個測試:建立 VPC
run "create_vpc" {
  command = apply

  variables {
    vpc_name = "dependency-test-vpc"
  }
}

# 第二個測試:依賴第一個測試的結果
run "create_subnet" {
  command = apply

  # 使用前一個測試建立的資源
  variables {
    vpc_id = run.create_vpc.vpc_id
  }

  assert {
    condition     = aws_subnet.main.vpc_id == run.create_vpc.vpc_id
    error_message = "Subnet should be in the created VPC"
  }
}

Mock Provider 使用

Mock Provider 允許您在不實際連接到雲端服務的情況下測試 Terraform 程式碼。

基本 Mock Provider 設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# tests/mock_test.tftest.hcl

# 定義 Mock Provider
mock_provider "aws" {
  alias = "mock"
}

run "test_with_mock" {
  command = plan

  providers = {
    aws = aws.mock
  }

  assert {
    condition     = aws_vpc.main.cidr_block == var.vpc_cidr
    error_message = "VPC CIDR does not match"
  }
}

自訂 Mock 資料

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 使用 mock_resource 定義模擬資源回應
mock_provider "aws" {
  mock_resource "aws_vpc" {
    defaults = {
      id                       = "vpc-mock12345"
      arn                      = "arn:aws:ec2:ap-northeast-1:123456789012:vpc/vpc-mock12345"
      enable_dns_hostnames     = true
      enable_dns_support       = true
      instance_tenancy         = "default"
      main_route_table_id      = "rtb-mock12345"
      default_network_acl_id   = "acl-mock12345"
      default_security_group_id = "sg-mock12345"
    }
  }

  mock_resource "aws_subnet" {
    defaults = {
      id                = "subnet-mock12345"
      arn               = "arn:aws:ec2:ap-northeast-1:123456789012:subnet/subnet-mock12345"
      availability_zone = "ap-northeast-1a"
    }
  }
}

run "test_with_custom_mock" {
  command = apply

  providers = {
    aws = aws.mock
  }

  assert {
    condition     = aws_vpc.main.id == "vpc-mock12345"
    error_message = "VPC ID should match mock value"
  }
}

Mock Data Source

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
mock_provider "aws" {
  # Mock data source
  mock_data "aws_availability_zones" {
    defaults = {
      names = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
      zone_ids = ["apne1-az4", "apne1-az1", "apne1-az2"]
    }
  }

  mock_data "aws_ami" {
    defaults = {
      id           = "ami-mock12345"
      architecture = "x86_64"
      name         = "mock-ami"
    }
  }
}

run "test_data_sources" {
  command = plan

  providers = {
    aws = aws.mock
  }

  assert {
    condition     = length(data.aws_availability_zones.available.names) == 3
    error_message = "Should have 3 availability zones"
  }
}

Override 檔案用於測試

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# tests/override.tf
# 這個檔案會覆寫主要配置中的 Provider 設定

provider "aws" {
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    ec2 = "http://localhost:4566"  # LocalStack endpoint
    s3  = "http://localhost:4566"
    iam = "http://localhost:4566"
  }
}

Terratest 整合測試

Terratest 是由 Gruntwork 開發的 Go 語言測試框架,用於撰寫自動化測試來驗證基礎設施程式碼。

安裝 Terratest

1
2
3
4
5
6
7
# 初始化 Go 模組
go mod init terraform-tests

# 安裝 Terratest
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/stretchr/testify/assert

基本測試結構

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// tests/vpc_test.go
package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVpcModule(t *testing.T) {
    t.Parallel()

    // Terraform 選項
    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        // 指定 Terraform 程式碼路徑
        TerraformDir: "../modules/vpc",

        // 設定變數
        Vars: map[string]interface{}{
            "vpc_name":   "terratest-vpc",
            "vpc_cidr":   "10.0.0.0/16",
            "environment": "test",
        },

        // 設定環境變數
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": "ap-northeast-1",
        },
    })

    // 測試結束後清理資源
    defer terraform.Destroy(t, terraformOptions)

    // 執行 terraform init 和 terraform apply
    terraform.InitAndApply(t, terraformOptions)

    // 取得輸出值
    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    vpcCidr := terraform.Output(t, terraformOptions, "vpc_cidr")

    // 驗證輸出值
    assert.NotEmpty(t, vpcId)
    assert.Equal(t, "10.0.0.0/16", vpcCidr)
}

進階測試範例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// tests/ec2_test.go
package test

import (
    "fmt"
    "testing"
    "time"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/http-helper"
    "github.com/gruntwork-io/terratest/modules/retry"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestEc2Instance(t *testing.T) {
    t.Parallel()

    // 隨機產生唯一名稱避免衝突
    uniqueId := random.UniqueId()
    instanceName := fmt.Sprintf("terratest-%s", uniqueId)

    awsRegion := "ap-northeast-1"

    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../modules/ec2",
        Vars: map[string]interface{}{
            "instance_name": instanceName,
            "instance_type": "t3.micro",
        },
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    })

    defer terraform.Destroy(t, terraformOptions)

    terraform.InitAndApply(t, terraformOptions)

    // 取得 EC2 執行個體 ID
    instanceId := terraform.Output(t, terraformOptions, "instance_id")
    publicIp := terraform.Output(t, terraformOptions, "public_ip")

    // 使用 AWS SDK 驗證執行個體狀態
    instanceState := aws.GetInstanceState(t, awsRegion, instanceId)
    assert.Equal(t, "running", instanceState)

    // 驗證 HTTP 端點可存取
    url := fmt.Sprintf("http://%s:80", publicIp)

    // 重試機制
    maxRetries := 30
    timeBetweenRetries := 10 * time.Second

    http_helper.HttpGetWithRetry(
        t,
        url,
        nil,
        200,
        "Hello, World!",
        maxRetries,
        timeBetweenRetries,
    )
}

測試 EKS 叢集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// tests/eks_test.go
package test

import (
    "testing"
    "path/filepath"

    "github.com/gruntwork-io/terratest/modules/k8s"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestEksCluster(t *testing.T) {
    t.Parallel()

    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../modules/eks",
        Vars: map[string]interface{}{
            "cluster_name":    "terratest-eks",
            "cluster_version": "1.28",
        },
    })

    defer terraform.Destroy(t, terraformOptions)

    terraform.InitAndApply(t, terraformOptions)

    // 取得 kubeconfig
    kubeconfig := terraform.Output(t, terraformOptions, "kubeconfig")

    // 建立暫時的 kubeconfig 檔案
    kubeconfigPath := filepath.Join(t.TempDir(), "kubeconfig")
    require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0644))

    // 建立 Kubernetes 選項
    kubectlOptions := k8s.NewKubectlOptions("", kubeconfigPath, "default")

    // 驗證叢集節點
    nodes := k8s.GetNodes(t, kubectlOptions)
    assert.GreaterOrEqual(t, len(nodes), 2)

    // 驗證所有節點都處於 Ready 狀態
    for _, node := range nodes {
        k8s.WaitUntilNodeReady(t, kubectlOptions, node.Name, 10, 30*time.Second)
    }
}

執行 Terratest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 執行所有測試
go test -v -timeout 30m ./tests/...

# 執行特定測試
go test -v -timeout 30m -run TestVpcModule ./tests/...

# 平行執行測試
go test -v -timeout 30m -parallel 4 ./tests/...

# 產生測試報告
go test -v -timeout 30m ./tests/... 2>&1 | go-junit-report > report.xml

驗證規則(Validation Rules)

Terraform 提供了變數驗證規則,讓您在執行 plan 或 apply 之前就能捕捉無效的輸入。

基本驗證規則

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# variables.tf

variable "environment" {
  description = "部署環境"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "環境必須是 dev、staging 或 prod 其中之一。"
  }
}

variable "instance_type" {
  description = "EC2 執行個體類型"
  type        = string
  default     = "t3.micro"

  validation {
    condition     = can(regex("^t[2-3]\\.(micro|small|medium|large)$", var.instance_type))
    error_message = "執行個體類型必須是 t2 或 t3 系列的 micro、small、medium 或 large。"
  }
}

複雜驗證規則

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
variable "vpc_cidr" {
  description = "VPC CIDR 區塊"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "VPC CIDR 必須是有效的 CIDR 表示法。"
  }

  validation {
    condition     = tonumber(split("/", var.vpc_cidr)[1]) <= 24
    error_message = "VPC CIDR 的網路遮罩不能小於 /24。"
  }

  validation {
    condition     = tonumber(split("/", var.vpc_cidr)[1]) >= 16
    error_message = "VPC CIDR 的網路遮罩不能大於 /16。"
  }
}

variable "tags" {
  description = "資源標籤"
  type        = map(string)

  validation {
    condition     = contains(keys(var.tags), "Environment")
    error_message = "標籤必須包含 'Environment' 鍵。"
  }

  validation {
    condition     = contains(keys(var.tags), "Owner")
    error_message = "標籤必須包含 'Owner' 鍵。"
  }
}

列表和物件驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
variable "subnet_cidrs" {
  description = "子網路 CIDR 清單"
  type        = list(string)

  validation {
    condition     = length(var.subnet_cidrs) >= 2
    error_message = "至少需要定義 2 個子網路 CIDR。"
  }

  validation {
    condition     = alltrue([for cidr in var.subnet_cidrs : can(cidrhost(cidr, 0))])
    error_message = "所有子網路 CIDR 都必須是有效的 CIDR 表示法。"
  }
}

variable "database_config" {
  description = "資料庫配置"
  type = object({
    engine         = string
    engine_version = string
    instance_class = string
    storage_size   = number
  })

  validation {
    condition     = contains(["mysql", "postgresql", "mariadb"], var.database_config.engine)
    error_message = "資料庫引擎必須是 mysql、postgresql 或 mariadb。"
  }

  validation {
    condition     = var.database_config.storage_size >= 20 && var.database_config.storage_size <= 1000
    error_message = "儲存空間必須在 20 到 1000 GB 之間。"
  }
}

使用 Local Values 進行複雜驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
variable "network_config" {
  description = "網路配置"
  type = object({
    vpc_cidr     = string
    public_cidrs = list(string)
    private_cidrs = list(string)
  })
}

locals {
  # 驗證所有子網路 CIDR 是否在 VPC CIDR 範圍內
  vpc_network = cidrhost(var.network_config.vpc_cidr, 0)
  vpc_netmask = tonumber(split("/", var.network_config.vpc_cidr)[1])

  all_subnet_cidrs = concat(
    var.network_config.public_cidrs,
    var.network_config.private_cidrs
  )

  # 檢查子網路是否重疊(簡化版本)
  subnet_count = length(local.all_subnet_cidrs)
}

# 使用 check 區塊進行執行期驗證
check "subnet_validation" {
  assert {
    condition     = local.subnet_count == length(distinct(local.all_subnet_cidrs))
    error_message = "子網路 CIDR 不能重複。"
  }
}

Preconditions 與 Postconditions

Terraform 1.2 引入了 preconditionpostcondition 區塊,提供更精細的資源層級驗證。

Preconditions(前置條件)

Preconditions 在資源建立之前進行驗證:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 驗證 AMI 是否存在且有效
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.ubuntu.architecture == "x86_64"
      error_message = "AMI 必須是 x86_64 架構。"
    }

    precondition {
      condition     = data.aws_ami.ubuntu.root_device_type == "ebs"
      error_message = "AMI 必須使用 EBS 作為根裝置。"
    }
  }

  tags = {
    Name = "web-server"
  }
}

Postconditions(後置條件)

Postconditions 在資源建立之後進行驗證:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id

  lifecycle {
    postcondition {
      condition     = self.private_ip != null
      error_message = "執行個體必須被分配私有 IP 位址。"
    }

    postcondition {
      condition     = self.instance_state == "running"
      error_message = "執行個體必須處於運行狀態。"
    }
  }

  tags = {
    Name = var.instance_name
  }
}

資料來源的條件驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
data "aws_vpc" "selected" {
  id = var.vpc_id

  lifecycle {
    postcondition {
      condition     = self.enable_dns_hostnames == true
      error_message = "VPC 必須啟用 DNS 主機名稱。"
    }

    postcondition {
      condition     = self.enable_dns_support == true
      error_message = "VPC 必須啟用 DNS 支援。"
    }
  }
}

data "aws_subnet" "selected" {
  id = var.subnet_id

  lifecycle {
    postcondition {
      condition     = self.vpc_id == var.vpc_id
      error_message = "子網路必須屬於指定的 VPC。"
    }

    postcondition {
      condition     = self.available_ip_address_count >= 10
      error_message = "子網路必須至少有 10 個可用 IP 位址。"
    }
  }
}

輸出值的條件驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
output "load_balancer_dns" {
  description = "負載平衡器 DNS 名稱"
  value       = aws_lb.main.dns_name

  precondition {
    condition     = aws_lb.main.status == "active"
    error_message = "負載平衡器必須處於活動狀態才能輸出 DNS 名稱。"
  }
}

output "database_endpoint" {
  description = "資料庫端點"
  value       = aws_db_instance.main.endpoint
  sensitive   = true

  precondition {
    condition     = aws_db_instance.main.status == "available"
    error_message = "資料庫必須處於可用狀態。"
  }
}

模組層級的條件驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# modules/vpc/main.tf

variable "enable_nat_gateway" {
  type = bool
}

variable "single_nat_gateway" {
  type    = bool
  default = false
}

resource "aws_nat_gateway" "main" {
  count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.availability_zones)) : 0

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  lifecycle {
    precondition {
      condition     = var.enable_nat_gateway ? length(aws_subnet.public) > 0 : true
      error_message = "啟用 NAT Gateway 時必須至少有一個公有子網路。"
    }

    postcondition {
      condition     = self.connectivity_type == "public"
      error_message = "NAT Gateway 必須是公有類型。"
    }
  }
}

Check 區塊(持續驗證)

Terraform 1.5 引入了 check 區塊,用於執行不阻擋部署的驗證:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 檢查 SSL 憑證是否即將到期
check "certificate_expiry" {
  data "aws_acm_certificate" "main" {
    domain   = "example.com"
    statuses = ["ISSUED"]
  }

  assert {
    condition     = timecmp(plantimestamp(), timeadd(data.aws_acm_certificate.main.not_after, "-720h")) < 0
    error_message = "SSL 憑證將在 30 天內到期,請儘快更新。"
  }
}

# 檢查資源是否符合標籤政策
check "tagging_compliance" {
  assert {
    condition     = contains(keys(aws_instance.web.tags), "CostCenter")
    error_message = "執行個體缺少必要的 CostCenter 標籤。"
  }
}

CI/CD 整合測試流程

GitHub Actions 整合

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
# .github/workflows/terraform-test.yml
name: Terraform Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  TF_VERSION: "1.6.0"
  GO_VERSION: "1.21"

jobs:
  # 靜態分析
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive

      - name: Terraform Init
        run: terraform init -backend=false

      - name: Terraform Validate
        run: terraform validate

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v4

      - name: Run TFLint
        run: tflint --recursive

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          framework: terraform

  # 原生測試
  unit-tests:
    runs-on: ubuntu-latest
    needs: static-analysis
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: terraform init -backend=false

      - name: Run Terraform Tests
        run: terraform test -verbose

  # 整合測試
  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    if: github.event_name == 'pull_request'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Download Go Dependencies
        working-directory: ./tests
        run: go mod download

      - name: Run Terratest
        working-directory: ./tests
        run: go test -v -timeout 60m ./...
        env:
          AWS_DEFAULT_REGION: ap-northeast-1

GitLab CI 整合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# .gitlab-ci.yml
stages:
  - validate
  - test
  - integration

variables:
  TF_VERSION: "1.6.0"
  GO_VERSION: "1.21"

.terraform-base:
  image: hashicorp/terraform:${TF_VERSION}
  before_script:
    - terraform --version

# 靜態分析階段
fmt-check:
  extends: .terraform-base
  stage: validate
  script:
    - terraform fmt -check -recursive

validate:
  extends: .terraform-base
  stage: validate
  script:
    - terraform init -backend=false
    - terraform validate

tflint:
  stage: validate
  image: ghcr.io/terraform-linters/tflint:latest
  script:
    - tflint --init
    - tflint --recursive

security-scan:
  stage: validate
  image: bridgecrew/checkov:latest
  script:
    - checkov -d . --framework terraform

# 單元測試階段
terraform-test:
  extends: .terraform-base
  stage: test
  script:
    - terraform init -backend=false
    - terraform test -verbose
  artifacts:
    reports:
      junit: test-results.xml

# 整合測試階段
integration-test:
  stage: integration
  image: golang:${GO_VERSION}
  only:
    - merge_requests
  before_script:
    - wget -O /tmp/terraform.zip https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip
    - unzip /tmp/terraform.zip -d /usr/local/bin/
  script:
    - cd tests
    - go mod download
    - go test -v -timeout 60m ./...
  variables:
    AWS_DEFAULT_REGION: ap-northeast-1

完整的 Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# Makefile for Terraform testing

.PHONY: all fmt validate lint test integration clean

TERRAFORM := terraform
TFLINT := tflint
GO := go
TEST_DIR := ./tests

# 預設目標
all: fmt validate lint test

# 格式化
fmt:
	@echo "==> Formatting Terraform files..."
	$(TERRAFORM) fmt -recursive

# 格式檢查
fmt-check:
	@echo "==> Checking Terraform format..."
	$(TERRAFORM) fmt -check -recursive

# 初始化
init:
	@echo "==> Initializing Terraform..."
	$(TERRAFORM) init -backend=false

# 驗證
validate: init
	@echo "==> Validating Terraform configuration..."
	$(TERRAFORM) validate

# Linting
lint:
	@echo "==> Running TFLint..."
	$(TFLINT) --init
	$(TFLINT) --recursive

# 安全掃描
security:
	@echo "==> Running security scan..."
	checkov -d . --framework terraform

# 原生測試
test: init
	@echo "==> Running Terraform tests..."
	$(TERRAFORM) test -verbose

# 整合測試
integration:
	@echo "==> Running integration tests..."
	cd $(TEST_DIR) && $(GO) mod download
	cd $(TEST_DIR) && $(GO) test -v -timeout 60m ./...

# 完整測試流程
full-test: fmt-check validate lint security test

# 清理
clean:
	@echo "==> Cleaning up..."
	rm -rf .terraform
	rm -rf .terraform.lock.hcl
	rm -rf $(TEST_DIR)/vendor

# 幫助
help:
	@echo "Available targets:"
	@echo "  fmt          - Format Terraform files"
	@echo "  fmt-check    - Check Terraform format"
	@echo "  init         - Initialize Terraform"
	@echo "  validate     - Validate Terraform configuration"
	@echo "  lint         - Run TFLint"
	@echo "  security     - Run security scan"
	@echo "  test         - Run Terraform native tests"
	@echo "  integration  - Run Terratest integration tests"
	@echo "  full-test    - Run complete test suite"
	@echo "  clean        - Clean up generated files"

測試報告與通知

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# .github/workflows/test-report.yml
name: Test Report

on:
  workflow_run:
    workflows: ["Terraform Test"]
    types: [completed]

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - name: Download Test Results
        uses: actions/download-artifact@v4
        with:
          name: test-results
          run-id: ${{ github.event.workflow_run.id }}

      - name: Publish Test Report
        uses: mikepenz/action-junit-report@v4
        with:
          report_paths: '**/test-results.xml'
          fail_on_failure: true

      - name: Notify Slack on Failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Terraform Test Failed",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Terraform Test Failed* :x:\n<${{ github.event.workflow_run.html_url }}|View Workflow>"
                  }
                }
              ]
            }            
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

最佳實踐總結

測試策略建議

階段測試類型工具頻率
開發Format + Validateterraform fmt/validate每次儲存
提交Static AnalysisTFLint, Checkov每次提交
PRUnit Teststerraform test每個 PR
合併Integration TestsTerratestPR 合併前
發布E2E TestsTerratest發布前

命名慣例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
tests/
├── unit/
│   ├── vpc.tftest.hcl
│   ├── ec2.tftest.hcl
│   └── rds.tftest.hcl
├── integration/
│   ├── vpc_test.go
│   ├── ec2_test.go
│   └── rds_test.go
└── e2e/
    └── full_stack_test.go

測試隔離

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 使用隨機後綴避免資源衝突
resource "random_string" "suffix" {
  length  = 8
  special = false
  upper   = false
}

locals {
  resource_prefix = "test-${random_string.suffix.result}"
}

總結

Terraform 測試框架與驗證機制為基礎設施程式碼提供了完整的品質保證工具鏈:

  1. 原生測試框架:使用 terraform test 進行快速的單元測試
  2. Mock Provider:在不連接雲端的情況下驗證程式碼邏輯
  3. Terratest:進行實際部署的整合測試
  4. 驗證規則:在變數層級捕捉無效輸入
  5. Preconditions/Postconditions:在資源層級進行前後條件驗證
  6. CI/CD 整合:自動化測試流程確保程式碼品質

透過這些工具和實踐,您可以建立可靠、可維護的基礎設施程式碼,降低部署風險,提升團隊信心。

參考資源

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy