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

ทำไม​สถาปัตยกรรม​นี้ test ง่าย

ในบท​ที่ 7 ของ​คอร์ส Clean Architecture .NET เรา​ปิด​ท้าย​ด้วย​การ​พิสูจน์​เบื้องต้น​ว่า Order.Place(...) test ได้​โดย​ไม่​ต้อง mock สัก​ตัว — นั่น​คือ​ที​เซอร์ เรา​เห็น​แล้ว​ว่า ทำไม domain บริสุทธิ์​ถึง test ง่าย คอร์ส​นี้​คือ​ภาค​ต่อ​ที่​ลงมือ test จริง​ทั้ง​พีระมิด ไม่ใช่​แค่​ชั้น​เดียว

คอร์ส​นี้​ไม่​สอน​สถาปัตยกรรม​ซ้ำ​คอร์ส Clean Architecture .NET (A) และ​ไม่​สอน​การ​ปั้น model ซ้ำ​คอร์ส “แปลง DDD tactical patterns เป็น code จริง” (B) — A วาง​โครง, B ปั้น model ลึก, C (คอร์ส​นี้) พิสูจน์​ว่า​ทั้ง​สอง​คอร์ส​ที่​ผ่าน​มา​ทำงาน​ถูก​จริง ทุก​บท​จาก​นี้​จะ​หยิบ type ที่ A หรือ B เคย​สร้าง​ไว้​แล้ว มา​เขียน test ให้​มัน ไม่มี layer ใหม่ ไม่มี building block ใหม่​ใน domain — มี​แต่ test

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Domain.Tests/

สิ่ง​ที่​ทำให้ domain Order test ง่าย​ไม่ใช่​เพราะ​มี​ใคร​นั่ง​ออกแบบ “ระบบ test” แยก​ต่างหาก แต่​เป็น​ผล​ข้าง​เคียง​ของ​การ​ตัดสิน​ใจ​เชิง​สถาปัตยกรรม​ที่ A วาง​ไว้​ตั้งแต่​บท​ที่ 1: Dependency Rule — วงใน​ไม่รู้จัก​วงนอก FoodOrdering.Domain ไม่มี <ProjectReference> ชี้​ไป​หา ASP.NET Core, EF Core หรือ MediatR เลย​แม้แต่​ตัว​เดียว ผล​คือ Order.Place(...) เป็น function บริสุทธิ์: รับ OrderId กับ​รายการ OrderLine เข้า​มา ตรวจ invariant แล้ว​คืน Order กลับ​ออก​ไป ไม่​แตะ​อะไร​ข้าง​นอก​เลย​สัก​จุด

TestabilityTestabilityคุณสมบัติ​ที่​ระบบ​ถูก​ทดสอบ​ได้​ง่าย​โดย​เนื้อแท้ เป็น​ผลพลอยได้​ของ​การกลับ​ทิศ dependency (Dependency Rule) — domain ที่​ไม่​พึ่ง​ภายนอก​เลย​ทดสอบ​ตรง ๆ ได้​โดย​ไม่​ต้อง mock อะไร​เลยArchitecture คือ​คุณสมบัติ​ที่​ระบบ​ถูก​ทดสอบ​ได้​ง่าย​โดย​เนื้อแท้ — และ​ใน​สถาปัตยกรรม​แบบ​นี้​มัน​มา​จาก​การกลับ​ทิศ dependency ล้วนๆ: ชั้น Application ประกาศ port อย่าง IMenuCatalog, IPaymentGateway, IOrderRepository แทนที่​จะ​เรียก adapter จริงตรงๆ ผล​คือ​เวลา test เรา​สลับ​ของ​จริง​เป็น test double ได้​เสมอ​โดย​ไม่​ต้อง​แก้ code ที่​ใช้งาน​จริง​แม้แต่​บรรทัด​เดียว — แบบ​เดียว​กับ FakePaymentGateway ที่ A บท​ที่ 7 แนะนำ​ไว้​แล้ว คอร์ส​นี้​จะ​กลับ​มา​ใช้ pattern นี้​ซ้ำ​ใน​บท​ที่ 5 เมื่อ test use case handler เต็ม​รูป

