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

สี่​เสา​หลัก​ของ test ที่​ดี

บท​ที่​แล้ว​เรา​เขียน​ยูนิต test ให้ Order, Money.Round(), PriceBreakdown และ PromotionEngine ไป​หลาย​ตัว — แต่​ยัง​ไม่​เคย​ถามตรงๆ ว่า อะไร​ทำให้ test หนึ่ง “ดี” test ที่​ผ่าน​สี​เขียว​ทุก​ครั้ง​ไม่​ได้​แปล​ว่า test นั้น​ดี​เสมอ​ไป และ test ที่​ล้ม​บ่อย​ก็​ไม่​ได้​แปล​ว่า​แย่​เสมอ​ไป​เช่น​กัน บท​นี้​หยิบ​เกณฑ์​จาก​หนังสือ Unit Testing Principles, Practices, and Patterns ของ Vladimir Khorikov มา​เป็น​ไม้บรรทัด​วัด​คุณภาพ test ให้​ชัดเจน แล้ว​ใช้​มัน​อธิบาย​ว่า​ทำไม​ยูนิต test บน domain บริสุทธิ์​อย่าง Order ถึง​เป็น test ที่ “คุ้ม” ที่สุด​ใน​พีระมิด​ทั้งหมด

📦 code ตัวอย่าง

บท​นี้​ไม่​ผูก​กับ project test ตัว​ใด​ตัว​หนึ่ง​ของ repo kaen-food-ordering (กำลัง​จัด​ทำ) โดย​เฉพาะ — มัน​คือ​เกณฑ์​ที่​ใช้​ตัดสิน test ใน​ทุก project ตั้งแต่ FoodOrdering.Domain.Tests/ ไป​จนถึง FoodOrdering.Api.Tests/

Four PillarsFour Pillars of a Good Testเกณฑ์​สี่​ข้อ​ของ test ที่​ดี​จาก​หนังสือ​ของ Khorikov: กัน​รี​เก​รส​ชัน (protection against regressions), ทน​ต่อ​การ refactor (resistance to refactoring), ฟีด​แบ็ก​เร็ว (fast feedback), และ​ดูแล​รักษา​ง่าย (maintainability) — test ที่​ดี​ต้อง​ได้​สมดุล​ของ​ทั้ง​สี่Process คือ​เกณฑ์​สี่​ข้อ​ที่ Khorikov ใช้​ชั่ง​น้ำหนัก​ว่า test หนึ่ง​ตัว “คุ้ม​ค่า” แค่​ไหน:

  1. กัน​รี​เก​รส​ชัน (protection against regressions) — test ต้อง​จับ bug ได้​จริง​เมื่อ​พฤติกรรม​เปลี่ยน​ไป​โดย​ไม่​ตั้งใจ ยิ่ง code ที่​ครอบคลุม​ซับซ้อน​และ​มี​เงื่อนไข​เยอะ ยิ่ง​มี​ค่า​ตรง​นี้​สูง
  2. Resistance to RefactoringResistance to Refactoringคุณสมบัติ​ที่ test ไม่​พัง​เวลา refactor code ที่​ยัง​ทำงาน​ถูก — test ที่​ผูก​กับ 'วิธี​ทำ' (implementation detail) แทน 'ผลลัพธ์' จะ​เปราะ​และ​เตือน​หลอก​บ่อยProcess — test ต้อง ไม่ ล้ม​เมื่อ refactor code ที่​ยัง​ทำงาน​ถูกต้อง​เหมือน​เดิม (เปลี่ยน​แค่​โครงสร้าง​ภายใน ไม่​เปลี่ยน​พฤติกรรม​ภายนอก) test ที่​ทน​ได้​คือ test ที่​ผูก​กับ “ผลลัพธ์​ที่​สังเกต​ได้​จาก​ภายนอก” ไม่ใช่ “วิธี​ทำ​ข้าง​ใน”
  3. ฟีด​แบ็ก​เร็ว (fast feedback) — test ยิ่ง​รัน​เร็ว ยิ่ง​เรียก​ใช้​บ่อย​ได้ ยูนิต test ที่​รัน​ใน​มิลลิ​วินาที​เรียก​ได้​ทุก​ครั้ง​ที่​เซฟ file ส่วน test ที่​ต้อง​รอ container หรือ​ยิง HTTP จริง​กิน​เวลา​เป็น​วินาที​ขึ้น​ไป
  4. ดูแล​รักษา​ง่าย (maintainability) — test อ่าน​ง่าย​แค่​ไหน, setup ซับซ้อน​แค่​ไหน, พัง​แล้ว​แก้​ยาก​แค่​ไหน test ที่ arrange ยุ่งเหยิง​หรือ mock ซ้อน mock ดูแล​รักษา​ยาก​กว่า test ที่ arrange ตรง​ไป​ตรง​มา

