Skip to main content

FIRST Principles

เทสต์ที่ดีควรเป็น Fast, Independent, Repeatable, Self-validating, Timely — กรอบคิด 5 ข้อนี้ใช้ตรวจสอบคุณภาพของเทสต์ได้ไม่ว่าจะเขียนด้วยภาษา/framework ไหนก็ตาม

Fast — เร็ว​

เทสต์ที่ช้าทำให้คนไม่อยากรันบ่อย (รันแค่ตอน CI แทนที่จะรันทุกครั้งที่ save ไฟล์) feedback loop ยิ่งช้า ยิ่งเสียเวลา developer รอ

🧠 จำง่ายๆ

"เทสต์ช้า = กระจกที่ฝ้าช้า มองเห็นรอยเปื้อนช้า" — ยิ่งเช็คตัวเองได้เร็วเท่าไหร่ ยิ่งแก้ปัญหาได้ ไวเท่านั้น เทสต์ที่ใช้เวลาเป็นนาทีต่อครั้งทำให้ developer หยุดรันบ่อยๆ แล้วปัญหาสะสมจนแก้ยากขึ้น

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

"Fast" ไม่ได้หมายถึงแค่เทสต์แต่ละตัวต้องเร็ว แต่หมายถึง total feedback time ของ test suite ทั้งก้อน ด้วย — ทีมจำนวนมากมี unit test ที่เร็วทีละตัว (มิลลิวินาที) แต่รวมกันหลักพันตัวแล้ว suite ใช้เวลาหลายนาที เพราะรันแบบ sequential ทั้งที่ unit test ส่วนใหญ่ independent กันอยู่ แล้วตามหลักข้อถัดไป จึง parallelize ได้เต็มที่ — เครื่องมือ test runner สมัยใหม่ (เช่น go test -p, Jest's --maxWorkers, pytest-xdist) รัน test แบบขนานหลาย process/thread ได้ ทีมที่ยังรัน sequential อยู่มักเสียเวลา CI โดยไม่จำเป็นทั้งที่ไม่ต้องแก้เทสต์แม้แต่บรรทัดเดียว แค่เปลี่ยนวิธีรัน

Independent — เป็นอิสระต่อกัน​

เทสต์แต่ละตัวไม่ควรพึ่ง state หรือผลลัพธ์จากเทสต์ตัวอื่น และไม่ควรต้องรันตามลำดับที่กำหนดถึงจะ ผ่าน — รันตัวไหนก่อนหลัง หรือรันตัวเดียวแยกจากกลุ่ม ก็ต้องได้ผลเหมือนกัน

🧠 จำง่ายๆ

"เทสต์ที่ดีไม่ควรมีความจำ" — แต่ละเทสต์ควรเริ่มต้นจากศูนย์ใหม่เสมอ (fresh state) ไม่ใช่อาศัยว่า "เทสต์ก่อนหน้าสร้างข้อมูลทิ้งไว้ให้แล้ว" ถ้าเทสต์ A ต้องรันก่อนเทสต์ B ถึงจะผ่าน นั่นคือสัญญาณว่า ออกแบบผิดแล้ว

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

การละเมิด "Independent" ที่ซ่อนเร้นที่สุดไม่ใช่การพึ่ง execution order ตรงๆ (ซึ่งเห็นง่าย) แต่ คือ shared mutable state ที่ซ่อนอยู่ใน test fixture หรือ global variable ที่ทุกเทสต์ใช้ ร่วมกันโดยไม่รู้ตัว เช่น in-memory database instance เดียวที่ถูก reuse ข้ามเทสต์ (เพื่อความเร็ว) โดยไม่ reset state ให้สะอาดก่อนแต่ละเทสต์ — ปัญหานี้มักไม่แสดงตัวตอนรันเทสต์ทีละไฟล์หรือรันแบบ sequential (เพราะ order ที่ "เผอิญ" ใช้งานได้พอดี) แต่จะเริ่มแสดงอาการ (intermittent failure) ทันทีที่เปลี่ยนไปรันแบบ parallel (ตามคำแนะนำเรื่อง Fast ข้างบน) เพราะ timing ของการเข้าถึง shared state ไม่แน่นอนอีกต่อไป — นี่คือเหตุผลที่เทสต์ที่ "เคยผ่านมาตลอด" จู่ๆ เริ่ม flaky พอ ทีมเปลี่ยนมา parallelize CI

Repeatable — ทำซ้ำได้ผลเหมือนเดิม​

รันกี่ครั้ง ที่ environment ไหน (เครื่อง dev, CI server, เวลาไหนของวัน) ต้องได้ผลเหมือนเดิมเป๊ะ — เทสต์ที่พึ่ง network, เวลาปัจจุบัน (time.Now()), หรือ random seed ที่ไม่ fix ไว้ มักทำลาย กฎข้อนี้

🧠 จำง่ายๆ

"Repeatable = สูตรอาหารที่ทำกี่ครั้งก็ได้รสเดิม" — ถ้าสูตรขึ้นกับ "อารมณ์คนทำวันนั้น" (external state ที่ควบคุมไม่ได้) ก็ไม่ใช่สูตรที่ดี เทสต์ก็เช่นกัน ต้องควบคุมทุกปัจจัยที่ไม่แน่นอนให้คงที่

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

เทสต์ที่พึ่ง time.Now() ตรงๆ มีปัญหาที่ลึกกว่าแค่ "ผลไม่เหมือนเดิม" — มันสามารถ ผ่านตลอด หลายปีแล้วจู่ๆ fail วันหนึ่ง โดยไม่มีใครแตะโค้ดเลย เช่น เทสต์ที่เช็ค "ถ้าวันนี้เป็นวันสุดท้าย ของเดือนให้ทำ X" จะผ่านตลอดจนกว่าจะถึงวันสุดท้ายของเดือนจริงๆ ถึงจะมีโอกาส expose บั๊ก (หรือ เทสต์ที่ fail เพราะ edge case ของปีอธิกสุรทิน ที่เกิดทุก 4 ปีเท่านั้น) วิธีแก้มาตรฐานคือ inject เวลาเป็น dependency (เช่น parameter now time.Time หรือ interface Clock แทนที่ จะเรียก time.Now() ตรงๆ ในโค้ดที่ต้องการเทส) ทำให้เทสต์ "fix" เวลาไว้ที่ค่าเฉพาะที่ต้องการ ทดสอบได้ (เช่น ทดสอบวันที่ 29 กุมภาพันธ์ 2024 ได้ตลอดเวลาไม่ว่าจะรันเทสต์วันไหนจริงๆ)

Self-validating — ตรวจสอบตัวเองได้​

เทสต์ต้องบอกผลลัพธ์ "ผ่าน" หรือ "ไม่ผ่าน" แบบ boolean ชัดเจนในตัวเอง ไม่ต้องให้คนมานั่งอ่าน log output เปรียบเทียบด้วยตา

🧠 จำง่ายๆ

"Self-validating = เครื่องตรวจลมยางอัตโนมัติ ไม่ใช่ต้องเอาหูไปฟังเสียงลมเอง" — เทสต์ที่ดีบอก ผลลัพธ์ชัดเจนทันที (ผ่าน/ไม่ผ่าน) ไม่ใช่แค่ print ค่าออกมาให้คนไปนั่งเทียบเองว่าถูกหรือผิด

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

มีรูปแบบการละเมิด "Self-validating" ที่แอบแฝงมากับเทสต์ที่ "ดูเหมือนดี": เทสต์ที่มี assertion แต่ assertion นั้น ตรวจแค่ว่าโค้ดไม่ throw exception (เช่น try { doSomething() } catch { fail() } โดยไม่มี assertion เช็คผลลัพธ์จริงเลย) เทสต์แบบนี้ "ผ่าน" ได้ง่ายมากแม้ business logic จะผิดสนิท เพราะมันไม่เคยเช็คว่า "ผลลัพธ์ที่ได้ถูกต้องตามที่คาดหวังไหม" เช็คแค่ "มันไม่ crash" เท่านั้น เป็นรูปแบบเทสต์ที่เพิ่ม code coverage ตัวเลขได้ง่ายแต่ไม่ได้เพิ่มความมั่นใจจริง เลย (เชื่อมโยงกับหัวข้อ Coverage ที่ย้ำว่า coverage สูงไม่ได้แปลว่าไม่มีบั๊ก)

Timely — เขียนให้ทันเวลา​

เขียนเทสต์ใกล้เคียงกับตอนเขียน production code ไม่ใช่ผัดวันไปเขียนทีหลัง (หรือไม่เขียนเลย) — ยิ่งทิ้งไว้นาน ยิ่งลืมบริบท ยิ่งยากที่จะเขียนเทสต์ที่ครอบคลุมจริง

🧠 จำง่ายๆ

"เขียนเทสต์ทิ้งไว้นาน เหมือนเก็บกวาดบ้านทิ้งไว้เป็นเดือน" — ยิ่งทิ้งไว้นานยิ่งสะสมมากขึ้นจนยาก จะเริ่ม แทนที่จะทำความสะอาดเล็กๆ ทุกวัน (เขียนเทสต์คู่กับโค้ดทุกครั้ง)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

"Timely" ไม่ได้บังคับว่าต้องเป็น Test-Driven Development (TDD) เสมอไป (เขียนเทสต์ก่อนโค้ด) แม้จะเป็นแนวทางที่สุดโต่งที่สุดของหลักการนี้ก็ตาม — สิ่งที่ FIRST principle เรียกร้องจริงๆ คือ "อย่าแยก" การเขียนเทสต์ออกจาก feedback loop ของการพัฒนา ไม่ว่าจะเขียนก่อน (TDD) หรือเขียนคู่กัน ทันทีหลังเขียนโค้ดเสร็จ (test-after ที่ทำทันที) ก็ตอบโจทย์ "Timely" ได้เหมือนกัน — สิ่งที่ผิด หลักการนี้จริงๆ คือการ "ผัดวัน" ไปเขียนเทสต์ทีหลังเป็น sprint ถัดไป หรือเขียนเป็น task แยก ต่างหากที่ priority ต่ำกว่า feature ใหม่เสมอ ซึ่งมักจบลงที่ "ไม่ได้เขียนเลย" ในทางปฏิบัติจริง

English Summary

FIRST — Fast, Independent, Repeatable, Self-validating, Timely — is a framework for evaluating test quality regardless of language. Fast matters at the suite level too: most unit tests are independent enough to parallelize, and teams running sequential suites waste CI time without touching a single test. Independence violations are often hidden in shared mutable fixtures rather than obvious execution-order dependencies — these bugs frequently surface only when CI switches to parallel execution. Repeatable tests should inject time/randomness as dependencies rather than calling time.Now() directly, since date-dependent bugs can lie dormant for years. Self-validating tests that only check "didn't throw" without asserting actual results inflate coverage numbers without adding real confidence. Timely doesn't strictly require TDD — writing tests immediately after the code (not deferred to a future sprint) satisfies the principle too.