สี่เสาหลักของ test ที่ดี
บทที่แล้วเราเขียนยูนิต test ให้ Order, Money.Round(), PriceBreakdown และ PromotionEngine ไปหลายตัว — แต่ยังไม่เคยถามตรงๆ ว่า อะไรทำให้ test หนึ่ง “ดี” test ที่ผ่านสีเขียวทุกครั้งไม่ได้แปลว่า test นั้นดีเสมอไป และ test ที่ล้มบ่อยก็ไม่ได้แปลว่าแย่เสมอไปเช่นกัน บทนี้หยิบเกณฑ์จากหนังสือ Unit Testing Principles, Practices, and Patterns ของ Vladimir Khorikov มาเป็นไม้บรรทัดวัดคุณภาพ test ให้ชัดเจน แล้วใช้มันอธิบายว่าทำไมยูนิต test บน domain บริสุทธิ์อย่าง Order ถึงเป็น test ที่ “คุ้ม” ที่สุดในพีระมิดทั้งหมด
บทนี้ไม่ผูกกับ project test ตัวใดตัวหนึ่งของ repo kaen-food-ordering (กำลังจัดทำ) โดยเฉพาะ — มันคือเกณฑ์ที่ใช้ตัดสิน test ในทุก project ตั้งแต่ FoodOrdering.Domain.Tests/ ไปจนถึง FoodOrdering.Api.Tests/
สี่เสาหลักของ test ที่ดี (Khorikov)
หัวข้อที่มีชื่อว่า “สี่เสาหลักของ test ที่ดี (Khorikov)”Four PillarsFour Pillars of a Good Testเกณฑ์สี่ข้อของ test ที่ดีจากหนังสือของ Khorikov: กันรีเกรสชัน (protection against regressions), ทนต่อการ refactor (resistance to refactoring), ฟีดแบ็กเร็ว (fast feedback), และดูแลรักษาง่าย (maintainability) — test ที่ดีต้องได้สมดุลของทั้งสี่Process คือเกณฑ์สี่ข้อที่ Khorikov ใช้ชั่งน้ำหนักว่า test หนึ่งตัว “คุ้มค่า” แค่ไหน:
- กันรีเกรสชัน (protection against regressions) — test ต้องจับ bug ได้จริงเมื่อพฤติกรรมเปลี่ยนไปโดยไม่ตั้งใจ ยิ่ง code ที่ครอบคลุมซับซ้อนและมีเงื่อนไขเยอะ ยิ่งมีค่าตรงนี้สูง
- Resistance to RefactoringResistance to Refactoringคุณสมบัติที่ test ไม่พังเวลา refactor code ที่ยังทำงานถูก — test ที่ผูกกับ 'วิธีทำ' (implementation detail) แทน 'ผลลัพธ์' จะเปราะและเตือนหลอกบ่อยProcess — test ต้อง ไม่ ล้มเมื่อ refactor code ที่ยังทำงานถูกต้องเหมือนเดิม (เปลี่ยนแค่โครงสร้างภายใน ไม่เปลี่ยนพฤติกรรมภายนอก) test ที่ทนได้คือ test ที่ผูกกับ “ผลลัพธ์ที่สังเกตได้จากภายนอก” ไม่ใช่ “วิธีทำข้างใน”
- ฟีดแบ็กเร็ว (fast feedback) — test ยิ่งรันเร็ว ยิ่งเรียกใช้บ่อยได้ ยูนิต test ที่รันในมิลลิวินาทีเรียกได้ทุกครั้งที่เซฟ file ส่วน test ที่ต้องรอ container หรือยิง HTTP จริงกินเวลาเป็นวินาทีขึ้นไป
- ดูแลรักษาง่าย (maintainability) — test อ่านง่ายแค่ไหน, setup ซับซ้อนแค่ไหน, พังแล้วแก้ยากแค่ไหน test ที่ arrange ยุ่งเหยิงหรือ mock ซ้อน mock ดูแลรักษายากกว่า test ที่ arrange ตรงไปตรงมา
เกณฑ์ทั้งสี่ข้อนี้ไม่ใช่แค่ทฤษฎีลอยๆ — มันคือคำถามสี่ข้อที่ควรถามทุกครั้งก่อนเขียน test ใหม่ หรือก่อนตัดสินใจว่าจะ mock อะไรบ้าง
เลือกได้ไม่ครบทุกเสา — trade-off ที่ต้องยอมรับ
หัวข้อที่มีชื่อว่า “เลือกได้ไม่ครบทุกเสา — trade-off ที่ต้องยอมรับ”ข่าวร้ายคือไม่มี 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 เท่านั้น ไม่ใช่ recordpublic 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, เปลี่ยน algorithmAggregateเป็น loop ธรรมดา หรือเพิ่ม field ภายในใหม่ ตราบใดที่Totalยังคำนวณถูก test ก็ยังผ่าน - ฟีดแบ็กเร็วสุด — ไม่มี I/O ไม่มีการรอ container รันได้ในมิลลิวินาที
- ดูแลรักษาง่าย — arrange แค่สร้าง
OrderLineสองสามตัว ไม่มี mock setup ให้ดูแล
นี่คือ test ที่ assert ผ่าน public API ล้วนๆ:
[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 ที่เปราะ — เมื่อ test ผูกกับวิธีทำแทนผลลัพธ์
หัวข้อที่มีชื่อว่า “test ที่เปราะ — เมื่อ test ผูกกับวิธีทำแทนผลลัพธ์”เทียบกับ 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)
False Positive กับ False Negative
หัวข้อที่มีชื่อว่า “False Positive กับ False Negative”สองเสาแรก (กันรีเกรสชัน + ทนต่อการ 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:
- 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 มีอะไรบ้าง?