เกณฑ์​ทั้ง​สี่​ข้อ​นี้​ไม่ใช่​แค่ทฤษฎีลอยๆ — มัน​คือ​คำถาม​สี่​ข้อ​ที่​ควร​ถาม​ทุก​ครั้ง​ก่อน​เขียน test ใหม่ หรือ​ก่อน​ตัดสิน​ใจ​ว่า​จะ mock อะไร​บ้าง

ข่าว​ร้าย​คือ​ไม่มี test แบบ​ไหน​ได้​คะแนน​เต็ม​ทั้ง​สี่​เสา​พร้อม​กัน — แต่ละ​แบบ​ต้อง​ยอม​สละ​บาง​เสา​เพื่อ​ได้​เสา​อื่น​มา:

  • e2e ที่​ยิง​ผ่าน API จริง (เช่น​บท​ที่ 7 ของ​คอร์ส​นี้​ที่​จะ​ยิง​ผ่าน WebApplicationFactory) ให้ กัน​รี​เก​รส​ชัน​สูงสุด เพราะ​พิสูจน์​ทั้ง​ระบบ​ทำงาน​ร่วม​กัน​จริง แต่​แลก​มา​ด้วย ฟีด​แบ็ก​เร็ว​ต่ำ​สุด — แต่ละ test กิน​เวลา​เป็น​วินาที เรียก​ใช้​บ่อยๆ ระหว่าง​เขียน code ไม่​ไหว
  • test ที่ mock ทุก​จุด​จน​เกิน​จำเป็น (mock แม้แต่ pure object ที่​ไม่มี dependency ให้ mock) ได้ ฟีด​แบ็ก​เร็ว เพราะ​ไม่​ต้อง​รอ I/O อะไร​เลย แต่​แลก​มา​ด้วย resistance to refactoring ต่ำ — test ผูก​ติด​กับ “มี​การ​เรียก​อะไร​เกิด​ขึ้น​บ้าง​ข้าง​ใน” แทนที่​จะ​ผูก​กับ​ผลลัพธ์​ที่​สังเกต​ได้​จาก​ภายนอก พอ refactor โครงสร้าง​ภายใน (แม้​พฤติกรรม​ยัง​ถูก​เป๊ะ) test ก็​ล้ม​ทันที

ไม่มี​ทาง​ลัด​ที่​ได้​ครบ​ทั้ง​สี่​เสา​ใน test เดียว — สิ่ง​ที่​ทำได้​คือ​เลือก “สัดส่วน” ของ test แต่ละ​แบบ​ให้​เหมาะ​กับ​สิ่ง​ที่​กำลัง​ทดสอบ ภาพ​นี้​สรุป trade-off ตรง​นี้​ไว้:

flowchart LR
  P1["กันรีเกรสชัน<br/>protection against regressions"]
  P2["ทนต่อการ refactor<br/>resistance to refactoring"]
  P3["ฟีดแบ็กเร็ว<br/>fast feedback"]
  P4["ดูแลรักษาง่าย<br/>maintainability"]
  SCORE{{"คุณค่าของ test<br/>= f(4 เสา)"}}
  P1 --> SCORE
  P2 --> SCORE
  P3 --> SCORE
  P4 --> SCORE
  SCORE --> UNIT["ยูนิต test<br/>บน domain บริสุทธิ์<br/>สูงครบทั้งสี่เสา"]
  SCORE --> MOCKY["test mock<br/>เกินจำเป็น<br/>เร็ว แต่ล้มเสาที่ 2"]
  SCORE --> E2E["e2e ผ่าน API จริง<br/>กันรีเกรสชันสูง<br/>แต่ล้มเสาที่ 3"]
  classDef good fill:#16a34a,stroke:#065f46,color:#f8fafc;
  classDef bad fill:#dc2626,stroke:#7f1d1d,color:#f8fafc;
  classDef mid fill:#f59e0b,stroke:#92400e,color:#1c1917;
  class UNIT good;
  class MOCKY bad;
  class E2E mid;

