อะไรที่ "ไม่ควร" test
เจ็ดบทที่ผ่านมาของคอร์สนี้สอนวิธี “เขียน test” มาตลอด — unit, sociable, integration, e2e วันนี้เราสลับด้าน มาถามคำถามที่สำคัญไม่แพ้กันแต่ไม่ค่อยมีใครพูดถึง: เมื่อไรควรหยุดเขียน test test ไม่ใช่ของฟรี แต่ละตัวมีต้นทุนดูแลรักษาตลอดอายุของ project test ที่เขียนผิดจุดไม่ได้แค่ไม่มีประโยชน์ — มันเป็นภาระที่ทำให้ refactor ช้าลงและเตือนหลอกบ่อยขึ้น
บทนี้ไม่ผูกกับ project test ตัวใดตัวหนึ่งของ repo kaen-food-ordering (กำลังจัดทำ) โดยเฉพาะ — เหมือนบทที่ 3 มันคือกฎการตัดสินใจที่ควรถามก่อนเพิ่ม test ใหม่ทุกตัว ไม่ว่าจะอยู่ project ไหน
test ที่พังทั้งที่ code ไม่ผิด
หัวข้อที่มีชื่อว่า “test ที่พังทั้งที่ code ไม่ผิด”ทวน code ที่ใช้ตลอดบทนี้ก่อน — จาก Course A (record wrapper พื้นฐาน) และ Course B (OrderLine เป็น entity ตั้งแต่บทที่ 3, state machine กับ domain event ตั้งแต่บทที่ 5–6):
// ทวนจาก Course A บทที่ 2 + Course B บทที่ 3, 5, 6public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);public sealed record Quantity(int Value); // guard: Value < 1 → "จำนวนต้องมีอย่างน้อย 1"
public sealed record Money(decimal Amount, string Currency) // guard: Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"{ public static Money Thb(decimal amount) => new(amount, "THB");}
public abstract class Entity<TId> { public TId Id { get; } protected Entity(TId id); }
public sealed class OrderLine : Entity<OrderLineId> // entity class ตั้งแต่บทที่ 3 ของ Course B ไม่ใช่ record{ 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; }}
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public interface IDomainEvent { DateTimeOffset OccurredOn { get; } }public sealed record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredOn) : IDomainEvent;
public sealed class Order{ private readonly List<IDomainEvent> _domainEvents = new(); // รายละเอียดภายใน — ไม่ใช่สัญญาสาธารณะ
public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines { get; } public Money Total { get; private set; } public OrderStatus Status { get; private set; } public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents; // สัญญาสาธารณะที่ test ควรใช้
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items); // invariant ①: items.Count == 0 → throw InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้")
public void Confirm(); // invariant ⑤: Placed -> Confirmed เท่านั้น ผ่านแล้วปล่อย OrderConfirmed}Brittle TestBrittle Testtest ที่พังเวลา refactor code ทั้งที่พฤติกรรมยังถูกต้อง มักเกิดจากผูกกับรายละเอียดภายใน (private field, ลำดับการเรียก) แทนผลลัพธ์ที่สังเกตได้จากภายนอกProcess คือ test ที่ล้มเวลา refactor code ที่ยังทำงานถูกเหมือนเดิมทุกประการ — ไม่มี bug เกิดขึ้นจริงเลยสักจุด แต่ test สีแดงขึ้นมา บทที่ 3 เรียกคุณสมบัติตรงข้ามว่า resistance to refactoringResistance to Refactoringคุณสมบัติที่ test ไม่พังเวลา refactor code ที่ยังทำงานถูก — test ที่ผูกกับ 'วิธีทำ' (implementation detail) แทน 'ผลลัพธ์' จะเปราะและเตือนหลอกบ่อยProcess ไปแล้ว — brittle test คือ test ที่ ไม่มี คุณสมบัตินั้น สาเหตุที่พบบ่อยที่สุดสามแบบคือ: test private method ที่ควรเป็นรายละเอียดภายในเสรีที่จะเปลี่ยนได้ตลอด, test ที่ assert ลำดับ/จำนวนครั้งของการเรียก (call order) แทนผลลัพธ์ปลายทาง แบบที่บทที่ 5 สาธิตไว้แล้วด้วย mock.Verify(..., Times.Once), และ test ที่ over-mock จนแม้แต่ pure object ก็ยังถูกแทนที่ด้วยของปลอม
version ดิบ — แงะ private field แทนที่จะใช้สัญญาสาธารณะ
หัวข้อที่มีชื่อว่า “version ดิบ — แงะ private field แทนที่จะใช้สัญญาสาธารณะ”สมมติอยาก test ว่า Confirm() ปล่อย OrderConfirmed จริงไหม แล้วดันไปแงะ field ภายใน _domainEvents ตรงๆ ด้วย reflection แทนที่จะใช้ property DomainEvents ที่ Order เปิดให้ใช้อยู่แล้ว:
[Fact]public void Confirm_OnPlacedOrder_RaisesOrderConfirmed_ViaReflection(){ var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(100)) });
order.Confirm();
// ❌ แงะ private field ด้วย reflection แทนที่จะใช้ DomainEvents ที่เป็นสัญญาสาธารณะอยู่แล้ว var field = typeof(Order).GetField("_domainEvents", BindingFlags.NonPublic | BindingFlags.Instance); var events = (List<IDomainEvent>)field!.GetValue(order)!;
Assert.Single(events); Assert.IsType<OrderConfirmed>(events[0]);}test นี้พิสูจน์สิ่งเดียวกับที่ DomainEvents (public property) พิสูจน์ได้อยู่แล้ว แต่ผูกกับชื่อและชนิดของ field ภายในเพิ่มเข้าไปด้วย — ถ้าวันหนึ่งมีคน refactor Order เปลี่ยนชื่อ _domainEvents เป็น _events หรือเปลี่ยนชนิดจาก List<IDomainEvent> เป็น HashSet<IDomainEvent> (พฤติกรรมภายนอกเหมือนเดิมทุกประการ DomainEvents ยังคืนค่าเดิม) test ตัวนี้พังทันทีด้วย NullReferenceException หรือ InvalidCastException ทั้งที่ไม่มี bug อะไรเกิดขึ้นเลย นี่คือ Poorly Written Tests ตามนิยามของ DevIQ — test ผูกกับวิธีทำแทนสัญญาสาธารณะ
ทางแก้ไม่ใช่แค่ “เขียนให้ดีขึ้น” แต่คือลบทิ้งแล้วเขียนใหม่ด้วยสัญญาที่มีอยู่แล้ว:
[Fact]public void Confirm_OnPlacedOrder_RaisesOrderConfirmed(){ var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(100)) });
order.Confirm();
Assert.Equal(OrderStatus.Confirmed, order.Status); var raised = Assert.Single(order.DomainEvents); Assert.IsType<OrderConfirmed>(raised);}พิสูจน์ผลลัพธ์เดียวกันเป๊ะ — แต่ผ่าน Status กับ DomainEvents ที่เป็น public API ล้วนๆ ไม่มี reflection ไม่มีการแตะรายละเอียดภายในสักบรรทัด refactor Order ยังไงก็ได้ ตราบใดที่สัญญาสาธารณะสองจุดนี้ยังถูกต้อง test ก็ยังผ่าน
Test Coverage สูงไม่ได้แปลว่า test ดี
หัวข้อที่มีชื่อว่า “Test Coverage สูงไม่ได้แปลว่า test ดี”ทีมจำนวนไม่น้อยตั้งเป้า “100% coverage” เป็นตัวชี้วัดคุณภาพ test — แต่ Test CoverageTest Coverageสัดส่วน code ที่ถูกรันระหว่าง test วัดแค่ 'การรัน' ไม่ได้วัดว่า assert ตรวจอะไรบ้าง — 100% coverage ไม่ได้แปลว่า test ดีProcess วัดแค่ว่าบรรทัดไหนถูกรันผ่านระหว่าง test ไม่ได้วัดว่า assert ที่ตามมาตรวจผลลัพธ์ถูกต้องจริงไหม 2 test นี้ให้ตัวเลข coverage เท่ากันเป๊ะสำหรับ Confirm() แต่คุณภาพต่างกันคนละขั้ว:
// test A — รัน Confirm() ผ่าน 100% แต่ไม่ตรวจอะไรเลยหลังจากนั้น[Fact]public void Confirm_OnPlacedOrder_DoesNotThrow(){ var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(100)) });
order.Confirm(); // บรรทัดนี้ถูกนับเป็น "รันแล้ว" ในรายงาน coverage — แต่ test จบแค่นี้ ไม่ assert อะไรต่อ}test A รันผ่าน Confirm() ทุกบรรทัดจริง — ตัวนับ coverage ขึ้น 100% สำหรับ method นี้ แต่ถ้าวันหนึ่งมีคนแก้ guard clause ผิดจนสถานะไม่เปลี่ยนหรือลืมปล่อย event test A ก็ยังเขียวเหมือนเดิม เพราะมันไม่เคยตรวจ Status หรือ DomainEvents เลยสักครั้ง เทียบกับ test Confirm_OnPlacedOrder_RaisesOrderConfirmed ด้านบนที่รันบรรทัดเดียวกันเป๊ะ แต่ assert ผลลัพธ์จริง — coverage เท่ากัน กันรีเกรสชันได้ไม่เท่ากันเลย Martin Fowler เคยเขียนเตือนเรื่องนี้ไว้ตรงๆ: ตัวเลข coverage สูงบอกได้แค่ว่า “code ส่วนไหนไม่เคยถูกทดสอบแตะเลย” (สัญญาณเตือนที่มีประโยชน์) แต่ไม่เคยบอกได้ว่า code ส่วนที่ถูกแตะแล้วถูกตรวจสอบดีแค่ไหน — 100% coverage กับ 0% assertion quality เกิดพร้อมกันได้สบายๆ
อะไรที่ไม่ควร test
หัวข้อที่มีชื่อว่า “อะไรที่ไม่ควร test”ไม่ใช่ว่ามีตรรกะให้เขียน test เสมอไป — code บางแบบไม่มีทางพังได้เลยด้วยตัวมันเอง เขียน test ให้มันเป็นแค่ต้นทุนดูแลรักษาเพิ่มโดยไม่เคยจับ bug สักตัว:
// ❌ ไม่คุ้มเขียน — getter เหล่านี้มาจาก record/property syntax ล้วน ๆ ไม่มี if/else ให้พังเลยสักบรรทัด[Fact]public void OrderId_Value_ReturnsConstructorArgument(){ var guid = Guid.NewGuid(); Assert.Equal(guid, new OrderId(guid).Value);}
[Fact]public void OrderLine_ProductId_ReturnsConstructorArgument(){ var productId = new ProductId(Guid.NewGuid()); var line = new OrderLine(new OrderLineId(Guid.NewGuid()), productId, new Quantity(1), Money.Thb(50)); Assert.Equal(productId, line.ProductId);}ทั้ง2 test ข้างบนพิสูจน์แค่ว่า C# compiler สร้าง getter ที่คืนค่าที่รับมาจาก constructor ได้ถูกต้อง — ไม่มี branch ไหนให้ผิดเลย รายการที่ไม่คุ้มเขียน test แบบเดียวกันนี้ยังมีอีกสามกลุ่ม:
- getter/setter ที่ไม่มีตรรกะ เหมือนตัวอย่างด้านบน — ทดสอบ compiler ไม่ใช่ทดสอบกฎธุรกิจ
- DTO mapping ตรงตัว อย่าง
PlaceOrderItem/response model ที่ property 1 map ตรงไปอีก property หนึ่งโดยไม่มีการแปลงหรือเงื่อนไขใดๆ - code framework เช่น MediatR ส่ง request ไปหา handler ที่ลงทะเบียนไว้ถูกไหม หรือ EF Core
SaveChanges()เขียนแถวลง DB จริงไหม — ทีม MediatR และทีม EF Core test สิ่งเหล่านี้ให้แล้วใน project ของเขาเอง หน้าที่เราคือเชื่อ contract ไม่ใช่ test ซ้ำ - third-party library อื่นๆ ที่มี test suite ของตัวเองอยู่แล้ว — ยกเว้นกรณีที่เราเขียน adapter/wrapper คลุมมันไว้ แล้วอยาก testว่า wrapper ของเราเรียกมันถูกวิธีไหม
ถ้าเจอ test แบบนี้ใน codebase จริง คำตอบไม่ใช่ “แก้ให้ดีขึ้น” — คำตอบคือลบทิ้ง test ที่ไม่เคยจับ bug ได้เลยสักครั้งตลอดอายุของมัน ไม่ได้ให้คุณค่าอะไรคืนมาแลกกับต้นทุนดูแลรักษาที่ต้องจ่ายทุกครั้งที่ refactor (ดูเพิ่มที่ Refactoring ใน DevIQ — การลบ test ที่ไม่มีค่าคือส่วนหนึ่งของวินัย refactor เหมือนกัน)
flowchart TD
Q{"บรรทัดนี้มีตรรกะ/กฎธุรกิจให้พังไหม?<br/>invariant, การคำนวณ, state transition"}
Q -->|มี| LOGIC["test มัน<br/>เช่น Order.Confirm(), Money.Round(), PromotionEngine"]
Q -->|ไม่มี| Q2{"เป็นแบบไหน?"}
Q2 -->|getter/setter auto-generated| SKIP1["ข้าม — compiler การันตีให้แล้ว"]
Q2 -->|DTO mapping ตรงตัว| SKIP2["ข้าม — ไม่มี if/else ให้พัง"]
Q2 -->|code framework/3rd-party lib| SKIP3["ข้าม — เจ้าของ lib test ให้แล้ว เชื่อ contract"]
classDef test fill:#16a34a,stroke:#065f46,color:#f8fafc;
classDef skip fill:#64748b,stroke:#334155,color:#f8fafc;
class LOGIC test;
class SKIP1 skip;
class SKIP2 skip;
class SKIP3 skip;
คำบรรยายภาพ: คำถามเดียวที่ต้องถามก่อนเพิ่ม test ใหม่ทุกตัวคือ “บรรทัดนี้มีทางพังได้จริงจากกฎธุรกิจไหม” ถ้ามี (เขียว) เขียน test เสมอ — นี่คือสิ่งที่บทที่ 2–7 ของคอร์สนี้ทำมาตลอด ถ้าไม่มี (เทา) ไม่ว่าจะเป็น getter, DTO หรือ code framework ให้ข้ามไปเลย test ที่เพิ่มเข้ามาตรงนั้นมีแต่ต้นทุน ไม่มีการป้องกัน bug จริงคืนมา
จากกระดาษสู่ code ที่ test แล้ว — สรุปทั้งไตรภาค
หัวข้อที่มีชื่อว่า “จากกระดาษสู่ code ที่ test แล้ว — สรุปทั้งไตรภาค”ย้อนกลับไปจุดเริ่มต้น: คอร์ส Clean Architecture .NET (A) วางโครง4 project ตาม Dependency Rule ของ Uncle Bob — Domain ไม่รู้จัก Infrastructure, Web เป็นแค่ adapter บางๆ คอร์ส DDD in Code (B) เติมเนื้อให้โครงนั้นด้วย tactical pattern เต็มชุด — Value Object, Entity, Aggregate, state machine, Domain Event, Domain Service, Specification, Factory และคอร์สนี้ (C) พิสูจน์ว่าทั้งสองคอร์สที่ผ่านมาทำงานถูกจริง ไล่ตั้งแต่ยูนิต test domain บริสุทธิ์ไปจนถึง e2e ผ่าน API จริง แต่ละบทของ B ที่สร้าง model ไว้ มีบทของ C ที่กลับมา test มันตรงๆ:
| บทของ B (model) | สร้างอะไรไว้ | บทของ C ที่ test มัน |
|---|---|---|
| บทที่ 2 — Value Object | Money.Round(), PriceBreakdown | บทที่ 2 |
| บทที่ 3 — Entity | OrderLine : Entity<OrderLineId> | บทที่ 2, บทนี้ |
| บทที่ 5 — State Machine | OrderStatus, Confirm(), Cancel(), timeout policy | บทที่ 2 (illegal transition), บทที่ 5 (timeout) |
| บทที่ 6 — Domain Events | OrderConfirmed, OrderCancelled, DomainEvents | บทที่ 5 (คืนเงิน), บทนี้ (สัญญาสาธารณะ vs reflection) |
| บทที่ 7 — Service/Specification | PromotionEngine, DeliveryFeeCalculator | บทที่ 2 (ส่วนลดซ้อน), บทที่ 5 (ค่าส่ง) |
| บทที่ 8 — Factory/Repository | OrderFactory, EfOrderRepository | บทที่ 6 (integration ผ่าน Testcontainers) |
นี่คือคำตอบของคำถามที่ บทที่ 8 ของ Course A ทิ้งค้างไว้ — บทนั้นสอนว่า เมื่อไรไม่ควรใช้ Clean Architecture ทั้งชุด (domain ที่เป็น CRUD ล้วนๆ ไม่มี invariant ให้รักษา) บทนี้สอนหลักเดียวกันแต่ในระดับที่ละเอียดกว่า — เมื่อไรไม่ควรเขียน test แม้จะอยู่ใน domain ที่คุ้มค่ากับ Clean Architecture ก็ตาม ทั้งสองบทใช้ไม้บรรทัดเดียวกัน: ลงทุนเฉพาะจุดที่มีกฎธุรกิจจริงให้รักษา ไม่ว่าจะเป็นการลงทุนด้านสถาปัตยกรรมหรือด้าน test ก็ตาม
ถ้านับจากคอร์ส Event Storming ที่ค้นพบ domain ฟู้ดเดลิเวอรีบนกำแพง Post-it ผ่านคอร์สทฤษฎี DDD ที่ปูรากศัพท์และ pattern ทั้งชุด มาถึง A ที่แปลงมันเป็นโครง .NET ที่ dotnet build ผ่าน มาถึง B ที่เติมเนื้อ model ให้ Order รักษา invariant ของตัวเอง และมาจบที่ C ที่พิสูจน์ว่าทั้งหมดนี้ทำงานถูกจริง — เส้นทางที่เดินมาคือ จากกระดาษสู่ code ที่รันได้ ทดสอบได้ และรู้ขอบเขตของตัวเอง ทั้งขอบเขตของสถาปัตยกรรม (A บทที่ 8) และขอบเขตของ test (บทนี้) ขอบคุณที่เดินทางมาครบทั้งไตรภาค — ไปลงมือสร้างของจริงกันต่อได้เลย
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Poorly Written Tests — กลิ่นของ test เปราะที่ผูกกับรายละเอียดภายในแทนสัญญาสาธารณะ ตามตัวอย่าง reflection ข้างบน
- Refactoring — วินัยเดียวกับที่บอกว่าเมื่อไรควรลบ test ที่ไม่เคยจับ bug ได้เลยทิ้งไป
- เมื่อไร “อย่า” ใช้ Clean Architecture (Course A บทที่ 8) — บทปิด Course A ที่ถามคำถามเดียวกันในระดับสถาปัตยกรรม
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3Brittle Test คือ test แบบไหน?