ข้ามไปยังเนื้อหา

Testing Modules

terraform validate, terraform plan และ terraform test แต่ละตัวตอบคำถามคนละข้อ — “syntax ถูกไหม” “Azure ยอมรับไหม” และ “ทำงานตามที่คาดไว้ไหม” — การเอาตัวใดตัวหนึ่งมาแทนอีกสองตัวคือวิธีที่ module พังไปถึง production ทั้ง ๆ ที่ผ่านสีเขียวมาตลอดทาง

น่าล่อใจที่จะมองว่า “ไม่มี error” แปลว่า “ทำงานได้” แต่จริง ๆ แล้ว Terraform ให้ check สามแบบที่ต่างกันชัดเจน แต่ละแบบจับ mistake คนละประเภท และแต่ละแบบที่ผ่านก็แทบไม่บอกอะไรเลยเกี่ยวกับแบบที่อยู่ถัดไป

terraform validate เช็คแค่ HCL syntax กับความสอดคล้องภายในของ configuration เท่านั้น โดยยืนยันว่า block ของคุณเขียนถูกรูปแบบ argument type ตรงกับที่ resource ต้องการ และ reference อย่าง var.name หรือ module.storage.primary_blob_endpoint ชี้ไปที่สิ่งที่มีอยู่จริง ที่สำคัญคือ validate ไม่คุยกับ Azure เลย จึงไม่มีทางรู้ว่า VM size ที่คุณพิมพ์ไปมีอยู่จริงใน region ที่คุณเลือกหรือเปล่า หรือชื่อ storage account ที่คุณตั้งถูกใช้ไปแล้วโดย subscription คนอื่น configuration หนึ่งสามารถผ่าน validate ได้อย่างสะอาดหมดจด แล้วยังพังทันทีที่แตะ Azure API จริง

Terminal window
terraform validate
# Success! The configuration is valid.
#
# ...but validate has no idea whether "Standard_ZZZ99" is a real VM size,
# or whether "acmeapplogs" is already taken as a storage account name.

terraform plan ก้าวไปอีกขั้นจริง ๆ โดย authenticate เข้า Azure resolve data source และถาม provider ว่าจะเกิดอะไรขึ้น ตรงนี้คือจุดที่ mistake เรื่อง region กับ SKU, ชื่อชนกัน, หรือปัญหา quota จะโผล่ขึ้นมาจริง เพราะ plan คุยกับ Azure API จริงและแสดง diff ที่จะ apply สิ่งที่ plan ไม่ ทำคือ assert อะไรเลยว่า diff นั้น ถูกต้อง แค่แสดง plan ให้คุณดู ไม่ได้เช็ค plan นั้นกับสิ่งที่คุณคาดหวังไว้ ไม่มีอะไรหยุด plan จากการสำเร็จ ทั้งที่เงียบ ๆ สร้าง storage account ที่มี replication type ผิด เพราะไม่มีใครบอก Terraform ว่า “ถูกต้อง” สำหรับ module นี้แปลว่าอะไร

Terminal window
terraform plan
# Plan: 1 to add, 0 to change, 0 to destroy.
#
# This confirms Azure accepts the request. It does not confirm the
# storage account it is about to create actually has the settings
# this module was supposed to guarantee.

terraform test คือตัวเดียวที่ assert อะไรบางอย่างจริง ๆ เป็น native testing framework ของ Terraform และเป็นตัวเดียวในสามตัวนี้ที่ encode ว่า “นี่คือ behavior เฉพาะที่ฉันคาดหวัง” แล้ว fail ทันทีที่ความเป็นจริงต่างออกไป ส่วน validate เช็ครูปแบบ และ plan เช็คว่า Azure ยอมรับ request นั้น test เช็คผลลัพธ์

test file อยู่ใน extension .tftest.hcl ตามธรรมเนียมมักอยู่ใต้ directory tests/ และรันด้วยคำสั่ง terraform test test file ประกอบด้วย block สองแบบ: variables block ระดับบนสุดที่ให้ input value เฉพาะสำหรับ test และ run block หนึ่งตัวหรือมากกว่า แต่ละตัวรัน configuration แล้วเช็คด้วย assert block หนึ่งตัวหรือมากกว่า