test ทั้ง​ระบบ​ไม่​ได้​อยู่​ชั้น​เดียว Martin Fowler เรียก​รูป​ที่​ใช้​อธิบาย​สัดส่วน​นี้​ว่า Test PyramidTest Pyramidกรอบ​คิด​ของ Fowler ที่​จัด​สัดส่วน test เป็น​ชั้น: unit test เยอะ​สุด​อยู่​ฐาน (เร็ว ถูก) → integration test รอง​ลง​มา → e2e test น้อย​สุด​อยู่​ยอด (ช้า แพง แต่​มั่นใจ​สุด) — ยิ่ง​ขึ้น​สูง​ยิ่ง test น้อย​ลง​แต่​ขอบเขต​กว้าง​ขึ้นProcess: test ระดับ unit ควร​มี​เยอะ​ที่สุด​เพราะ​เร็ว​และ​ถูก​ที่สุด test ระดับ integration มี​น้อย​ลง​เพราะ​ต้อง​คุย​กับ​เทคโนโลยี​จริง (ช้า​กว่า​และ​เปราะ​กว่า) และ test ระดับ end-to-end ควร​มี​น้อย​ที่สุด​เพราะ​ช้า​ที่สุด​และ​เปราะ​ที่สุด — พีระมิด​นี้ map ตรง​กับ project test ทั้ง​สี่​ตัว​ของ kaen-food-ordering:

flowchart TB
  E2E["Api.Tests<br/>~5 test • ช้าสุด (วินาที/test) • ยิงผ่าน HTTP endpoint จริง"]
  INT["Integration.Tests<br/>~15 test • ปานกลาง (Testcontainers คุย Postgres จริง)"]
  UNIT["Domain.Tests + Application.Tests<br/>~100+ test • เร็วสุด (มิลลิวินาที/test) • ไม่ mock หรือ mock เท่าที่จำเป็น"]
  E2E --> INT --> UNIT
  classDef e2e fill:#dc2626,stroke:#7f1d1d,color:#f8fafc;
  classDef intc fill:#f59e0b,stroke:#92400e,color:#1c1917;
  classDef unitc fill:#16a34a,stroke:#065f46,color:#f8fafc;
  class E2E e2e;
  class INT intc;
  class UNIT unitc;

คำ​บรรยาย​ภาพ: ฐาน​พีระมิด (เขียว) คือ FoodOrdering.Domain.Tests และ FoodOrdering.Application.Tests — test เร็ว​สุด เยอะ​สุด รัน​ได้​ทุก​ครั้ง​ที่​เซฟ file ชั้น​กลาง (ส้ม) คือ FoodOrdering.Integration.Tests — คุย​กับ Postgres จริง​ผ่าน Testcontainers ช้า​กว่า​เพราะ​ต้อง​รอ container ส​ตาร์ต ยอด​บน​สุด (แดง) คือ FoodOrdering.Api.Tests — ยิง​ผ่าน HTTP endpoint จริง​เหมือน​ผู้​ใช้​คลิก​ใช้งาน ช้า​สุด​และ​เปราะ​สุด จึง​มี​น้อย​สุด บท​ที่ 2–7 ของ​คอร์ส​นี้​ไล่​จาก​ฐาน​ขึ้น​ไป​ถึง​ยอด​ตาม​ลำดับ​นี้​เป๊ะ

kaen-food-ordering จัด project test ตาม​กฎ​เดียว: 1 project test ต่อ1 project โปร​ดัก​ชัน แต่ละ​ตัว​มี <ProjectReference> ชี้​ไป​หา project ที่​มัน​ทดสอบ​เท่านั้น ไม่​ลาก​ของ​ที่​ไม่​เกี่ยว​เข้า​มา:

kaen-food-ordering/
├── FoodOrdering.Domain/
├── FoodOrdering.Domain.Tests/ <- unit: ทดสอบ Domain ล้วน ๆ ไม่ mock (บทที่ 2)
├── FoodOrdering.Application/
├── FoodOrdering.Application.Tests/ <- unit: ทดสอบ use case ด้วย test double (บทที่ 5)
├── FoodOrdering.Infrastructure/
├── FoodOrdering.Integration.Tests/ <- integration: Testcontainers คุย Postgres จริง (บทที่ 6)
├── FoodOrdering.Web/
└── FoodOrdering.Api.Tests/ <- e2e: WebApplicationFactory ยิงผ่าน HTTP จริง (บทที่ 7)