คำ​บรรยาย​ภาพ: สี่​เสา​หลัก (ซ้าย) ป้อน​เข้าไป​เป็น​ตัว​ชี้​วัด​คุณค่า​ของ test หนึ่ง​ตัว (กลาง) test สาม​แบบ​ทาง​ขวา​สอบ​ผ่าน​เกณฑ์​ไม่​เท่า​กัน — ยูนิต test บน domain บริสุทธิ์ (เขียว) ได้​คะแนน​สูง​ครบ​ทั้ง​สี่​เสา test ที่ mock เกิน​จำเป็น (แดง) เร็ว​ก็​จริง​แต่​ล้ม​เสา​ที่ 2 เพราะ​เปราะ​เวลา refactor ส่วน e2e (ส้ม) กัน​รี​เก​รส​ชัน​ได้​สูงสุด​แต่​ล้ม​เสา​ที่ 3 เพราะ​ช้า

ทำไม​ยูนิต test domain บริสุทธิ์​ถึง​ได้​คะแนน​สูงสุด​ทั้ง​สี่​เสา

หัวข้อ​ที่​มีชื่อ​ว่า “ทำไม​ยูนิต test domain บริสุทธิ์​ถึง​ได้​คะแนน​สูงสุด​ทั้ง​สี่​เสา”

ทวน​ชนิด​ที่ Order ใช้​อยู่​ก่อน — ทุก​ตัว​มา​จาก Course A/B เดิม ไม่มี​อะไร​ใหม่:

// ทวนจาก FoodOrdering.Domain/Orders/ (Course A บทที่ 2, ยกระดับใน Course B บทที่ 2–4)
public sealed record Money(decimal Amount, string Currency) // guard Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"
{
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้"
public static Money operator *(Money unitPrice, int quantity);
}
public sealed record Quantity(int Value); // guard Value<1 → "จำนวนต้องมีอย่างน้อย 1"
public sealed record OrderId(Guid Value);
public sealed record OrderLineId(Guid Value);
public sealed record ProductId(Guid Value);
// OrderLine เป็น entity class (Course B บทที่ 3) เทียบด้วย Id เท่านั้น ไม่ใช่ record
public abstract class Entity<TId>
{
public TId Id { get; }
protected Entity(TId id) => Id = id;
}
public sealed class OrderLine : Entity<OrderLineId>
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice)
: base(id)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
// Order — aggregate root (Course B บทที่ 4 ทวน Total คำนวณเองจากบรรทัด)
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
public OrderId Id { get; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Money Total { get; private set; }
private Order(OrderId id, IEnumerable<OrderLine> lines)
{
Id = id;
_lines.AddRange(lines);
Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value);
}
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items)
{
if (items.Count == 0)
throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
return new Order(id, items);
}
}

Order.Place(...) ไม่มี dependency ภายนอก​แม้แต่​ตัว​เดียว — ไม่มี IMenuCatalog, ไม่มี IPaymentGateway, ไม่มี​อะไร​ให้ mock เลย เพราะ​ฉะนั้น test ที่​เรียก​มัน​ก็​ไม่มี​อะไร​ให้ mock ตาม​ไป​ด้วย นี่​คือ​เหตุผล​ที่​มัน​ได้​คะแนน​สูง​ทั้ง​สี่​เสา​พร้อม​กัน:

  • กัน​รี​เก​รส​ชัน​สูง — assert ผลลัพธ์​จริง (Total, exception) จับ bug ได้​ทันที​ถ้า​มี​คน​แก้​สูตร​คำนวณ​ผิด
  • ทน​ต่อ​การ refactor สูง — test ไม่รู้จัก​โครงสร้าง​ภายใน​ของ Order เลย ไม่​ว่า​จะ​เปลี่ยน _lines เป็น array, เปลี่ยน algorithm Aggregate เป็น loop ธรรมดา หรือ​เพิ่ม field ภายใน​ใหม่ ตราบ​ใด​ที่ Total ยัง​คำนวณ​ถูก test ก็​ยัง​ผ่าน
  • ฟีด​แบ็ก​เร็ว​สุด — ไม่มี I/O ไม่มี​การ​รอ container รัน​ได้​ใน​มิลลิ​วินาที
  • ดูแล​รักษา​ง่าย — arrange แค่​สร้าง OrderLine สอง​สาม​ตัว ไม่มี mock setup ให้​ดูแล