# Illustrative: tests/storage_account_name.tftest.hcl (targets the storage-account module from writing-a-reusable-module.mdx)
variables {
resource_group_name = "rg-app-logs"
}
run "creates_storage_account_with_expected_name" {
command = plan
variables {
name = "acmeapplogs"
}
assert {
condition = azurerm_storage_account.this.name == "acmeapplogs"
error_message = "Storage account name did not match the name variable passed in"
}
}

assert block แต่ละตัวต้องการสองอย่างเป๊ะ ๆ: condition ที่ต้อง evaluate ออกมาเป็น true และ error_message string ที่โชว์ให้คุณเห็นทันทีที่ condition นั้นเป็น false variables block ข้างใน run block override หรือให้ value ที่ scope เฉพาะ run นั้น ส่วน variables block ระดับบนสุดตั้ง default ที่แชร์กันทุก run block ใน file — ตัวอย่างนี้คือ module storage-account จากบทก่อนหน้าในหน่วยนี้เป๊ะ ๆ ที่ถูกส่ง name = "acmeapplogs" เข้าไป แล้ว assert ว่าสร้าง resource ที่มีชื่อนั้นจริง

run block ทุกตัวมี command argument และการเลือกระหว่างสองค่านั้น คือ plan กับ apply เป็นการตัดสินใจที่สำคัญที่สุดใน test file

command = plan เร็วและไม่สร้าง resource จริงเลย Terraform สร้าง plan และ assert block ของคุณเช็ค planned value — นี่คือสิ่งที่ตัวอย่างข้างบนทำเป๊ะ ๆ เพราะ name มาจาก variable ตรง ๆ และรู้ค่าได้ก่อนที่จะมี apply ใด ๆ เกิดขึ้น ใช้ command = plan สำหรับ test case ส่วนใหญ่: อะไรก็ตามที่คุณ verify ได้จาก configuration อย่างเดียว อย่าง “input นี้ทำให้เกิด attribute ที่คาดไว้ไหม” ควรอยู่ตรงนี้

command = apply สร้าง resource จริงใน Azure จริง ๆ assert กับ attribute จริงที่ได้ผลลัพธ์ออกมา แล้ว teardown ทุกอย่างอีกครั้งเมื่อ test file รันจบ นี่คือ integration testing จริง ๆ — เพราะเป็นวิธีเดียวที่ assert กับ value ที่ Azure คำนวณให้คุณได้ เช่น endpoint ที่ถูก generate หรือ resource ID เพราะ value นั้นไม่มีอยู่จริงจนกว่าจะมีการ apply เกิดขึ้นจริง

# Illustrative: tests/storage_account_endpoint.tftest.hcl (targets the storage-account module from writing-a-reusable-module.mdx)
variables {
name = "acmeapplogs"
resource_group_name = "rg-app-logs"
}
run "creates_storage_account_with_working_endpoint" {
command = apply
assert {
condition = output.primary_blob_endpoint != ""
error_message = "Expected a non-empty primary_blob_endpoint after apply"
}
}

command = apply ช้ากว่าและมีค่าใช้จ่ายจาก cloud resource จริงในช่วงสั้น ๆ เพราะ Terraform provision แล้วก็ destroy จริง ๆ เป็นส่วนหนึ่งของการรัน test ใช้อย่างตั้งใจ สำหรับ assertion ที่ต้องการ Azure-computed value จริง ๆ เท่านั้น ไม่ใช่เป็นค่า default ของทุก run block ใน file

ทุกการพิสูจน์จนถึงตอนนี้ในบทนี้มีข้อจำกัดจริง — บทก่อนหน้าบอกไว้แล้วว่า plan ของ azurerm ต้องการ subscription จริง คอร์สนี้เลย validate azurerm แทนที่จะ plan กับ Azure จริง feature mocking ของ Terraform 1.7 เปลี่ยนเรื่องนี้เฉพาะสำหรับ terraform test mock_provider แทน provider azurerm จริงทั้งหมด — ทุก resource ที่มันจัดการจะได้ mock value จาก provider สำหรับ attribute ไหนก็ตามที่ config ของคุณไม่ได้ตั้งไว้ตรง ๆ ดังนั้น command = apply ข้างใน test รันได้ครบวงจรโดยไม่มี API call จริงเลยและไม่มี credential จริงเลย รวมถึง azurerm ด้วย พิสูจน์เต็มรูปแบบข้างล่างด้วย module จริงครบและไฟล์ .tftest.hcl จริง

