Poorly Written Tests
มีชุด test แต่ไม่ให้ฟีดแบ็กที่เร็ว แม่นยำ และน่าเชื่อถือ
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Poorly Written Tests คือกลิ่นที่ชุด automated test มีอยู่จริง แต่ล้มเหลวในจุดประสงค์พื้นฐานของมันเอง — ให้ฟีดแบ็กที่เร็ว แม่นยำ และเชื่อถือได้ ทีมมักวัด “คุณภาพ test” ด้วยตัวเลขผิวเผิน เช่น code coverage หรือจำนวน test ที่ผ่าน แต่ตัวเลขเหล่านี้ไม่บอกอะไรเกี่ยวกับว่า test นั้น ดี หรือไม่ test ที่ครอบคลุมทุกบรรทัดแต่ไม่มี assertion ที่มีความหมาย หรือ test ที่ผ่านเสมอไม่ว่า code จะพังแค่ไหน ล้วนนับเป็น coverage แต่ไร้ค่า
Gerard Meszaros ผู้เขียน xUnit Test Patterns แบ่งปัญหาเหล่านี้ออกเป็นสามกลุ่ม: Code Smells (ปัญหาที่เห็นได้ในตัว code test เอง เช่น duplication, magic number), Behavior Smells (test ที่ประพฤติตัวแย่ตอนรัน เช่น ค้าง, ไม่เสถียร) และ Project Smells (ปัญหาระดับทีม/project เช่น build ที่ใช้เวลานานเกินไปเพราะ test ช้า) Poorly Written Tests ในหน้านี้ครอบคลุมทั้งสามระดับ
model คุณภาพที่นิยมใช้เช็ก test คือ FIRST — Fast (เร็ว), Isolated/Independent (แยกจากกัน รันสลับลำดับได้), Repeatable (ให้ผลเดิมทุกครั้งไม่ว่ารันที่ไหน), Self-validating (บอกผ่าน/ไม่ผ่านได้เองไม่ต้องอ่าน output ด้วยตา) และ Timely (เขียนทันเวลา ไม่ใช่หลังจาก code ใช้งานจริงไปนานแล้ว) test ที่ละเมิดหลักเหล่านี้ข้อใดข้อหนึ่งมักจะแสดงกลิ่นแบบใดแบบหนึ่งด้านล่าง
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”อาการที่พบบ่อยที่สุด อ้างอิงอนุกรมวิธาน test smell ที่เป็นที่ยอมรับในวงการ (Meszaros, van Deursen et al.):
- Fragile Test — test พังเมื่อ production code เปลี่ยนในจุดที่ ไม่เกี่ยวกับ พฤติกรรมที่ test นั้นตรวจสอบ เช่น เปลี่ยนชื่อ private field แล้ว test ล้มทั้งชุด สาเหตุมักมาจาก Indirect Testing (ทดสอบผ่านออบเจกต์กลาง) หรือ Overspecified Software (test ยึดติดกับ implementation detail มากเกินไป)
- Obscure Test — อ่านแล้วไม่รู้ว่ากำลังทดสอบอะไร ต้องไล่ตาม setup หลายชั้นหรือ helper method ซ้อนกันจึงจะเข้าใจ
- Assertion Roulette — หลาย assertion เรียงกันใน test เดียวโดยไม่มีข้อความอธิบาย พอ test ล้มก็ไม่รู้ว่าบรรทัดไหนที่พัง
- Eager Test — test เดียวเรียก production method หลายตัวหรือตรวจสอบหลายพฤติกรรมพร้อมกัน กลายเป็น test integration ที่แฝงตัวมาในนาม unit test
- Mystery Guest — test พึ่งพาทรัพยากรภายนอกที่มองไม่เห็นใน code เช่น file, ฐานข้อมูล, เวลาปัจจุบันของเครื่อง ทำให้ผลลัพธ์เปลี่ยนไปตามสภาพแวดล้อม
- Conditional Test Logic — มี if/switch/loop อยู่ใน test ทำให้ตัว test เองมี logic ที่ต้องถูกทดสอบซ้อนอีกที
- Sleepy Test — ใช้
Thread.Sleepเพื่อรอ side effect แทนที่จะซิงโครไนซ์อย่างถูกวิธี ทำให้ test ทั้งช้าและยัง flaky อยู่ดี - Sensitive Equality — เทียบผลลัพธ์ด้วย
ToString()หรือ string ทั้งก้อน พอ format เปลี่ยนนิดเดียว test ก็พังทั้งที่ค่าจริงถูกต้อง - Empty/Unknown Test — test ที่ไม่มี assertion เลย (ผ่านเสมอ ให้ความมั่นใจปลอม) หรือมี code แต่ไม่ชัดว่าจะยืนยันอะไร
- Ignored Test — test ถูกปิดด้วย
[Ignore]/Skipไว้ “แก้ทีหลัง” แล้วไม่มีใครกลับมาแก้อีกเลย - General Fixture / Duplicate Setup — setup ก้อนใหญ่ที่ทุก test ใน class ต้องแบกรับ ทั้งที่ใช้จริงแค่บางส่วน (ดูกลิ่นที่เกี่ยวข้อง Required Setup/Teardown)
สัญญาณเชิงพฤติกรรมของทีม: นักพัฒนาเริ่มพูดว่า “test นี้ล้มบ่อย ๆ เฉย ๆ rerun ก็ผ่าน” (flaky), ไม่มีใครกล้าแตะ test เก่าเพราะไม่เข้าใจว่ามันตรวจอะไร หรือ build สีแดงกลายเป็นเรื่องปกติจนถูกมองข้าม
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”ชุด test ที่แย่อาจ เลวร้ายกว่าไม่มี test เลย เพราะสร้างความมั่นใจลวง (false confidence) ในขณะที่ซ่อนปัญหาจริงไว้ ผลกระทบที่ตามมา:
- สูญเสียฟีดแบ็กเร็ว — จุดประสงค์หลักของ automated test คือให้รู้ผลเร็วว่า code ยังทำงานถูกต้องหรือไม่ test ที่ช้าหรือ flaky ทำให้ทีมรอนานขึ้นหรือต้องรันซ้ำ ฟีดแบ็กที่ควรมาใน 1 นาทีกลายเป็น 10 นาทีหรือถูกข้ามไปเลย
- บั่นทอนความเชื่อมั่นในชุด test ทั้งหมด — เมื่อ test flaky บ่อยเข้า นักพัฒนาจะเริ่ม ignore การล้มเหลว (“รันใหม่เดี๋ยวก็ผ่าน”) จนถึงจุดที่ test ล้มจริงก็ถูกมองข้ามไปด้วย — นี่คือ “the boy who cried wolf” ใน version ซอฟต์แวร์
- ขัดขวางการ refactor — Fragile Test ที่ผูกกับ implementation detail ทำให้ทุกครั้งที่จะปรับโครงสร้าง code (แม้พฤติกรรมภายนอกไม่เปลี่ยน) ต้องไปแก้ test จำนวนมาก กลายเป็นว่า test ที่ควรเป็นตาข่ายนิรภัยกลับเป็นเชือกผูกขา
- Coverage สูงแต่ไม่มีความหมาย — Empty Test หรือ Redundant Assertion (เทียบค่ากับตัวมันเอง) ทำให้ตัวเลข coverage สวยงามแต่ไม่ได้จับ bug จริง
- ต้นทุนบำรุงรักษาสูง — Obscure Test และ Eager Test ทำให้แก้ test แต่ละครั้งใช้เวลานานพอ ๆ กับแก้ production code หรือมากกว่า เพราะต้องไล่อ่านทำความเข้าใจก่อน
- ปิดบัง regression — งานวิจัยเรื่อง test smell ชี้ว่า test ที่มีกลิ่นมักสัมพันธ์กับอัตราการเปลี่ยนแปลง (change-proneness) และอัตรา bug (defect-proneness) ที่สูงขึ้นทั้งในตัว test เองและใน production code ที่มันทดสอบ
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”Assertion Roulette + Eager Test → แยกเป็น test เดี่ยว ๆ
หัวข้อที่มีชื่อว่า “Assertion Roulette + Eager Test → แยกเป็น test เดี่ยว ๆ”test เดียวยืนยันหลายพฤติกรรม ไม่มีข้อความอธิบาย assertion และพึ่งพา wall-clock:
// สเมลล์: Eager Test + Assertion Roulette + Mystery Guest (DateTime.Now)[Fact]public void OrderTests(){ var order = new Order(customerId: 1); order.AddItem("SKU-1", quantity: 2, unitPrice: 100m); order.AddItem("SKU-2", quantity: 1, unitPrice: 50m);
Assert.Equal(250m, order.Total); Assert.Equal(2, order.Items.Count); Assert.True(order.CreatedAt <= DateTime.Now); // ผูกกับเวลาจริงของเครื่อง Assert.Equal("Pending", order.Status);
order.Submit(); Assert.Equal("Submitted", order.Status); Assert.NotNull(order.SubmittedAt);}พอ test นี้ล้ม บอกไม่ได้ทันทีว่าล้มเพราะราคารวมผิด หรือสถานะผิด หรือเวลาไม่ตรง ต้องไล่ทีละบรรทัด และถ้ารันตอนเที่ยงคืนพอดี เงื่อนไข <= DateTime.Now อาจ flaky ได้
หลัง refactor ด้วยเทคนิค Extract Method (แยกเป็น test ย่อยที่มี arrange-act-assert เดี่ยว, ตั้งชื่อบอกเจตนา) และ Introduce Explaining Message พร้อมฉีด clock เข้ามาแทนการเรียก DateTime.Now ตรง ๆ:
// หลัง: 1 test = หนึ่งพฤติกรรม ชื่อบอกเจตนา ไม่พึ่ง wall-clockpublic class OrderTests{ private readonly FakeClock _clock = new(new DateTime(2026, 1, 1, 12, 0, 0));
[Fact] public void AddItem_ควรรวมยอดสินค้าเข้ากับ_Total() { var order = new Order(customerId: 1, _clock); order.AddItem("SKU-1", quantity: 2, unitPrice: 100m); order.AddItem("SKU-2", quantity: 1, unitPrice: 50m);
Assert.Equal(250m, order.Total); }
[Fact] public void Order_ที่สร้างใหม่_ควรมีสถานะเป็น_Pending() { var order = new Order(customerId: 1, _clock);
Assert.Equal(OrderStatus.Pending, order.Status); }
[Fact] public void Submit_ควรเปลี่ยนสถานะเป็น_Submitted_และบันทึกเวลาที่ submit() { var order = new Order(customerId: 1, _clock);
order.Submit();
Assert.Equal(OrderStatus.Submitted, order.Status); Assert.Equal(_clock.UtcNow, order.SubmittedAt); }}ตอนนี้ถ้า Total คำนวณผิด มี test เดียวที่ล้ม ชื่อของมันบอกตรง ๆ ว่าอะไรพัง และผลลัพธ์ deterministic เพราะควบคุมเวลาด้วย fake clock — ไม่มี Mystery Guest หลงเหลืออีกแล้ว
Fragile Test จาก Overspecification → ยืนยันพฤติกรรม ไม่ใช่ implementation
หัวข้อที่มีชื่อว่า “Fragile Test จาก Overspecification → ยืนยันพฤติกรรม ไม่ใช่ implementation”// สเมลล์: Fragile Test — ผูกกับลำดับ private call ภายใน ไม่ใช่ผลลัพธ์ภายนอก[Fact]public void CalculateDiscount_ทำงานถูกต้อง(){ var mockLogger = new Mock<ILogger>(); var calculator = new PriceCalculator(mockLogger.Object);
calculator.CalculateDiscount(customerTier: "Gold", amount: 1000m);
mockLogger.Verify(l => l.Log("Checking tier"), Times.Once); mockLogger.Verify(l => l.Log("Applying 10% discount"), Times.Once); mockLogger.Verify(l => l.Log("Done"), Times.Once);}ตัว test นี้จะพังทันทีที่มีใครแก้ log message หรือลำดับการ log แม้ค่าส่วนลดที่คำนวณได้จะยังถูกต้องอยู่ นี่คือ Indirect Testing ที่ทำให้เกิด Fragile Test
แก้ด้วยการยึดหลัก Assert Actual Outcome, Not Implementation Path — ทดสอบผ่าน public contract:
// หลัง: ทดสอบผลลัพธ์ที่สังเกตได้จากภายนอก ไม่สนใจว่าข้างในทำอย่างไร[Fact]public void CalculateDiscount_ลูกค้าระดับ_Gold_ควรได้ส่วนลด_10_เปอร์เซ็นต์(){ var calculator = new PriceCalculator(NullLogger.Instance);
var discount = calculator.CalculateDiscount(customerTier: "Gold", amount: 1000m);
Assert.Equal(100m, discount);}แผนภาพ: ห่วงฟีดแบ็กที่ควรจะเป็น เทียบกับที่ถูกกลิ่นนี้ทำลาย
หัวข้อที่มีชื่อว่า “แผนภาพ: ห่วงฟีดแบ็กที่ควรจะเป็น เทียบกับที่ถูกกลิ่นนี้ทำลาย”flowchart LR
Code[แก้ code] --> Run[รันชุด test]
Run --> Fast{เร็ว และ เชื่อถือได้ ไหม}
Fast -->|ใช่ FIRST| Trust[ทีมเชื่อผลลัพธ์]
Trust --> Refactor[กล้า refactor ต่อ]
Fast -->|ไม่ Fragile หรือ Flaky| Ignore[ทีมเริ่มเมิน build แดง]
Ignore --> Hidden[bug จริงหลุดผ่านไป]
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Test Driven Development
- Red, Green, Refactor
- Required Setup/Teardown
- Behavior-Driven Development
- Long Method
- Fail Fast