Unit Testing Best Practices & Code Review Checklist
Arrange-Act-Assert (AAA)
โครงเทสต์ที่ดีแยก 3 ส่วนชัดเจน: Arrange (เตรียมข้อมูล/state), Act (เรียกสิ่งที่ทดสอบ), Assert (ตรวจผลลัพธ์) — การแยกชัดเจนช่วยให้อ่านเทสต์เข้าใจเร็ว โดยเฉพาะตอน fail แล้วต้อง diff หาว่าพังตรงไหน
func TestCalculateDiscount(t *testing.T) {
// Arrange
cart := &Cart{Total: 1000, CustomerTier: "gold"}
// Act
discount := CalculateDiscount(cart)
// Assert
assert.Equal(t, 100.0, discount)
}
"AAA = เตรียมวัตถุดิบ, ทำอาหาร, ชิมรส" — สามขั้นตอนทำอาหารพื้นฐาน ถ้าปนกัน (เตรียมวัตถุดิบ ระหว่างทำอาหารไปด้วย) จะสับสนว่าขั้นตอนไหนทำอะไรอยู่ แยกให้ชัดเจนเป็น block ช่วยให้อ่านง่ายและ แก้ไขง่าย
มีกฎเสริมที่ไม่ค่อยมีคนพูดถึงคือ "หนึ่งเทสต์ ควรมีแค่หนึ่ง logical assertion" (ไม่ใช่
แปลว่าเขียน assert ได้แค่บรรทัดเดียว แต่หมายถึง assert หลายบรรทัดที่ตรวจสอบ แง่มุมเดียวกัน
ของผลลัพธ์เดียว ได้ แต่ไม่ควร assert ผลลัพธ์ของการกระทำที่ไม่เกี่ยวข้องกันในเทสต์เดียว) เหตุผล
คือถ้าเทสต์เดียวทดสอบหลายอย่างที่ไม่สัมพันธ์กัน พอ assert แรก fail การรันจะหยุดทันที (ในหลาย
framework) ทำให้ไม่รู้เลยว่า assert ที่เหลือจะผ่านหรือไม่ ต้องแก้ไปทีละรอบแทนที่จะเห็นภาพรวม
ทั้งหมดในรอบเดียว — การแยกเป็นหลายเทสต์เล็กๆ ที่แต่ละตัวโฟกัสแง่มุมเดียว ทำให้เวลา fail เห็นภาพ
รวมของทุกจุดที่พังพร้อมกันในการรันครั้งเดียว ซึ่งประหยัดรอบการแก้บั๊กได้มากในทางปฏิบัติ
Naming Convention ที่สื่อความหมาย
ชื่อเทสต์ควรบอก scenario + expected outcome ให้คนอ่านเข้าใจได้โดยไม่ต้องเปิดดู
implementation เลย — pattern ที่นิยม: MethodName_Scenario_ExpectedResult
// แย่: ไม่บอกอะไรเลยว่าเทสต์อะไร
func TestDivide1(t *testing.T) { ... }
// ดี: อ่านชื่อแล้วรู้ทันทีว่าทดสอบอะไร คาดหวังผลอะไร
func TestDivide_ByZero_ReturnsError(t *testing.T) { ... }
func TestDivide_NegativeNumerator_ReturnsNegativeResult(t *testing.T) { ... }
"ชื่อเทสต์ควรเป็นเหมือนหัวข้อข่าว ไม่ใช่เลขหน้า" — TestDivide1/TestDivide2 เหมือนเลขหน้า
หนังสือพิมพ์ (ไม่บอกอะไรเลยว่าข่าวคืออะไร) ส่วน TestDivide_ByZero_ReturnsError เหมือนหัวข้อข่าว
ที่อ่านแล้วรู้เนื้อหาทันทีโดยไม่ต้องเปิดอ่านรายละเอียด
ชื่อเทสต์ที่ดีมีประโยชน์เกินกว่าแค่ "อ่านง่าย" — มันทำหน้าที่เป็น living documentation ของ
business requirement ที่แม่นยำกว่า comment เสียอีก (เพราะ comment อาจไม่ sync กับโค้ดจริง
แต่ test name ที่ผูกกับเทสต์ที่ run จริงจะ "โกหกไม่ได้" ถ้าเทสต์ยัง pass) ทีมที่ทำ BDD
(Behavior-Driven Development) เอาแนวคิดนี้ไปอีกขั้นด้วยการเขียนชื่อเทสต์เป็นประโยคที่คน
non-technical อ่านเข้าใจได้เลย (เช่น framework อย่าง Ginkgo ใน Go หรือ RSpec ใน Ruby ที่เขียน
Describe("Divide") → Context("when divisor is zero") → It("returns an error") ประกอบ
กันอ่านเป็นประโยคเต็ม) — เป็นเทคนิคที่ทำให้ test suite กลายเป็นเอกสาร requirement ที่อัปเดต
ตัวเองอยู่เสมอ (เพราะถ้า behavior เปลี่ยนแต่เทสต์ไม่เปลี่ยน เทสต์จะ fail ทันที ต่างจาก doc ที่
แยกไฟล์ซึ่งไม่มีกลไกบังคับให้ sync)
Code Review Checklist
- เทสต์ตั้งชื่อสื่อความหมาย (scenario + expected outcome) ไม่ใช่
Test1,Test2 - แยก Arrange-Act-Assert ชัดเจน ไม่ปนกัน
- Mock เฉพาะ external boundary (I/O, network, เวลา) ไม่ mock pure logic/internal method ของตัวเอง (ดู Test Doubles)
- ไม่มีเทสต์ที่ assert แค่ "ไม่ throw exception" โดยไม่เช็คผลลัพธ์จริง
- เทสต์ไม่พึ่ง execution order หรือ shared mutable state ข้ามเทสต์ (independent ตามหลัก FIRST)
- เวลา/random ถูก inject เป็น dependency ไม่เรียก
time.Now()/rand()ตรงๆ ในโค้ดที่ ทดสอบ - Edge case ครอบคลุม: ค่าว่าง (empty), nil, ค่าติดลบ, boundary value (0, max, min)
- เทสต์ที่ fail อ่าน error message แล้วรู้ทันทีว่าพังตรงไหน (ไม่ใช่แค่ "expected true got false" ที่ไม่บอกบริบท)
Arrange-Act-Assert structures a test into clear setup, execution, and verification phases; a lesser-known refinement is keeping each test to one logical assertion about one outcome, since mixed unrelated assertions stop at the first failure and hide the full picture of what's broken. Test names should encode scenario and expected outcome — this doubles as living documentation that can't silently drift out of sync the way comments can, which BDD frameworks (Ginkgo, RSpec) push further by composing test names into full readable sentences. Review checklist: meaningful names, clean AAA separation, mocking only true external boundaries, no assertion-free "didn't crash" tests, independence from execution order/shared state, injected time/randomness, and coverage of empty/nil/negative/boundary edge cases.