module ที่ถูกทดสอบ — azurerm_storage_account เล็ก ๆ ที่มี module logging ซ้อนอยู่ข้างใน และ variable validation

modules/logging/main.tf
resource "azurerm_log_analytics_workspace" "this" {
name = "law-${var.app_name}"
location = var.location
resource_group_name = var.resource_group_name
retention_in_days = var.retention_days
}
output "workspace_id" {
value = azurerm_log_analytics_workspace.this.id
}
modules/logging/variables.tf
variable "app_name" {
type = string
}
variable "location" {
type = string
}
variable "resource_group_name" {
type = string
}
variable "retention_days" {
type = number
}
main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
variable "storage_account_name" {
type = string
validation {
condition = length(var.storage_account_name) >= 3
error_message = "storage_account_name must be at least 3 characters."
}
}
variable "app_name" {
type = string
default = "web"
}
resource "azurerm_storage_account" "this" {
name = var.storage_account_name
resource_group_name = "acme-rg"
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
}
module "logging" {
source = "./modules/logging"
app_name = var.app_name
location = "eastus"
resource_group_name = "acme-rg"
retention_days = 30
}

ไฟล์ test — mock_provider "azurerm" {} ที่บนสุดใช้กับทุก run block ในไฟล์ที่ไม่ได้ประกาศของตัวเอง

tests/storage_account.tftest.hcl
mock_provider "azurerm" {}
variables {
storage_account_name = "acmeapplogstest"
}
run "name_matches_input" {
command = plan
assert {
condition = azurerm_storage_account.this.name == var.storage_account_name
error_message = "Storage account name did not match input"
}
}
run "override_logging_module_output" {
command = plan
override_module {
target = module.logging
outputs = {
workspace_id = "/subscriptions/00000000/resourceGroups/acme-rg/providers/Microsoft.OperationalInsights/workspaces/fake"
}
}
assert {
condition = module.logging.workspace_id == "/subscriptions/00000000/resourceGroups/acme-rg/providers/Microsoft.OperationalInsights/workspaces/fake"
error_message = "override_module did not replace the logging module output"
}
}
run "override_storage_account_resource" {
command = plan
override_resource {
target = azurerm_storage_account.this
override_during = plan
values = {
primary_blob_endpoint = "https://overridden.blob.core.windows.net/"
}
}
assert {
condition = azurerm_storage_account.this.primary_blob_endpoint == "https://overridden.blob.core.windows.net/"
error_message = "override_resource did not replace the computed primary_blob_endpoint attribute"
}
}
run "name_too_short_fails_validation" {
command = plan
variables {
storage_account_name = "ab"
}
expect_failures = [
var.storage_account_name,
]
}
run "storage_account_is_created_with_correct_name" {
command = apply
assert {
condition = azurerm_storage_account.this.name == var.storage_account_name
error_message = "Real storage account name did not match input"
}
}

output จริงของ terraform test — ทุก run รันจริง ไม่มี Azure credential ตั้งค่าไว้ที่ไหนเลย ทั้งที่ azurerm_storage_account เป็น resource ที่คอร์สนี้ plan หรือ apply ตามปกติไม่ได้เลยด้วยซ้ำ

$ terraform test
tests/storage_account.tftest.hcl... in progress
run "name_matches_input"... pass
run "override_logging_module_output"... pass
run "override_storage_account_resource"... pass
run "name_too_short_fails_validation"... pass
run "storage_account_is_created_with_correct_name"... pass
tests/storage_account.tftest.hcl... tearing down
tests/storage_account.tftest.hcl... pass
Success! 5 passed, 0 failed.

