ทำไมสถาปัตยกรรมนี้ 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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain.Tests/
Testability เป็นผลพลอยได้ของ Dependency Rule ไม่ใช่ feature แยก
หัวข้อที่มีชื่อว่า “Testability เป็นผลพลอยได้ของ Dependency Rule ไม่ใช่ feature แยก”สิ่งที่ทำให้ 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 Pyramid — สัดส่วนของ test แต่ละชั้น
หัวข้อที่มีชื่อว่า “Test Pyramid — สัดส่วนของ test แต่ละชั้น”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 ของคอร์สนี้ไล่จากฐานขึ้นไปถึงยอดตามลำดับนี้เป๊ะ
โครง project test — 1 project test ต่อ1 project โปรดักชัน
หัวข้อที่มีชื่อว่า “โครง project test — 1 project test ต่อ1 project โปรดักชัน”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 จริง จากนี้เราจะไล่ขึ้นพีระมิดทีละชั้น:
- ยูนิต test domain (บทที่ 2) — test
Order,Money.Round(),PriceBreakdown,PromotionEngineแบบเต็มๆ โดยไม่ต้อง mock - สี่เสาหลักของ test ที่ดี (บทที่ 3) — เกณฑ์จาก Khorikov ที่ใช้วัดว่า test “ดี” จริงไหม
- Test Doubles (บทที่ 4) — mock/stub/fake/spy/dummy ต่างกันยังไง แล้วเมื่อไรควรใช้ตัวไหน
- test use case ด้วย test double (บทที่ 5) —
PlaceOrderHandler, saga คืนเงิน, timeout policy - Integration test ด้วย Testcontainers (บทที่ 6) — คุยกับ Postgres จริง ไม่ mock repository
- E2E ผ่าน API จริง (บทที่ 7) —
WebApplicationFactoryยิงทั้งระบบเหมือนผู้ใช้จริง - อะไรที่ “ไม่ควร” test (บทที่ 8) — เมื่อ test เยอะไม่ได้แปลว่าดี
ทุกบทกลับมาที่ domain Order เดิมเสมอ — ไม่มี domain ใหม่ ไม่มี business rule ใหม่ มีแต่การพิสูจน์ว่าของที่มีอยู่แล้วทำงานถูก
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Test Driven Development — วินัยเขียน test ก่อน code ที่ domain แบบนี้ทำได้ง่ายเป็นพิเศษเพราะไม่มี dependency ภายนอกให้ mock
- test ครั้งแรก — ทำไมสถาปัตยกรรมนี้ test ง่าย (Course A บทที่ 7) — จุดต่อของทีเซอร์ที่บทนี้สานต่อเต็มรูป
- Red-Green-Refactor — วงจรเขียน test ก่อน code ที่บทถัดๆ ไปในคอร์สนี้จะใช้ซ้ำ
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ทำไม test Order.Place(...) ไม่ต้องใช้ mock framework เลยสักตัว?