test ครั้งแรก — ทำไมสถาปัตยกรรมนี้ test ง่าย
บทที่แล้วเราปิดท้ายด้วยคำสัญญา — “บทถัดไปเราจะพิสูจน์สิ่งที่พูดมาตลอดหกบทว่าเป็นจริง” ถึงเวลาทำตามสัญญานั้นแล้ว บทนี้ไม่มี layer ใหม่ ไม่มี interface ใหม่ให้ประกาศ — มีแต่ test จริงที่รันได้จริง เพื่อตอบคำถามที่ค้างมาตั้งแต่บทที่ 1: ทุกครั้งที่เราเลือกห่อ dependency เป็น port แทนที่จะเรียกของจริงตรงๆ มันคุ้มค่าจริงไหม
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain.Tests/
Testability ไม่ใช่ feature ที่ต้องเติมทีหลัง
หัวข้อที่มีชื่อว่า “Testability ไม่ใช่ feature ที่ต้องเติมทีหลัง”ลองสังเกตสิ่งที่เราไม่ได้ทำในหกบทที่ผ่านมา: เราไม่เคยเขียน code สักบรรทัดที่บอกว่า “เพื่อให้ test ง่ายขึ้น” — ทุกการตัดสินใจ (Domain ไม่รู้จักใครเลย, Application ประกาศ port แทนที่จะ new adapter ตรงๆ, composition root เป็นที่เดียวที่รู้จักทั้งสองฝั่ง) ล้วนมีเหตุผลเรื่องสถาปัตยกรรมล้วนๆ ทั้งนั้น แต่ผลข้างเคียงที่ได้มาฟรีๆ คือ TestabilityTestabilityคุณสมบัติที่ระบบถูกทดสอบได้ง่ายโดยเนื้อแท้ — ผลพลอยได้หลักของการกลับทิศ dependency ให้ business rules ไม่ผูกกับ UI/DB/frameworkArchitecture — คุณสมบัติที่ระบบถูกทดสอบได้ง่ายโดยเนื้อแท้ ไม่ต้องออกแบบ “ระบบ test” แยกต่างหากเลย
เหตุผลเชิงเทคนิคตรงไปตรงมา: FoodOrdering.Domain.Tests เป็น project ทดสอบที่มี <ProjectReference> ชี้ไปที่ FoodOrdering.Domain project เดียว — ไม่มี ASP.NET Core, ไม่มี EF Core, ไม่มี MediatR อยู่ใน graph dependency เลยแม้แต่ตัวเดียว เพราะ FoodOrdering.Domain เองก็ไม่รู้จักของพวกนี้ตั้งแต่บทที่ 1 (<ProjectReference> ของมันว่างเปล่า) ผลคือรัน test ทั้งชุดของ Domain ได้ในเสี้ยววินาที ไม่ต้อง spin up เว็บ server ไม่ต้องต่อฐานข้อมูล ไม่ต้องรอ network call ใดๆ ทั้งสิ้น
test Domain โดยไม่ต้อง mock สักตัว
หัวข้อที่มีชื่อว่า “test Domain โดยไม่ต้อง mock สักตัว”นี่คือจุดที่แตกต่างจาก codebase ส่วนใหญ่ที่เคยเจอ: test ของ Order ในบทนี้ไม่มี mock framework โผล่มาเลยสักบรรทัด ไม่มี Mock<T>, ไม่มี Substitute.For<T>() เพราะ Order.Place(...) ไม่มี dependency ภายนอกให้ mock ตั้งแต่แรก — มันรับแค่ OrderId กับ IReadOnlyList<OrderLine> เข้ามา แล้วคืน Order ที่ผ่านการเช็ก invariant ออกไป เป็น function บริสุทธิ์ (pure) ในความหมายที่ว่าให้ input เดิม ได้ output เดิมเสมอ ไม่แตะอะไรข้างนอกเลย นี่คือรูปแบบของ Unit TestUnit Testtest หน่วยเล็กที่สุดแบบแยกตัว รันเร็ว ไม่พึ่ง DB/เครือข่าย — ในสถาปัตยกรรมนี้ domain ทดสอบได้โดยไม่ต้อง mock เพราะมันบริสุทธิ์อยู่แล้วArchitecture ที่ตรงไปตรงมาที่สุด — Arrange-Act-Assert ล้วนๆ ไม่มีขั้นตอน “ตั้งค่า mock ให้ตอบกลับแบบนี้” มาปนอยู่ก่อนเลย
ทวนจากบทที่ 2: Order เป็น aggregateAggregateกลุ่มของ object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง (consistency boundary) มี root เดียวเป็นประตูเข้า และคอยรักษา invariant ของทั้งกลุ่ม เช่น Order ที่คุม OrderLineTactical Design root ที่รักษา invariant ① ห้ามตะกร้าว่าง, ② Total ต้องเท่ากับผลรวมของทุกบรรทัด, และ ④ แต่ละบรรทัดต้องมี Quantity อย่างน้อย 1 (ข้อนี้ Quantity เองรักษาไปแล้วตั้งแต่ตอนสร้าง) test แรกตรวจ invariant ① ตรงๆ:
public class OrderTests{ [Fact] public void PlaceOrder_WithEmptyCart_Throws() { var emptyItems = new List<OrderLine>();
var ex = Assert.Throws<InvalidOperationException>(() => Order.Place(new OrderId(Guid.NewGuid()), emptyItems));
Assert.Equal("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้", ex.Message); }
[Fact] public void PlaceOrder_WithTwoLines_TotalEqualsSumOfLines() { 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(120)), };
var order = Order.Place(new OrderId(Guid.NewGuid()), lines);
Assert.Equal(220m, order.Total.Amount); // (2 × 50) + (1 × 120) }}สังเกตว่า test แรกไม่ได้เช็กแค่ว่า “throw” — มันเช็กข้อความ exception ตรงๆ ด้วย ("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้" ตัวเดียวกับที่ Order.Place(...) โยนไว้ในบทที่ 2 เป๊ะ) เพราะ test ที่เช็กแค่ “type ของ exception ถูกไหม” โดยไม่สนใจข้อความ อาจปล่อยผ่านกรณีที่ code throw ถูก type แต่ผิดเหตุผลได้ ส่วน test ที่สองพิสูจน์ invariant ② ตรงๆ ว่า Total คำนวณถูกจากทุกบรรทัดจริง ไม่ใช่แค่ “มีค่า” — ทั้ง2 test ไม่มีขั้นตอน setup ที่ซับซ้อนเกินสามบรรทัด เพราะไม่มีอะไรให้ต้อง mock
// อย่าทำแบบนี้ — Order.Place(...) ไม่มี dependency ให้ mock เลย// การยัด mock เข้ามาโดยไม่จำเป็นแค่เพิ่ม setup ที่ไม่มีความหมาย แล้วทำให้ test อ่านยากขึ้นเปล่า ๆvar mockLogger = new Mock<ILogger<Order>>(); // Order ไม่มี constructor ที่รับ ILogger ด้วยซ้ำvar order = Order.Place(new OrderId(Guid.NewGuid()), lines);mockLogger.Verify(l => l.Log(It.IsAny<LogLevel>(), ...), Times.Never); // เช็กเรื่องที่ไม่เกี่ยวกับพฤติกรรมที่ทดสอบตัวอย่างข้างบนไม่ใช่แค่ “เขียนเกินจำเป็น” — มันคือสัญญาณของ Mystery Guest หรือ Obscure Test กลับทาง คือใส่ของที่ไม่มีอยู่จริงเข้ามาให้ test ดูซับซ้อนเกินความเป็นจริงของสิ่งที่กำลังทดสอบ ยิ่ง test domain มี mock เยอะเท่าไร ยิ่งเป็นสัญญาณว่า domain นั้นเริ่มมี dependency ที่ไม่ควรมีอยู่แล้ว ไม่ใช่ว่า test เขียนไม่ดี
test pyramid — ชั้นไหนทดสอบอะไร
หัวข้อที่มีชื่อว่า “test pyramid — ชั้นไหนทดสอบอะไร”test ทั้งระบบไม่ได้อยู่ชั้นเดียว — รูปที่ใช้อธิบายเรื่องนี้บ่อยที่สุดคือ test pyramid ซึ่งบอกสัดส่วนที่ควรเป็น: test ระดับ unit ควรมีเยอะที่สุดเพราะเร็วและถูกที่สุด ส่วน test ที่ยิ่งใกล้ปลายบน ยิ่งช้าและเปราะบางกว่า จึงควรมีน้อยลงต่อเนื่อง
flowchart TB E2E["End-to-End<br/>น้อยที่สุด — ยิงผ่านทั้งระบบจริง (HTTP → DB)"] INT["Integration Test<br/>ปานกลาง — คุยกับ DB จริงผ่าน Testcontainers"] UNIT["Unit Test<br/>เยอะที่สุด เร็วที่สุด — ทดสอบ Domain ล้วน ๆ ไม่ 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;
คำบรรยายภาพ: ฐานพีระมิด (เขียว) คือ test ระดับ Order.Place(...) ที่เพิ่งเขียนไปข้างบน — เร็วสุด เยอะสุด ชั้นกลาง (ส้ม) คือ test ที่คุยกับเทคโนโลยีจริงอย่างฐานข้อมูล ยอดบนสุด (แดง) คือ test ที่ยิงผ่านทั้งระบบเหมือนผู้ใช้จริงคลิกใช้งาน — บทนี้แตะแค่สองชั้นล่างเป็นตัวอย่าง ส่วนชั้นบนสุดปล่อยผ่านไปก่อน
integration test แรก — คุยกับ DB จริงด้วย Testcontainers
หัวข้อที่มีชื่อว่า “integration test แรก — คุยกับ DB จริงด้วย Testcontainers”test Order.Place(...) พิสูจน์แค่ว่า กฎธุรกิจ ถูกต้อง แต่ยังไม่พิสูจน์ว่า EfOrderRepository จากบทที่ 4 จริงๆ แล้วบันทึกและอ่าน Order กลับมาได้ถูกต้อง — คำถามนั้นต้องใช้ฐานข้อมูลจริงตอบ ไม่ใช่แค่ compile ผ่าน เครื่องมือยอดนิยมสำหรับงานนี้คือ Testcontainers — library ที่สั่ง Docker รัน Postgres จริงขึ้นมาชั่วคราวแค่ตอน test แล้วปิดทิ้งทันทีที่ test จบ ไม่ต้องมีฐานข้อมูล “ที่ share กันทั้งทีม” ที่พังบ่อยๆ อีกต่อไป
// FoodOrdering.IntegrationTests/OrderRepositoryTests.cs — พรีวิวโครงเท่านั้น// เต็ม ๆ (setup/teardown, schema, seeding) อยู่ในคอร์สที่ว่าด้วยการ test โดยเฉพาะpublic sealed class OrderRepositoryTests : IAsyncLifetime{ private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().Build(); // Testcontainers.PostgreSql
public Task InitializeAsync() => _db.StartAsync(); public Task DisposeAsync() => _db.DisposeAsync().AsTask();
// ต่อ FoodOrderingDbContext จริงเข้ากับ _db.GetConnectionString() แล้วเรียก // EfOrderRepository.SaveAsync(...)/FindAsync(...) ของจริง เพื่อพิสูจน์ว่า mapping // ระหว่าง Order กับตาราง SQL ถูกต้อง — รายละเอียดเต็มอยู่นอกขอบเขตบทนี้}สังเกตว่านี่คือ test ชั้น “Integration” ในพีระมิดข้างบน — ช้ากว่า test Order.Place(...) มาก (ต้องรอ container สตาร์ต) แต่ก็ยังไม่ใช่ E2E เพราะไม่ได้ยิงผ่าน HTTP endpoint จริง มันทดสอบแค่ว่า EfOrderRepository (adapter) กับฐานข้อมูลจริงเข้ากันได้ถูกต้อง repositoryRepositoryabstraction ที่ทำให้ domain เข้าถึง/บันทึก aggregate ได้เหมือนเป็น in-memory collection โดยไม่รู้จักเทคโนโลยีจัดเก็บจริง — interface อยู่วงใน implementation อยู่ InfrastructureTactical Design เป็นจุดที่ต้องมี test แบบนี้เสมอ เพราะเป็นชั้นเดียวที่ “แปลง” object ในหน่วยความจำให้กลายเป็นแถวในตาราง SQL — งานที่พิสูจน์ด้วย unit test อย่างเดียวไม่ได้ ต้องมีฐานข้อมูลจริงมายืนยัน
บทนี้แตะ integration test แค่ผิวเดียวเป็นทีเซอร์ — เรื่อง test fixture ที่ share container ข้าม test, การ seed ข้อมูล, transaction rollback ระหว่าง test และกลยุทธ์ test MediatR pipeline ทั้งเส้น เป็นเนื้อหาที่ลึกกว่าขอบเขตของคอร์สที่เน้นโครงสร้าง Clean Architecture คอร์สนี้ คอร์สแยกที่ว่าด้วยการ test โดยเฉพาะ (ยังไม่เผยแพร่ ณ ตอนนี้) จะกลับมาลงรายละเอียดเต็มๆ
C3 seam: ทำไมนโยบายยกเลิก/คืนเงินถึงทดสอบได้ตั้งแต่วันนี้
หัวข้อที่มีชื่อว่า “C3 seam: ทำไมนโยบายยกเลิก/คืนเงินถึงทดสอบได้ตั้งแต่วันนี้”ลองนึกภาพนโยบายที่ทีมธุรกิจจะขอในอนาคต: “ถ้าลูกค้ายกเลิกออเดอร์ก่อนร้านเริ่มทำ ให้คืนเงินที่ตัดไปแล้วอัตโนมัติ” นี่คือตัวอย่างของเคสซับซ้อนที่คอร์สนี้เรียกว่า C3 — saga สั้นๆ ที่ต้อง “ชดเชย” (compensate) สิ่งที่ทำไปแล้วตอนมีคำสั่งย้อนกลับเข้ามา ต่างจาก C1 (โปรโมชันในบทที่ 3) และ C2 (timeout ในบทที่ 6) ตรงที่มันเกี่ยวกับการย้อนกลับ side effect ที่เกิดขึ้นแล้ว ไม่ใช่แค่ตัดสินใจว่าจะทำอะไรตอนสร้างออเดอร์
CancelOrderHandler และ state machine ของสถานะออเดอร์ (Placed → Cancelled → คืนเงินสำเร็จ) ยังไม่ถูกสร้างในคอร์สนี้ — คอร์สถัดไปในซีรีส์ (ที่ว่าด้วยเคสซับซ้อน C1–C3) จะกลับมาสร้าง handler ตัวนี้เต็มๆ พร้อม compensation logic บทนี้ตั้งใจแสดงแค่ seam เดียว: ทำไม saga แบบนี้ถึง “ทดสอบได้ล่วงหน้า” ตั้งแต่ตอนที่ยังไม่มี handler อยู่เลยด้วยซ้ำ
คำตอบคือเพราะ IPaymentGateway เป็น portPortinterface ที่วงในประกาศไว้เพื่อคุยกับโลกภายนอก (เช่น IPaymentGateway) โดยไม่รู้จัก implementation จริงArchitecture มาตั้งแต่บทที่ 3 อยู่แล้ว — สิ่งที่ต้องเพิ่มมีแค่ method ใหม่หนึ่งตัวในบทนี้เพื่อรองรับการคืนเงิน:
// FoodOrdering.Application/Orders/IPaymentGateway.cs — เพิ่ม method ใหม่ในบทนี้public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct); // ใหม่ — เตรียม seam ไว้สำหรับ saga คืนเงิน (C3)}RefundAsync เป็นแค่ลายเซ็น method ในบทนี้ — ยังไม่มี handler ตัวไหนเรียกมันจริง และ adapterAdaptercode วงนอกที่ implement port เพื่อเชื่อมกับเทคโนโลยีจริง เช่น EF Core repository หรือ HTTP payment clientArchitecture ของจริงอย่าง MockPaymentGateway (บทที่ 6) ก็ยังไม่ได้ implement method นี้ด้วยซ้ำ (เป็นงานที่รอบทถัดไปในซีรีส์มาต่อ) แต่สิ่งที่ทำได้ ตอนนี้เลย คือเขียน test double ของ IPaymentGateway ที่ implement ครบทั้ง2 method แล้วให้มัน “จำ” ว่าใครเรียกมันบ้าง:
public sealed class FakePaymentGateway : IPaymentGateway{ public List<Money> Charges { get; } = new(); public List<Money> Refunds { get; } = new();
public Task ChargeAsync(Money amount, CancellationToken ct) { Charges.Add(amount); return Task.CompletedTask; }
public Task RefundAsync(Money amount, CancellationToken ct) { Refunds.Add(amount); return Task.CompletedTask; }}[Fact]public async Task FakePaymentGateway_RecordsRefund_WhenRefundIsRequested(){ var gateway = new FakePaymentGateway();
await gateway.RefundAsync(Money.Thb(250), CancellationToken.None);
Assert.Single(gateway.Refunds); Assert.Equal(250m, gateway.Refunds[0].Amount);}สังเกตว่า test ตัวนี้ไม่ต้องมี CancelOrderHandler อยู่เลยด้วยซ้ำเพื่อพิสูจน์ประเด็นสำคัญ — seam ของ saga (ตัว port) ทดสอบได้ตั้งแต่ก่อนที่ orchestrator จะถูกสร้างขึ้นจริง และนี่ไม่ใช่เรื่องบังเอิญ: PlaceOrderHandler รับ IPaymentGateway เข้ามาผ่าน constructor ตามหลัก dependency injectionDependency Injectionเทคนิคส่ง dependency (ที่ implement ตาม interface) เข้ามาจากภายนอกแทนที่จะสร้างเอง ทำให้วงในพึ่งพา abstraction ไม่ใช่ของจริง และสลับ/ทดสอบได้ง่ายArchitecture เดียวกับที่บทที่ 6 ผูกไว้ใน composition root อยู่แล้ว วันที่ CancelOrderHandler ถูกสร้างขึ้นจริงในคอร์สถัดไป มันก็จะรับ IPaymentGateway เข้ามาแบบเดียวกัน test ของมันก็จะมีรูปร่างเดียวกับที่เห็นข้างบนเป๊ะ — สลับ MockPaymentGateway (adapter จริงที่ผูกไว้ใน composition root) ออก แล้วส่ง FakePaymentGateway เข้าไปแทนตอน test โดยไม่ต้องแก้ code handler แม้แต่บรรทัดเดียว
นี่คือคำตอบของคำถามที่ตั้งไว้ตอนเปิดบทนี้: การห่อ dependency เป็น port คุ้มค่าตรงนี้เอง — ไม่ใช่แค่ “แยก code สวย” แต่เพราะมันทำให้ สลับของจริงเป็นของปลอมได้โดยไม่แตะ code ที่ใช้งานจริง ซึ่งคือนิยามของ testability ที่มาจากการกลับทิศ dependency ล้วนๆ
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้เราพิสูจน์สิ่งที่พูดมาตลอดหกบทด้วย test จริง: Order.Place(...) ทดสอบได้โดยไม่ต้อง mock สักตัวเพราะ Domain ไม่มี dependency ให้ mock, integration test ด้วย Testcontainers พิสูจน์ว่า EfOrderRepository คุยกับฐานข้อมูลจริงถูกต้อง (ทีเซอร์เท่านั้น รายละเอียดเต็มรอคอร์ส test แยกต่างหาก), และ seam ของ C3 — IPaymentGateway.RefundAsync กับ FakePaymentGateway — แสดงให้เห็นว่า saga ยกเลิก/คืนเงินทดสอบได้ตั้งแต่ก่อนที่ตัว handler จะถูกสร้างขึ้นจริงเสียอีก เพราะทุกอย่างคุยกันผ่าน port ไม่ใช่ของจริงตรงๆ
บทถัดไปเป็นบทสุดท้ายของคอร์สนี้ — และมันจะทำสิ่งที่ไม่มีบทไหนทำมาก่อน: ชวนตั้งคำถามว่า Clean Architecture คุ้มกับทุก project จริงหรือเปล่า ต้นทุนของโครงสร้าง4 project ports และ ceremony ทั้งหมดนี้คุ้มก็ต่อเมื่อ domain ซับซ้อนพอ ถ้าเป็นแค่ CRUD ล้วนๆ ทางเลือกที่เบากว่าอาจตอบโจทย์ดีกว่า
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Test Driven Development — วินัยเขียน test ก่อน code และเหตุผลที่ domain แบบนี้เขียน test ก่อนได้ง่ายเป็นพิเศษ
- Poorly Written Tests — กลิ่นของ test ที่ไม่ให้ฟีดแบ็กเร็ว แม่นยำ น่าเชื่อถือ (Mystery Guest, Obscure Test ฯลฯ) ที่ตัวอย่าง mock เกินจำเป็นข้างบนสาธิตไว้
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ทำไม test ของ Order.Place(...) ไม่ต้องใช้ mock framework เลยสักตัว?