สี่กลไก แต่ละตัวพิสูจน์ด้วยหนึ่งในห้า run นั้น

  • override_module แทนที่ output ทั้งหมดของ child module ด้วยค่าคงที่ โดยไม่รัน resource ของ module นั้นเลย — พิสูจน์ข้างบน module.logging.workspace_id resolve เป็น fake ID ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่ mock generate ให้ ทำให้ test แยก logic ของ root module ออกจาก behavior จริงของ dependency ได้
  • override_resource แทนที่ attribute ของ resource ตัวเดียว nuance จริงที่ไม่ obvious ที่พิสูจน์ข้างบน ความพยายามครั้งแรกของ assertion นี้ (ไม่แสดงในโค้ด — ดูความเข้าใจผิดด้านล่าง) ลอง override primary_blob_endpoint ภายใต้ command = plan โดยไม่มี override_during = plan แล้ว fail ด้วย “Unknown condition value… executed a run block with command = plan and one of the values your condition depended on is not known until after the plan has been applied” การเพิ่ม override_during = plan เข้าไปใน override_resource block คือสิ่งที่ทำให้ค่าที่ override ใช้ได้ใน assertion ระดับ plan-time — พิสูจน์ด้วยการ pass ข้างบนทันทีที่เพิ่ม argument นั้นเข้าไป
  • expect_failures พิสูจน์ว่า variable validation block ปฏิเสธ input ที่ผิดจริง — พิสูจน์ข้างบน การส่ง storage_account_name = "ab" (2 ตัวอักษร) เข้า run ที่ระบุ var.storage_account_name ใน expect_failures ทำให้ test นั้น pass เพราะ validation fail ตามที่คาดไว้เป๊ะ ถ้า validation ไม่ fire test นี้จะ fail แทน
  • command = apply กับ mock_provider ยังรัน apply-แล้ว-destroy รอบเต็มจริง — แค่กับ resource ที่ mock generate ให้แทนที่จะเป็น Azure จริง พิสูจน์ข้างบน มัน pass เหมือนกับ run ที่เป็น plan เท่านั้น และนี่คือวิธีเป๊ะ ๆ ที่ไฟล์ terraform test ทดสอบ behavior ของ command = apply ให้ resource อย่าง azurerm_storage_account ที่คอร์สนี้ plan หรือ apply ตามปกติไม่ได้เลยถ้าไม่มี subscription จริง
flowchart TB
  subgraph rung1["terraform validate"]
    v1["HCL syntax and internal consistency"] --> v2["never talks to Azure"]
  end
  subgraph rung2["terraform plan"]
    p1["talks to the real Azure API"] --> p2["shows the diff, asserts nothing about it"]
  end
  subgraph rung3["terraform test"]
    t1["run blocks with assert"] --> t2["condition and error_message enforce expected behavior"]
  end
  rung1 -->|catches malformed config| rung2
  rung2 -->|catches provider-side errors| rung3
  rung3 -->|catches wrong results| done["confidence the module actually works"]
สามระดับความมั่นใจ แต่ละระดับจับความผิดพลาดคนละประเภท

แต่ละขั้นในสี่ขั้นนี้ถามคำถามที่แคบลงกว่าขั้นก่อนหน้า และนั่นคือประเด็น validate ถามว่า “HCL นี้สอดคล้องภายในหรือไม่” plan ถามว่า “provider จริงจะทำอะไรกับสิ่งนี้” — คำถามที่คอร์สนี้ถาม azurerm ได้ไม่เต็มที่ด้วยซ้ำถ้าไม่มี subscription จริง test ที่ command = plan ถามว่า “shape ที่ วางแผนไว้ ตรงกับที่ตั้งใจไหม” test ที่ command = apply (จริงหรือ mock_provider) ถามว่า “shape ที่ apply แล้ว ตรงกับที่ตั้งใจไหม” mock_provider ไม่ใช่ขั้นที่ห้า — มันคือคำถาม apply เดียวกันที่ถามโดยไม่ต้องจ่ายต้นทุน (เวลา, credential, infrastructure จริง) ของ provider จริง ซึ่งสำหรับ azurerm ในคอร์สนี้คือความต่างระหว่าง “ทดสอบไม่ได้เลย” กับ “ทดสอบ code path ของ apply ได้จริง” override_resource/override_module ทำให้คำถามแคบลงไปอีก “ถ้าทุกอย่างอื่นคงที่ ชิ้นนี้ชิ้นเดียวทำงานตามที่คาดไหม” — พิสูจน์ข้างบน override_during = plan คือรายละเอียดที่ตัดสินว่าคำตอบที่แคบลงนี้จะมีให้ก่อนหรือหลัง apply จริงเท่านั้น

