Testing Modules
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”terraform validate, terraform plan และ terraform test แต่ละตัวจับความผิดพลาดคนละประเภทกันโดยสิ้นเชิง การผ่านตัวใดตัวหนึ่งไม่ได้บอกอะไรเลยว่าตัวอื่นจะผ่านด้วย
สามระดับความมั่นใจที่แตกต่างกัน
หัวข้อที่มีชื่อว่า “สามระดับความมั่นใจที่แตกต่างกัน”น่าเผลอมองว่า validate, plan และ test เป็นวิธีตรวจสอบว่า module “ทำงานหรือไม่” ที่ใช้แทนกันได้ แต่จริง ๆ แล้วไม่ใช่ — แต่ละตัวคุยกับ reality คนละชั้น
terraform validate ตรวจแค่ syntax ของ HCL กับความสอดคล้องภายใน configuration เท่านั้น เช่น block เขียนถูกต้องหรือไม่ variable และ resource ที่อ้างอิงมีอยู่จริงหรือไม่ type ตรงกันหรือไม่ ที่สำคัญคือไม่คุยกับ AWS เลยแม้แต่นิดเดียว module หนึ่งอาจผ่าน validate แบบเนียน ๆ แล้วพังทันทีที่ไปแตะ AWS account จริง เพราะ validate ไม่มีทางรู้เลยว่า IAM permission ของคุณ, service quota ของ account หรือ AWS API เห็นด้วยกับสิ่งที่คุณเขียนหรือไม่
terraform plan ปิดช่องว่างนี้ด้วยการคุยกับ AWS API จริง จึงจับ error ฝั่ง provider ได้ — AMI ID พิมพ์ผิด, region ที่ resource type นั้นไม่รองรับ, permission ที่ credential ของคุณไม่มี — และแสดง diff ของสิ่งที่จะเปลี่ยน แต่ plan ไม่ assert อะไรเลยว่าผลลัพธ์นั้นถูกต้อง แต่แสดง diff ให้ดูอย่างสบายใจแม้ bucket จะตั้งชื่อผิด, subnet อยู่ผิด CIDR range หรือ security group เปิด port ที่ไม่ตั้งใจ ตราบใดที่ diff นั้น consistent ในตัวเอง
terraform test เป็นตัวเดียวในสามตัวที่ assert behavior ที่คาดหวังไว้จริง ๆ เป็น native testing framework ของ Terraform และเป็นตัวที่บอกได้จริงว่า module ทำสิ่งที่คุณตั้งใจไว้หรือไม่ ไม่ใช่แค่ Terraform parse หรือ apply ผ่าน
native test framework ของ Terraform
หัวข้อที่มีชื่อว่า “native test framework ของ Terraform”test อยู่ในไฟล์ .tftest.hcl และรันด้วย terraform test ไฟล์ test หนึ่งมี variables block สำหรับค่า input เฉพาะของ test และมี run block หนึ่งตัวหรือมากกว่า แต่ละตัวมี assert block ที่มี condition expression กับ error_message string ที่แสดงเมื่อ condition นั้นเป็นเท็จ
variables { bucket_name = "acme-app-logs-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Bucket name did not match the bucket_name input variable" }}test นี้เล็งไปที่ pattern ของ s3-bucket module จากบทก่อนหน้าในโมดูลนี้ โดยตั้ง bucket_name เป็น input เฉพาะของ test แล้ว assert ว่า attribute aws_s3_bucket.this.bucket ที่ได้ตรงกับค่าที่ส่งเข้ามาเป๊ะ ถ้ามีใครแก้ module แล้วเผลอ hardcode ชื่อ bucket หรือส่ง variable ผิดตัวเข้ามา assertion นี้จะ fail พร้อม message ที่ชี้ตรงไปที่ความผิดพลาด — สิ่งที่ validate กับ plan เพียงอย่างเดียวไม่มีทางจับได้เลย
command = plan เทียบกับ command = apply
หัวข้อที่มีชื่อว่า “command = plan เทียบกับ command = apply”ทุก run block มี command argument และการเลือกระหว่าง plan กับ apply คือ trade-off จริง ไม่ใช่แค่ความชอบส่วนตัว
command = plan เร็วและไม่สร้าง resource จริงเลย Terraform สร้าง plan แล้ว assertion ของคุณตรวจค่าที่วางแผนไว้ — เหมือนตัวอย่างข้างบนเป๊ะ นี่คือ default ที่ถูกต้องสำหรับ test case ส่วนใหญ่ เพราะ validate behavior ที่คาดหวังไว้ได้โดยไม่ต้องแตะ AWS account จริงเลย
command = apply สร้าง resource จริง assert บน attribute ที่เกิดขึ้นจริง แล้วรื้อทุกอย่างทิ้งอีกครั้งตอนจบไฟล์ test นี่คือ integration testing ตัวจริง เพราะจับปัญหาที่ plan จับไม่ได้ เช่น AWS API ปฏิเสธ combination ของ argument ที่ดูดีบนกระดาษ ต้นทุนคือช้ากว่าและสร้าง cloud resource จริงชั่วคราว ดังนั้นควรเก็บไว้ใช้กับ assertion ที่ต้องดู behavior จริงของ AWS โดยเฉพาะ
run "bucket_is_created_with_correct_name" { command = apply
assert { condition = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Real bucket name in AWS did not match the bucket_name input variable" }}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 จริง — ไม่มี AWS account เข้ามาเกี่ยวเลย
module ที่ถูกทดสอบ — aws_s3_bucket เล็ก ๆ ที่มี module logging ซ้อนอยู่ข้างใน และ variable validation
resource "aws_cloudwatch_log_group" "this" { name = "/app/${var.app_name}" retention_in_days = var.retention_days}
output "log_group_arn" { value = aws_cloudwatch_log_group.this.arn}variable "app_name" { type = string}
variable "retention_days" { type = number}terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.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 "aws_s3_bucket" "this" { bucket = var.bucket_name}
module "logging" { source = "./modules/logging" app_name = var.app_name retention_days = 14}ไฟล์ test — mock_provider "aws" {} ที่บนสุดใช้กับทุก run block ในไฟล์ที่ไม่ได้ประกาศของตัวเอง
mock_provider "aws" {}
variables { bucket_name = "acme-app-logs-test"}
run "bucket_name_matches_input" { command = plan
assert { condition = aws_s3_bucket.this.bucket == 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 = { log_group_arn = "arn:aws:logs:us-east-1:123456789012:log-group:/app/fake" } }
assert { condition = module.logging.log_group_arn == "arn:aws:logs:us-east-1:123456789012:log-group:/app/fake" error_message = "override_module did not replace the logging module output" }}
run "override_bucket_resource" { command = plan
override_resource { target = aws_s3_bucket.this override_during = plan values = { arn = "arn:aws:s3:::overridden-bucket" } }
assert { condition = aws_s3_bucket.this.arn == "arn:aws:s3:::overridden-bucket" error_message = "override_resource did not replace the computed arn 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 = aws_s3_bucket.this.bucket == var.bucket_name error_message = "Real bucket name did not match the bucket_name input variable" }}output จริงของ terraform test — ทุก run รันจริง ไม่มี AWS credential ตั้งค่าไว้ที่ไหนเลย
$ terraform testtests/s3_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/s3_bucket.tftest.hcl... tearing downtests/s3_bucket.tftest.hcl... pass
Success! 5 passed, 0 failed.สี่กลไก แต่ละตัวพิสูจน์ด้วยหนึ่งในห้า run นั้น
override_moduleแทนที่ output ทั้งหมดของ child module ด้วยค่าคงที่ โดยไม่รัน resource ของ module นั้นเลย — พิสูจน์ข้างบนmodule.logging.log_group_arnresolve เป็น fake ARN ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่ mock generate ให้ ทำให้ test แยก logic ของ root module ออกจาก behavior จริงของ dependency ได้override_resourceแทนที่ attribute ของ resource ตัวเดียว nuance จริงที่ไม่ obvious ที่พิสูจน์ข้างบน ความพยายามครั้งแรกของ test นี้ (ไม่แสดงในโค้ด — ดูความเข้าใจผิดด้านล่าง) ลอง overridearnภายใต้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 ให้แทนที่จะเป็น AWS จริง พิสูจน์ข้างบน มัน pass เหมือนกับ run ที่เป็นplanเท่านั้น และ harness ของคอร์สนี้ใช้กลไกนี้เป๊ะ (mock_provider+command = apply) เพื่อพิสูจน์ว่า fence.tftest.hclรันจริงแบบ offline สำหรับทุก cloud ในคอร์สนี้
flowchart TB a["terraform validate: HCL syntax and internal consistency only, never talks to AWS"] --> b["terraform plan: talks to the real AWS API, catches provider-side errors, shows the diff"] b --> c["terraform test: asserts specific expected behavior via .tftest.hcl run blocks"]
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 ทั้งชุดรันโดยไม่มี AWS account | ค่า attribute ที่ mock generate ให้ไม่ใช่ behavior จริงของ provider — AWS API ที่ปฏิเสธ combination ของ argument ที่ดูดีบนกระดาษยังต้อง apply จริงกับ AWS จริงถึงจะจับได้ |
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 จริง ๆ โดยไม่เคยเรียก AWS เลย - “
override_moduleยังรัน resource ของ child module เอง แค่ด้วย input ต่างกัน” พิสูจน์ข้างบนmodule.logging.log_group_arnที่ถูก override คืนค่า fake string ตัวที่ระบุไว้เป๊ะ ไม่ใช่ค่าที่มาจากaws_cloudwatch_log_group.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_arn" {command = plan
override_resource { target = aws_s3_bucket.this values = { arn = "arn:aws:s3:::overridden-bucket" }}
assert { condition = aws_s3_bucket.this.arn == "arn:aws:s3:::overridden-bucket" error_message = "override did not take effect"}}เฉลย
พิสูจน์ข้างบนด้วย error จริงที่สิ่งนี้สร้างขึ้น arn เป็น attribute แบบ computed ที่ยัง unknown จนกว่า aws_s3_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