นี่​คือ test ที่ assert ผ่าน public API ล้วนๆ:

FoodOrdering.Domain.Tests/OrderTests.cs
[Fact]
public void Place_WithTwoLines_TotalIsSumOfLines()
{
// Arrange
var lines = new List<OrderLine>
{
new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(50)),
new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(30)),
};
// Act
var order = Order.Place(new OrderId(Guid.NewGuid()), lines);
// Assert
Assert.Equal(Money.Thb(130), order.Total);
}

test นี้​ทวน​ตัวอย่าง Order เป็น Aggregate จาก​คอร์ส DDD in Code — assert ผ่าน order.Total ซึ่ง​เป็น​ผลลัพธ์​ที่ผู้ใช้จริงๆ มอง​เห็น (ยอด​รวม​ที่​ต้อง​เรียก​เก็บ​เงิน) ไม่ใช่​รายละเอียด​ว่า Order เก็บ​บรรทัด​ไว้​ยังไง​ข้าง​ใน

เทียบ​กับ test ที่​ทดสอบ​สถานการณ์​เดียวกันเป๊ะ แต่​เลือก assert ผิด​จุด:

// ❌ เปราะ — เจาะเข้าไปอ่าน private field ตรง ๆ ด้วย reflection แทนอ่านผ่าน public API
[Fact]
public void Place_WithTwoLines_StoresTwoInternalLines()
{
// Arrange (เตรียมแบบเดียวกับ test ที่ดีด้านบน)
var lines = new List<OrderLine>
{
new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(50)),
new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(30)),
};
var order = Order.Place(new OrderId(Guid.NewGuid()), lines);
var linesField = typeof(Order).GetField("_lines", BindingFlags.NonPublic | BindingFlags.Instance);
var internalLines = (List<OrderLine>)linesField!.GetValue(order)!;
Assert.Equal(2, internalLines.Count);
}

test ตัว​นี้​ทดสอบ​พฤติกรรม​เดียวกัน แต่ ล้ม​เสา​ที่​สอง​ทันที: มัน​ไม่​ได้ assert ว่ายอด​คำนวณ​ถูก​เลย​ด้วย​ซ้ำ (ถ้า​มี​คน​คำนวณ Total ผิด test นี้​ก็​ยัง​ผ่าน — ล้ม​เสา​ที่​หนึ่ง​ด้วย) แล้ว​ยัง​ผูก​ติด​กับ​ชื่อ field _lines ตรงๆ ผ่าน reflection — แค่​เปลี่ยน​ชื่อ field หรือ​เปลี่ยน​โครงสร้าง​เก็บ​ข้อมูล​ภายใน (ที่​พฤติกรรม​ภายนอก​ยัง​ถูก​ทุก​อย่าง) test นี้​ก็​พัง​ทันที นี่​คือ​รูปแบบ​เดียว​กับ​การ “verify ว่า​มี​การ​เรียก method ภายใน​เกิด​ขึ้น” ที่​จะ​เจอ​เต็มๆ ใน​บท​ที่ 4 (Test Doubles) — ทั้ง​คู่​ผูก​กับ วิธี​ทำ ไม่ใช่ ผลลัพธ์ จึง​เปราะพอๆ กัน (ดู​เพิ่ม​ที่ Poorly Written Tests ใน DevIQ)

สอง​เสา​แรก (กัน​รี​เก​รส​ชัน + ทน​ต่อ​การ refactor) จริงๆ แล้ว​คือ​การ​ควบคุม​ความ​ผิดพลาด​สอง​แบบ​ที่​ตรง​ข้าม​กัน:

  • False negative — test ผ่าน (เขียว) ทั้ง​ที่​มี bug จริง​อยู่ มัก​เกิด​จาก test ที่ assert ไม่​ครบ​หรือ​ไม่ assert อะไร​เลย (version ดิบ​แบบ Assert.NotNull(order) ที่​บท​ที่ 2 เตือน​ไว้) — เสา​ที่​หนึ่ง​ต่ำ
  • False positive — test ล้ม (แดง) ทั้ง​ที่ code ยัง​ทำงาน​ถูก​ทุก​อย่าง มัก​เกิด​จาก test ที่​ผูก​กับ​รายละเอียด​ภายใน เช่น​ตัวอย่าง Place_WithTwoLines_StoresTwoInternalLines ด้าน​บน — เสา​ที่​สอง​ต่ำ