ทางเลือกได้อะไรเสียอะไร
mock_provider + command = applyapply-แล้ว-destroy รอบเต็มโดยไม่มี credential หรือ infrastructure จริงเลย — พิสูจน์ข้างบน test suite ทั้งชุดรันกับ azurerm_storage_account โดยไม่มี subscriptionค่า attribute ที่ mock generate ให้ไม่ใช่ behavior จริงของ provider — Azure API จริงที่ปฏิเสธ combination ของ argument ที่ดูดีบนกระดาษยังต้อง apply จริงกับ subscription จริงถึงจะจับได้
override_resource กับ override_during = planassert บน attribute ที่ override แล้วได้ก่อนจ่ายต้นทุน apply จริง — พิสูจน์ข้างบนเผลอใช้ plan เป็น default แล้วได้ error “Unknown condition value” จริงง่ายมาก พิสูจน์ข้างบน ถ้าลืม argument นี้
override_moduleแยก module ที่ถูกทดสอบออกจาก behavior จริงของ dependency ได้เต็มที่ — พิสูจน์ข้างบน resource ของ child module เองไม่ถูกรันเลยด้วยซ้ำbehavior จริงของ dependency เองไม่ถูกทดสอบโดย run นี้ ต้องมี test แยกของตัวเอง
expect_failuresพิสูจน์ว่า validation block ปฏิเสธ input ที่ผิดจริง ไม่ใช่แค่ compile ผ่าน — พิสูจน์ข้างบนการ “pass” ของ run หมายถึง validation fail ตามที่คาดไว้ — อ่าน test output ให้ถูกต้องต้องใช้เวลาปรับตัวสักหน่อย
  • “values ของ override_resource ใช้ได้เสมอกับ assertion ที่ command = plan” พิสูจน์ข้างบนด้วย error จริงที่เจอตอนเขียนการพิสูจน์นี้ — ถ้าไม่มี override_during = plan ค่าที่ override ยังคง unknown จนกว่าจะ apply และ assertion fail ด้วย “Unknown condition value … executed a run block with command = plan”
  • “resource ของ azurerm ทดสอบด้วย command = apply ในคอร์สนี้ไม่ได้จริง ๆ เพราะ plan ต้องการ subscription จริง” พิสูจน์ข้างบน ด้วย mock_provider ที่มีผลอยู่ apply รัน create-แล้ว-destroy รอบเต็มกับ resource ที่ mock generate ให้ — ทดสอบ code path ของ apply จริง ๆ สำหรับ azurerm_storage_account โดยไม่เคยเรียก Azure เลย
  • “override_module ยังรัน resource ของ child module เอง แค่ด้วย input ต่างกัน” พิสูจน์ข้างบน module.logging.workspace_id ที่ถูก override คืนค่า fake string ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่มาจาก azurerm_log_analytics_workspace.this — resource ของ child module ถูกแทนที่ทั้งหมด ไม่ใช่ re-plan ด้วยค่าต่างกัน
  • “run block ที่ใช้ expect_failures คือ test ที่ยอมให้ fail ได้” พิสูจน์ข้างบน run นั้น pass เพราะ validation ของ object ที่ระบุไว้ fail ตามที่คาดไว้เป๊ะ — ถ้า validation ปล่อย input ที่ผิดผ่านไปโดยไม่ fail run นี้จะเป็นตัวที่ report fail แทน

