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

อะไร​ที่ "ไม่​ควร" test

เจ็ด​บท​ที่​ผ่าน​มา​ของ​คอร์ส​นี้​สอน​วิธี “เขียน test” มา​ตลอด — unit, sociable, integration, e2e วัน​นี้​เรา​สลับ​ด้าน มา​ถาม​คำถาม​ที่​สำคัญ​ไม่​แพ้​กัน​แต่​ไม่​ค่อย​มี​ใคร​พูด​ถึง: เมื่อไร​ควร​หยุด​เขียน test test ไม่ใช่​ของ​ฟรี แต่ละ​ตัว​มี​ต้นทุน​ดูแล​รักษา​ตลอด​อายุ​ของ project test ที่​เขียน​ผิด​จุด​ไม่​ได้​แค่​ไม่มี​ประโยชน์ — มัน​เป็น​ภาระ​ที่​ทำให้ refactor ช้า​ลง​และ​เตือน​หลอก​บ่อย​ขึ้น

📦 code ตัวอย่าง

บท​นี้​ไม่​ผูก​กับ project test ตัว​ใด​ตัว​หนึ่ง​ของ repo kaen-food-ordering (กำลัง​จัด​ทำ) โดย​เฉพาะ — เหมือน​บท​ที่ 3 มัน​คือ​กฎ​การ​ตัดสิน​ใจ​ที่​ควร​ถาม​ก่อน​เพิ่ม test ใหม่​ทุก​ตัว ไม่​ว่า​จะ​อยู่ project ไหน

ทวน code ที่​ใช้​ตลอด​บท​นี้​ก่อน — จาก Course A (record wrapper พื้นฐาน) และ Course B (OrderLine เป็น entity ตั้งแต่​บท​ที่ 3, state machine กับ domain event ตั้งแต่​บท​ที่ 5–6):

// ทวนจาก Course A บทที่ 2 + Course B บทที่ 3, 5, 6
public 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 ก็​ยัง​ถูก​แทนที่​ด้วย​ของ​ปลอม

สมมติ​อยาก test ว่า Confirm() ปล่อย OrderConfirmed จริง​ไหม แล้ว​ดัน​ไป​แงะ field ภายใน _domainEvents ตรงๆ ด้วย reflection แทนที่​จะ​ใช้ property DomainEvents ที่ Order เปิด​ให้​ใช้​อยู่​แล้ว:

❌ version ดิบ — ผูก​กับ​ชื่อ field ภายใน​ที่​ไม่มี​ใคร​สัญญา​ว่า​จะ​ไม่​เปลี่ยน
FoodOrdering.Domain.Tests/OrderTests.cs
[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 ก็​ยัง​ผ่าน

ทีม​จำนวน​ไม่​น้อย​ตั้ง​เป้า “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 เสมอ​ไป — 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 จริง​คืน​มา

ย้อน​กลับ​ไป​จุด​เริ่มต้น: คอร์ส 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 ObjectMoney.Round(), PriceBreakdownบท​ที่ 2
บท​ที่ 3 — EntityOrderLine : Entity<OrderLineId>บท​ที่ 2, บท​นี้
บท​ที่ 5 — State MachineOrderStatus, Confirm(), Cancel(), timeout policyบท​ที่ 2 (illegal transition), บท​ที่ 5 (timeout)
บท​ที่ 6 — Domain EventsOrderConfirmed, OrderCancelled, DomainEventsบท​ที่ 5 (คืน​เงิน), บท​นี้ (สัญญา​สาธารณะ vs reflection)
บท​ที่ 7 — Service/SpecificationPromotionEngine, DeliveryFeeCalculatorบท​ที่ 2 (ส่วนลด​ซ้อน), บท​ที่ 5 (ค่า​ส่ง)
บท​ที่ 8 — Factory/RepositoryOrderFactory, 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

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

  • Poorly Written Tests — กลิ่น​ของ test เปราะ​ที่​ผูก​กับ​รายละเอียด​ภายใน​แทน​สัญญา​สาธารณะ ตาม​ตัวอย่าง reflection ข้าง​บน
  • Refactoring — วินัย​เดียว​กับ​ที่​บอกว่า​เมื่อไร​ควร​ลบ test ที่​ไม่​เคย​จับ bug ได้​เลย​ทิ้ง​ไป
  • เมื่อไร “อย่า” ใช้ Clean Architecture (Course A บท​ที่ 8) — บท​ปิด Course A ที่​ถาม​คำถาม​เดียวกัน​ใน​ระดับ​สถาปัตยกรรม

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

ข้อ 1 / 3

Brittle Test คือ test แบบไหน?