test ที่​ทน​ต่อ​การ refactor สูง​คือ test ที่​ลด false positive ให้​เหลือ​น้อย​ที่สุด — มัน​จะ​แดง​ก็​ต่อ​เมื่อ​พฤติกรรม​ที่​สังเกต​ได้​จาก​ภายนอก​เปลี่ยนไปจริงๆ เท่านั้น ไม่ใช่​แดง​เพราะ​มี​คน​ไป​แตะ​รายละเอียด​ภายใน​ที่​ไม่​ควร​มี​ใคร​สนใจ​ตั้งแต่​แรก นี่​คือ​สิ่ง​ที่​ทำให้​ทีม​กล้า refactor — ถ้า test แดง​บ่อย​โดยที่​ระบบ​ยัง​ทำงาน​ถูก คน​ก็​จะ​เริ่ม​ไม่​เชื่อ​สัญญาณ​จาก test (ดู​เพิ่ม​ที่ Refactoring ใน DevIQ)

ย้อน​กลับ​ไป​ที่ Test PyramidTest Pyramidกรอบ​คิด​ของ Fowler ที่​จัด​สัดส่วน test เป็น​ชั้น: unit test เยอะ​สุด​อยู่​ฐาน (เร็ว ถูก) → integration test รอง​ลง​มา → e2e test น้อย​สุด​อยู่​ยอด (ช้า แพง แต่​มั่นใจ​สุด) — ยิ่ง​ขึ้น​สูง​ยิ่ง test น้อย​ลง​แต่​ขอบเขต​กว้าง​ขึ้นProcess จาก​บท​ที่ 1 — ทำไม​ยูนิต test ถึง​ควร​มี​เยอะ​สุด​ที่​ฐาน และ e2e ถึง​ควร​มี​น้อย​สุด​ที่​ยอด ตอน​นี้​ตอบ​ได้​ด้วย​สี่​เสาหลักตรงๆ: ยูนิต test บน domain บริสุทธิ์​ได้​คะแนน​สูง​เกือบ​ทุก​เสา​พร้อม​กัน (ต้นทุน​ต่ำ ได้​ผล​ตอบแทน​สูง) จึง​คุ้ม​ที่​จะ​มี​เยอะ​ที่สุด ส่วน integration และ e2e แม้​กัน​รี​เก​รส​ชัน​ได้​สูง​กว่า (เพราะ​พิสูจน์​การ​ต่อ​กับ​ของ​จริง) แต่​จ่าย​ด้วย​ฟีด​แบ็ก​ที่​ช้า​ลง​และ​ดูแล​รักษา​ยาก​ขึ้น (ต้อง​มี container, ต้อง seed ข้อมูล) จึง​ควร​มี​เท่า​ที่​จำเป็น​เท่านั้น — สัดส่วน​พีระมิด​ไม่ใช่​กฎ​ที่​ต้อง​ท่องจำ แต่​เป็นผลลัพธ์ตรงๆ จาก​การ​เอา​สี่​เสา​หลัก​มา​ชั่ง​น้ำหนัก​แต่ละ​ชั้น​ของ​พีระมิด​นั่นเอง


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Refactoring — การ​จัด​โครงสร้าง code ใหม่​โดย​ไม่​เปลี่ยน​พฤติกรรม คือ​สิ่ง​ที่ test resistance to refactoring สูง​ต้อง​รองรับ​ได้​โดย​ไม่​ล้ม
  • Poorly Written Tests — กลิ่น​ของ test ที่​ผูก​กับ​รายละเอียด​ภายใน​แทน​ผลลัพธ์ เหมือน​ตัวอย่าง test เปราะ​ใน​บท​นี้
  • Order เป็น Aggregate (คอร์ส DDD in Code บท​ที่ 4) — ที่มา​ของ Total ที่ test resistant ใน​บท​นี้ assert ผ่าน

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

สี่เสาหลักของ test ที่ดีตามแนวคิดของ Khorikov มีอะไรบ้าง?