ตอน review ไฟล์ .tftest.hcl ที่ generate มา เช็คสิ่งเหล่านี้

  • override_resource override attribute ที่เป็น computed ภายใต้ command = plan โดยไม่มี override_during = plan — พิสูจน์ข้างบน สิ่งนี้ fail ด้วย “Unknown condition value” ไม่ใช่ pass เงียบ ๆ ด้วยค่าผิด
  • command = apply ถูกใช้ตลอดทั้ง test suite กับ azurerm provider จริง (ไม่มี mock_provider) ทั้งที่ assertion ส่วนใหญ่ต้องการแค่ command = plan — พิสูจน์ข้างบน plan เร็วกว่าและไม่แตะอะไรจริง ควรเป็น default ตามคำแนะนำเดิมของบทนี้
  • ไฟล์ test ที่ assert บน attribute ของ resource azurerm โดยไม่มี mock_provider และไม่มี subscription จริงตั้งค่าไว้ที่ไหนเลย — ไฟล์แบบนี้ไม่มีทาง pass command = apply ได้เลย เช็คว่า mock_provider ควรอยู่ตรงนั้นหรือเปล่า
  • expect_failures ระบุ checkable object ผิดตัว (assert ว่า var.storage_account_name fail ทั้งที่ตั้งใจทดสอบ postcondition ของ resource เอง หรือกลับกัน) — test อาจ pass ด้วยเหตุผลผิด ถ้า object ที่ระบุไว้บังเอิญ fail อยู่แล้วไม่ว่ากรณีไหน
  • หลาย run block ในไฟล์เดียวที่ run แบบ command = apply ตัวก่อนหน้าสร้าง state จริงที่เปลี่ยนสิ่งที่ run แบบ command = plan ตัวถัดไปเห็น — พิสูจน์โดยนัยข้างบนจากเหตุผลที่ run แบบ apply ในไฟล์ test ของบทนี้ถูกจัดไว้ตัวสุดท้าย apply ก่อนหน้าที่ทิ้ง state จริงไว้อาจทำให้ override ที่ plan-time ตัวถัดไปทำงานต่างจากตอนที่ยังไม่มี state นั้น

🐛 หา bug ในโค้ดนี้

run "check_blob_endpoint" {
command = plan
override_resource {
target = azurerm_storage_account.this
values = {
primary_blob_endpoint = "https://overridden.blob.core.windows.net/"
}
}
assert {
condition = azurerm_storage_account.this.primary_blob_endpoint == "https://overridden.blob.core.windows.net/"
error_message = "override did not take effect"
}
}
เฉลย

พิสูจน์ข้างบนด้วย error จริงที่สิ่งนี้สร้างขึ้น primary_blob_endpoint เป็น attribute แบบ computed ที่ยัง unknown จนกว่า azurerm_storage_account.this จะถูกสร้างจริง — ภายใต้ command = plan Terraform ยัง resolve มันไม่ได้ ดังนั้นค่าที่ override แทนก็ยังไม่พร้อมให้ assertion ใช้ด้วย เว้นแต่ override_resource block จะระบุ override_during = plan ชัดเจน ถ้าไม่มี run นี้จะ fail ด้วย “Unknown condition value … one of the values your condition depended on is not known until after the plan has been applied” ไม่ใช่ pass เงียบ ๆ ด้วยค่าผิด และไม่ใช่ no-op เงียบ ๆ ด้วย — test fail ดัง ๆ แต่ด้วยเหตุผลที่ดูเหมือน bug ใน module มากกว่า test argument ที่ขาดไป ทางแก้คือบรรทัดเดียว override_during = plan ในตัว override_resource block ซึ่งพิสูจน์ข้างบนแล้วว่าทำให้ assertion เดียวกันเป๊ะ pass

terraform validate จับอะไรได้ และจับอะไรไม่ได้เลย
ทำไม terraform plan ที่ผ่านแบบสะอาดหมดจดถึงไม่รับประกันว่า module ทำงานได้ตามที่ตั้งใจจริง
ใน run block ของ .tftest.hcl ความต่างจริง ๆ ระหว่าง command = plan กับ command = apply คืออะไร
error จริงนี้เกิดตอน override attribute ที่ computed ภายใต้ command = plan อะไรแก้ได้ พิสูจน์ข้างบน
$ terraform test
...
Error: Unknown condition value
...
azurerm_storage_account.this.primary_blob_endpoint is a string

Condition expression could not be evaluated at this time. ... executed a
`run` block with `command = plan` and one of the values your condition
depended on is not known until after the plan has been applied.
เมื่อ azurerm ต้องการ subscription จริงสำหรับ plan/apply ในที่อื่นของคอร์สนี้ บทนี้พิสูจน์ behavior ของ command = apply สำหรับ azurerm_storage_account โดยไม่มี credential เลยได้อย่างไร
ทำไม test ที่ต้องการ assert กับ value อย่าง primary_blob_endpoint ถึงต้องใช้ command = apply แทน command = plan