Testing Modules
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”terraform validate, terraform plan และ terraform test แต่ละตัวตรวจสอบคนละเรื่องกันจริง ๆ — syntax, diff ของโลกจริง และ behavior ที่ถูก assert จริง — การผ่านตัวหนึ่งไม่ได้แปลว่าอีกตัวจะผ่านด้วย
สามระดับความมั่นใจที่ต่างกัน
หัวข้อที่มีชื่อว่า “สามระดับความมั่นใจที่ต่างกัน”น่าเผลอคิดว่า validate, plan และ test เป็นสามวิธีที่ถามคำถามเดียวกัน แต่จริง ๆ แล้วตรวจคนละเรื่องกัน และแต่ละตัวจับ mistake คนละแบบที่อีกสองตัวจับไม่ได้เลย
terraform validate ตรวจว่า HCL ของคุณ syntax ถูกต้องและ consistent กันภายใน — ว่า variable ทุกตัวที่ถูกอ้างอิงมีอยู่จริง type ตรงกัน argument ที่ required ครบ ที่สำคัญคือ validate ไม่เคยคุยกับ GCP เลย ไม่มี credential ไม่เรียก API ไหนเลย และบอกไม่ได้ว่า project_id เป็นของจริงหรือ account ของคุณมีสิทธิ์สร้าง google_storage_bucket หรือเปล่า
terraform validate# Success! The configuration is valid.terraform plan ไปไกลกว่านั้น โดย authenticate เข้า GCP API จริง อ่าน state ปัจจุบันของ resource ที่มีอยู่ และคำนวณ diff ว่าอะไรจะเปลี่ยน นี่จับ error กลุ่มที่ validate จับไม่ได้เลย — ค่า location ที่ API ปฏิเสธ, project_id ที่ credential ของคุณเข้าไม่ถึง, quota ที่เกิน แต่ plan ก็ยังไม่ได้ assert อะไรเลยว่าผลลัพธ์ถูกต้องตามที่คุณตั้งใจไว้ แค่โชว์ให้ดูว่าจะเกิดอะไรขึ้น
terraform plan# google_storage_bucket.this will be created# + name = "acme-app-logs"# + location = "US"terraform test เป็นตัวเดียวในสามตัวที่ assert behavior ที่คาดหวังไว้จริง ๆ — ว่าชื่อ bucket ตรงกับที่คุณส่งเข้าไปจริงไหม output ตัวหนึ่งมีค่าตรงกับที่คาดไว้จริงไหม การผ่าน validate หรือแม้แต่ plan ที่ clean บอกแค่ว่า configuration well-formed และ GCP ยอมรับ ไม่มีตัวไหนบอกว่า module ตัวนี้ทำงานตามที่คุณออกแบบไว้จริงหรือเปล่า
native test framework ของ Terraform
หัวข้อที่มีชื่อว่า “native test framework ของ Terraform”native test framework ของ Terraform ที่เปิดตัวใน Terraform 1.6 รันไฟล์ .tftest.hcl ผ่านคำสั่ง terraform test ไฟล์ test หนึ่งไฟล์มี variables block ระดับบนสุดสำหรับค่า input เฉพาะของ test และมี run block หนึ่งตัวขึ้นไป แต่ละตัวมี assert block หนึ่งตัวขึ้นไปที่มี condition expression กับ error_message ที่จะโชว์เมื่อ condition นั้นเป็นเท็จ
# Illustrative: tests/gcs_bucket.tftest.hcl (targets the gcs-bucket module from writing-a-reusable-module.mdx)variables { bucket_name = "acme-app-logs-test" project_id = "acme-app-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}ตัวอย่างนี้ test module gcs-bucket จากก่อนหน้านี้ในโมดูลนี้ โดย assert ว่า resource ของ bucket ข้างใน module ได้ name ตรงกับที่ caller ส่งเข้ามาเป็น bucket_name เป๊ะ ไม่ใช่แค่ว่า configuration syntax ถูกต้อง หรือ GCP ยอมรับเท่านั้น
command = plan เทียบกับ command = apply
หัวข้อที่มีชื่อว่า “command = plan เทียบกับ command = apply”ทุก run block มี command argument และการเลือกระหว่างสองค่านั้นคือ tradeoff จริง ไม่ใช่แค่พิธีการ command = plan คำนวณ plan แล้ว assert กับค่าที่ plan ไว้ โดยไม่สร้าง resource จริงเลยแม้แต่ตัวเดียว วิธีนี้เร็ว ฟรี และรันได้ทุก commit อย่างปลอดภัย นี่คือเหตุผลที่ควรครอบคลุม test case ส่วนใหญ่ของคุณ
run "bucket_name_matches_input" { command = plan
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}command = apply สร้าง resource จริงใน GCP จริง ๆ assert กับ attribute ที่เกิดขึ้นจริง แล้ว teardown ทุกอย่างทิ้งตอนท้ายไฟล์ test นี่คือ integration testing ของจริง — เพราะจับสิ่งที่ plan-only test จับไม่ได้ เช่น naming collision ที่ GCP API ปฏิเสธเฉพาะตอน apply เท่านั้น — แต่ช้ากว่าและเสียค่าใช้จ่าย cloud resource จริงชั่วครู่ ดังนั้นควรเลือกใช้อย่างตั้งใจ ไม่ใช่ใช้เป็น default
run "bucket_actually_created" { command = apply
assert { condition = google_storage_bucket.this.url != "" error_message = "Bucket was not actually created in GCP" }}mock_provider, override_resource, override_module และ expect_failures
หัวข้อที่มีชื่อว่า “mock_provider, override_resource, override_module และ expect_failures”Terraform 1.7 เพิ่มเครื่องมือตัวที่สี่ที่อยู่ระหว่าง plan กับ apply จริง คือ mocking mock_provider แทน provider จริงทั้งหมด — ทุก resource ที่มันจัดการจะได้ mock value จาก provider สำหรับ attribute ไหนก็ตามที่ config ของคุณไม่ได้ตั้งไว้ตรง ๆ ดังนั้น command = apply รันได้ครบวงจรโดยไม่มี API call จริงเลยและไม่มี credential จริงเลย นี่คือกลไกพิสูจน์หลักของ harness คอร์สนี้เอง และพิสูจน์เต็มรูปแบบข้างล่างด้วย module จริงครบและไฟล์ .tftest.hcl จริง — ไม่มี GCP project เข้ามาเกี่ยวเลย
module ที่ถูกทดสอบ — google_storage_bucket เล็ก ๆ ที่มี module logging ซ้อนอยู่ข้างใน และ variable validation
resource "google_logging_project_sink" "this" { name = "sink-${var.app_name}" destination = "storage.googleapis.com/${var.bucket_name}" filter = "severity >= WARNING"}
output "sink_id" { value = google_logging_project_sink.this.id}variable "app_name" { type = string}
variable "bucket_name" { type = string}terraform { required_providers { google = { source = "hashicorp/google" version = "~> 8.0" } }}
variable "bucket_name" { type = string validation { condition = length(var.bucket_name) >= 3 error_message = "bucket_name must be at least 3 characters." }}
variable "app_name" { type = string default = "web"}
resource "google_storage_bucket" "this" { name = var.bucket_name location = "US"}
module "logging" { source = "./modules/logging" app_name = var.app_name bucket_name = var.bucket_name}ไฟล์ test — mock_provider "google" {} ที่บนสุดใช้กับทุก run block ในไฟล์ที่ไม่ได้ประกาศของตัวเอง
mock_provider "google" {}
variables { bucket_name = "acme-app-logs-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}
run "override_logging_module_output" { command = plan
override_module { target = module.logging outputs = { sink_id = "projects/my-project/sinks/fake" } }
assert { condition = module.logging.sink_id == "projects/my-project/sinks/fake" error_message = "override_module did not replace the logging module output" }}
run "override_bucket_resource" { command = plan
override_resource { target = google_storage_bucket.this override_during = plan values = { self_link = "https://storage.googleapis.com/storage/v1/b/overridden-bucket" } }
assert { condition = google_storage_bucket.this.self_link == "https://storage.googleapis.com/storage/v1/b/overridden-bucket" error_message = "override_resource did not replace the computed self_link attribute" }}
run "bucket_name_too_short_fails_validation" { command = plan
variables { bucket_name = "ab" }
expect_failures = [ var.bucket_name, ]}
run "bucket_is_created_with_correct_name" { command = apply
assert { condition = google_storage_bucket.this.name == var.bucket_name error_message = "Real bucket name did not match the bucket_name input variable" }}output จริงของ terraform test — ทุก run รันจริง ไม่มี GCP credential ตั้งค่าไว้ที่ไหนเลย
$ terraform testtests/bucket.tftest.hcl... in progress run "bucket_name_matches_input"... pass run "override_logging_module_output"... pass run "override_bucket_resource"... pass run "bucket_name_too_short_fails_validation"... pass run "bucket_is_created_with_correct_name"... passtests/bucket.tftest.hcl... tearing downtests/bucket.tftest.hcl... pass
Success! 5 passed, 0 failed.สี่กลไก แต่ละตัวพิสูจน์ด้วยหนึ่งในห้า run นั้น
override_moduleแทนที่ output ทั้งหมดของ child module ด้วยค่าคงที่ โดยไม่รัน resource ของ module นั้นเลย — พิสูจน์ข้างบนmodule.logging.sink_idresolve เป็น fake ID ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่ mock generate ให้ ทำให้ test แยก logic ของ root module ออกจาก behavior จริงของ dependency ได้override_resourceแทนที่ attribute ของ resource ตัวเดียว nuance จริงที่ไม่ obvious ที่พิสูจน์ข้างบน ความพยายามครั้งแรกของ test นี้ (ไม่แสดงในโค้ด — ดูความเข้าใจผิดด้านล่าง) ลอง overrideself_linkภายใต้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 ที่ผิดจริง — พิสูจน์ข้างบน การส่งbucket_name = "ab"(2 ตัวอักษร) เข้าrunที่ระบุvar.bucket_nameในexpect_failuresทำให้ test นั้น pass เพราะ validation fail ตามที่คาดไว้เป๊ะ ถ้า validation ไม่ fire test นี้จะ fail แทนcommand = applyกับmock_providerยังรัน apply-แล้ว-destroy รอบเต็มจริง — แค่กับ resource ที่ mock generate ให้แทนที่จะเป็น GCP จริง พิสูจน์ข้างบน มัน pass เหมือนกับ run ที่เป็นplanเท่านั้น และ harness ของคอร์สนี้ใช้กลไกนี้เป๊ะ (mock_provider+command = apply) เพื่อพิสูจน์ว่า fence.tftest.hclรันจริงแบบ offline สำหรับทุก cloud ในคอร์สนี้
flowchart LR v["terraform validate: HCL syntax and internal consistency only"] --> p["terraform plan: talks to the real GCP API, shows the diff"] --> t["terraform test: asserts specific expected behavior via .tftest.hcl"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”แต่ละขั้นในสี่ขั้นนี้ถามคำถามที่แคบลงกว่าขั้นก่อนหน้า และนั่นคือประเด็น validate ถามว่า “HCL นี้สอดคล้องภายในหรือไม่” plan ถามว่า “provider จริงจะทำอะไรกับสิ่งนี้” test ที่ command = plan ถามว่า “shape ที่ วางแผนไว้ ตรงกับที่ตั้งใจไหม” test ที่ command = apply (จริงหรือ mock_provider) ถามว่า “shape ที่ apply แล้ว ตรงกับที่ตั้งใจไหม” mock_provider ไม่ใช่ขั้นที่ห้า — มันคือคำถาม apply เดียวกันที่ถามโดยไม่ต้องจ่ายต้นทุน (เวลา, credential, infrastructure จริง) ของ provider จริง override_resource/override_module ทำให้คำถามแคบลงไปอีก “ถ้าทุกอย่างอื่นคงที่ ชิ้นนี้ชิ้นเดียวทำงานตามที่คาดไหม” — พิสูจน์ข้างบน override_during = plan คือรายละเอียดที่ตัดสินว่าคำตอบที่แคบลงนี้จะมีให้ก่อนหรือหลัง apply จริงเท่านั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
mock_provider + command = apply | apply-แล้ว-destroy รอบเต็มโดยไม่มี credential หรือ infrastructure จริงเลย — พิสูจน์ข้างบน test suite ทั้งชุดรันโดยไม่มี GCP project | ค่า attribute ที่ mock generate ให้ไม่ใช่ behavior จริงของ provider — GCP API ที่ปฏิเสธ combination ของ argument ที่ดูดีบนกระดาษยังต้อง apply จริงกับ GCP จริงถึงจะจับได้ |
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” - “
command = applyใน test แตะ infrastructure จริงเสมอ” พิสูจน์ข้างบน ด้วยmock_providerที่มีผลอยู่applyรัน create-แล้ว-destroy รอบเต็มกับ resource ที่ mock generate ให้ — ทดสอบ code path ของ apply จริง ๆ โดยไม่เคยเรียก GCP เลย - “
override_moduleยังรัน resource ของ child module เอง แค่ด้วย input ต่างกัน” พิสูจน์ข้างบนmodule.logging.sink_idที่ถูก override คืนค่า fake string ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่มาจากgoogle_logging_project_sink.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 กับ provider จริง (ไม่มีmock_provider) ทั้งที่ assertion ส่วนใหญ่ต้องการแค่command = plan— พิสูจน์ข้างบนplanเร็วกว่าและไม่แตะอะไรจริง ควรเป็น default ตามคำแนะนำเดิมของบทนี้- ไฟล์ test ที่ assert บน attribute ของ resource โดยไม่มี
mock_providerและไม่มี credential จริงตั้งค่าไว้ที่ไหนเลย — ไฟล์แบบนี้ไม่มีทาง passcommand = applyได้เลย เช็คว่าmock_providerควรอยู่ตรงนั้นหรือเปล่า expect_failuresระบุ checkable object ผิดตัว (assert ว่าvar.bucket_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_bucket_self_link" {command = plan
override_resource { target = google_storage_bucket.this values = { self_link = "https://storage.googleapis.com/storage/v1/b/overridden-bucket" }}
assert { condition = google_storage_bucket.this.self_link == "https://storage.googleapis.com/storage/v1/b/overridden-bucket" error_message = "override did not take effect"}}เฉลย
พิสูจน์ข้างบนด้วย error จริงที่สิ่งนี้สร้างขึ้น self_link เป็น attribute แบบ computed ที่ยัง unknown จนกว่า google_storage_bucket.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