อีก​กฎ​ที่​ใช้​ตลอด​คอร์ส​นี้​คือ​การ​ตั้ง​ชื่อ test: MethodName_Scenario_Expectation — ชื่อ method, สถานการณ์, ผล​ที่​คาด​หวัง คั่น​ด้วย underscore อ่าน​ออก​ทันที​ว่า test นี้​ทดสอบ​อะไร โดย​ไม่​ต้อง​เปิด​ดู code ข้าง​ใน ทวน​จาก A บท​ที่ 2 ก่อน — Order.Place(...) คือ​ทาง​เดียว​ที่​สร้าง​ออเดอร์​ได้ (constructor เป็น private) และ​รักษา invariant ①: ตะกร้า​ว่าง​สร้าง​ไม่​ได้

// ทวนจาก FoodOrdering.Domain/Orders/Order.cs (Course A บทที่ 2)
public sealed record OrderId(Guid Value);
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items)
{
if (items.Count == 0)
throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
// ...
}

เทียบ​กับ​ชื่อ test ที่​ตาม​กฎ MethodName_Scenario_Expectation ตรงๆ:

// FoodOrdering.Domain.Tests/OrderTests.cs — ทีเซอร์บรรทัดเดียว ไม่ต้อง mock อะไรเลย
[Fact]
public void Place_WithEmptyCart_Throws() =>
Assert.Throws<InvalidOperationException>(() =>
Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine>()));

Place คือ method ที่​ทดสอบ, WithEmptyCart คือ​สถานการณ์, Throws คือ​ผล​ที่​คาด​หวัง — อ่าน​ชื่อ test อย่าง​เดียว​ก็​รู้​แล้ว​ว่า​มัน​พิสูจน์ invariant ① โดย​ไม่​ต้อง​เปิด​ดู code ข้าง​ใน​เลย บท​ที่ 2 จะ​กลับ​มา​ขยาย test Order ตัว​นี้​เต็ม​รูป พร้อม invariant ที่​เหลือ​ทั้งหมด

บท​นี้​วาง​แค่​ภาพ​รวม — ทำไม test ง่าย และ map พีระมิด​กับ project จริง จาก​นี้​เรา​จะ​ไล่​ขึ้น​พีระมิด​ที​ละ​ชั้น:

  1. ยูนิต test domain (บท​ที่ 2) — test Order, Money.Round(), PriceBreakdown, PromotionEngine แบบ​เต็มๆ โดย​ไม่​ต้อง mock
  2. สี่​เสา​หลัก​ของ test ที่​ดี (บท​ที่ 3) — เกณฑ์​จาก Khorikov ที่​ใช้​วัด​ว่า test “ดี” จริง​ไหม
  3. Test Doubles (บท​ที่ 4) — mock/stub/fake/spy/dummy ต่าง​กัน​ยังไง แล้ว​เมื่อไร​ควร​ใช้​ตัว​ไหน
  4. test use case ด้วย test double (บท​ที่ 5) — PlaceOrderHandler, saga คืน​เงิน, timeout policy
  5. Integration test ด้วย Testcontainers (บท​ที่ 6) — คุย​กับ Postgres จริง ไม่ mock repository
  6. E2E ผ่าน API จริง (บท​ที่ 7) — WebApplicationFactory ยิง​ทั้ง​ระบบ​เหมือน​ผู้​ใช้​จริง
  7. อะไร​ที่ “ไม่​ควร” test (บท​ที่ 8) — เมื่อ test เยอะ​ไม่​ได้​แปล​ว่า​ดี

ทุก​บท​กลับ​มา​ที่ domain Order เดิม​เสมอ — ไม่มี domain ใหม่ ไม่มี business rule ใหม่ มี​แต่​การ​พิสูจน์​ว่า​ของ​ที่​มี​อยู่​แล้ว​ทำงาน​ถูก


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

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

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

ข้อ 1 / 3

ทำไม test Order.Place(...) ไม่ต้องใช้ mock framework เลยสักตัว?