ข้าม​ไป​ยัง​เนื้อหา

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 ที่​มัน​ทดสอบ

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-clock
public 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 — ผูกกับลำดับ 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 จริงหลุดผ่านไป]