在現代基礎設施即程式碼(Infrastructure as Code, IaC)的實踐中,測試已成為確保部署品質和可靠性的關鍵環節。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 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 引入了 precondition 和 postcondition 區塊,提供更精細的資源層級驗證。
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 + Validate | terraform fmt/validate | 每次儲存 |
| 提交 | Static Analysis | TFLint, Checkov | 每次提交 |
| PR | Unit Tests | terraform test | 每個 PR |
| 合併 | Integration Tests | Terratest | PR 合併前 |
| 發布 | E2E Tests | Terratest | 發布前 |
命名慣例
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 測試框架與驗證機制為基礎設施程式碼提供了完整的品質保證工具鏈:
- 原生測試框架:使用
terraform test 進行快速的單元測試 - Mock Provider:在不連接雲端的情況下驗證程式碼邏輯
- Terratest:進行實際部署的整合測試
- 驗證規則:在變數層級捕捉無效輸入
- Preconditions/Postconditions:在資源層級進行前後條件驗證
- CI/CD 整合:自動化測試流程確保程式碼品質
透過這些工具和實踐,您可以建立可靠、可維護的基礎設施程式碼,降低部署風險,提升團隊信心。
參考資源