Testing Modules
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”terraform validate, terraform plan และ terraform test แต่ละตัวตอบคำถามคนละข้อ — “syntax ถูกไหม” “Azure ยอมรับไหม” และ “ทำงานตามที่คาดไว้ไหม” — การเอาตัวใดตัวหนึ่งมาแทนอีกสองตัวคือวิธีที่ module พังไปถึง production ทั้ง ๆ ที่ผ่านสีเขียวมาตลอดทาง
confidence สามระดับ และทำไมแทนกันไม่ได้
หัวข้อที่มีชื่อว่า “confidence สามระดับ และทำไมแทนกันไม่ได้”น่าล่อใจที่จะมองว่า “ไม่มี 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 จริง
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 นี้แปลว่าอะไร
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 เช็คผลลัพธ์
native test framework ของ Terraform: .tftest.hcl และ terraform test
หัวข้อที่มีชื่อว่า “native test framework ของ Terraform: .tftest.hcl และ terraform 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 ที่มีชื่อนั้นจริง
command argument: plan กับ apply
หัวข้อที่มีชื่อว่า “command argument: plan กับ apply”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
mock_provider, override_resource, override_module และ expect_failures
หัวข้อที่มีชื่อว่า “mock_provider, override_resource, override_module และ expect_failures”ทุกการพิสูจน์จนถึงตอนนี้ในบทนี้มีข้อจำกัดจริง — บทก่อนหน้าบอกไว้แล้วว่า 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
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}variable "app_name" { type = string}
variable "location" { type = string}
variable "resource_group_name" { type = string}
variable "retention_days" { type = number}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 ในไฟล์ที่ไม่ได้ประกาศของตัวเอง
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 testtests/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"... passtests/storage_account.tftest.hcl... tearing downtests/storage_account.tftest.hcl... pass
Success! 5 passed, 0 failed.สี่กลไก แต่ละตัวพิสูจน์ด้วยหนึ่งในห้า run นั้น
override_moduleแทนที่ output ทั้งหมดของ child module ด้วยค่าคงที่ โดยไม่รัน resource ของ module นั้นเลย — พิสูจน์ข้างบนmodule.logging.workspace_idresolve เป็น fake ID ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่ mock generate ให้ ทำให้ test แยก logic ของ root module ออกจาก behavior จริงของ dependency ได้override_resourceแทนที่ attribute ของ resource ตัวเดียว nuance จริงที่ไม่ obvious ที่พิสูจน์ข้างบน ความพยายามครั้งแรกของ assertion นี้ (ไม่แสดงในโค้ด — ดูความเข้าใจผิดด้านล่าง) ลอง overrideprimary_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_resourceblock คือสิ่งที่ทำให้ค่าที่ override ใช้ได้ใน assertion ระดับ plan-time — พิสูจน์ด้วยการ pass ข้างบนทันทีที่เพิ่ม argument นั้นเข้าไปexpect_failuresพิสูจน์ว่าvariablevalidationblock ปฏิเสธ 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"] Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”แต่ละขั้นในสี่ขั้นนี้ถามคำถามที่แคบลงกว่าขั้นก่อนหน้า และนั่นคือประเด็น 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 = apply | apply-แล้ว-destroy รอบเต็มโดยไม่มี credential หรือ infrastructure จริงเลย — พิสูจน์ข้างบน test suite ทั้งชุดรันกับ azurerm_storage_account โดยไม่มี subscription | ค่า attribute ที่ mock generate ให้ไม่ใช่ behavior จริงของ provider — Azure API จริงที่ปฏิเสธ combination ของ argument ที่ดูดีบนกระดาษยังต้อง apply จริงกับ subscription จริงถึงจะจับได้ |
override_resource กับ override_during = plan | assert บน 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 ด้วยค่าต่างกัน - “
runblock ที่ใช้expect_failuresคือ test ที่ยอมให้ fail ได้” พิสูจน์ข้างบน run นั้น pass เพราะ validation ของ object ที่ระบุไว้ fail ตามที่คาดไว้เป๊ะ — ถ้า validation ปล่อย input ที่ผิดผ่านไปโดยไม่ fail run นี้จะเป็นตัวที่ report fail แทน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review ไฟล์ .tftest.hcl ที่ generate มา เช็คสิ่งเหล่านี้
override_resourceoverride 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 จริงตั้งค่าไว้ที่ไหนเลย — ไฟล์แบบนี้ไม่มีทาง passcommand = applyได้เลย เช็คว่าmock_providerควรอยู่ตรงนั้นหรือเปล่า expect_failuresระบุ checkable object ผิดตัว (assert ว่าvar.storage_account_namefail ทั้งที่ตั้งใจทดสอบ postcondition ของ resource เอง หรือกลับกัน) — test อาจ pass ด้วยเหตุผลผิด ถ้า object ที่ระบุไว้บังเอิญ fail อยู่แล้วไม่ว่ากรณีไหน- หลาย
runblock ในไฟล์เดียวที่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