ยูนิต test domain — ไม่ต้อง mock
บทที่แล้วปิดท้ายด้วยทีเซอร์บรรทัดเดียว: Place_WithEmptyCart_Throws — test ที่พิสูจน์ invariant ① ของ Order โดยไม่ต้อง mock อะไรเลย บทนี้คือจุดที่เรากลับมาขยายมันเต็มรูป พร้อม test invariant ที่เหลือของ domain Order ที่ Course A และ Course B ปั้นไว้ทั้งหมด
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path FoodOrdering.Domain.Tests/OrderTests.cs, MoneyTests.cs, PriceBreakdownTests.cs, PromotionEngineTests.cs
Unit Test คืออะไร และ AAA สามส่วน
หัวข้อที่มีชื่อว่า “Unit Test คืออะไร และ AAA สามส่วน”Unit TestUnit Testtest หน่วยเล็กที่สุดแบบแยกตัว รันเร็ว ไม่พึ่ง DB/เครือข่าย/เวลาจริง — ใน domain บริสุทธิ์อย่าง Order ทดสอบได้ตรง ๆ โดยไม่ต้อง mock อะไรเลยProcess คือ test ที่ทดสอบหน่วยเล็กที่สุดของ code — ใน domain ที่บริสุทธิ์แบบนี้มักหมายถึง method เดียวหรือ class เดียว โดยไม่พึ่งอะไรที่ช้า/ไม่แน่นอน เช่น ฐานข้อมูล เครือข่าย หรือนาฬิกาของเครื่อง test ระดับนี้ควรรันจบในหลักมิลลิวินาที และให้ผลลัพธ์เดิมทุกครั้งไม่ว่าจะรันกี่รอบ — ตรงตามที่บทที่ 1 map ไว้ว่าเป็นฐานพีระมิด FoodOrdering.Domain.Tests
xUnit (framework test ที่คอร์สนี้ใช้ตลอดทั้งเล่ม) มี attribute สองตัวหลักสำหรับยูนิต test: [Fact] สำหรับ test ที่รันครั้งเดียวไม่มี parameter และ [Theory] คู่กับ [InlineData(...)] สำหรับ test เดียวกันที่อยากรันซ้ำหลายชุดข้อมูล — เห็นตัวอย่าง [Theory] ชัดๆ ท้ายบทนี้ตอน test เพดานส่วนลด
ไม่ว่า test จะสั้นหรือยาว มันควรมีรูปร่างสามส่วนเสมอ — Vladimir Khorikov เรียกมันว่า Arrange-Act-AssertArrange-Act-Assert (AAA)โครงสามส่วนของ test ที่ดี: Arrange (จัดเตรียมข้อมูล/dependency), Act (เรียกพฤติกรรมที่ต้องการทดสอบ), Assert (ตรวจผลลัพธ์) — ทำให้ test อ่านง่ายและมีจุดโฟกัสเดียวProcess (AAA) ในหนังสือ Unit Testing: Principles, Practices, and Patterns:
flowchart LR ARR["Arrange<br/>จัดเตรียม input<br/>เช่น OrderLine และ OrderId"] ACT["Act<br/>เรียกพฤติกรรมที่ทดสอบ<br/>บรรทัดเดียว เช่น Order.Place(...)"] ASS["Assert<br/>ตรวจผลลัพธ์จริง<br/>เช่น Total หรือ exception ที่ควร throw"] ARR --> ACT --> ASS classDef stage fill:#0891b2,stroke:#164e63,color:#f8fafc; class ARR,ACT,ASS stage;
คำบรรยายภาพ: Arrange เตรียมข้อมูลนำเข้าให้พร้อม Act เรียกพฤติกรรมที่กำลังทดสอบเพียงบรรทัดเดียว (ยิ่งสั้นยิ่งอ่านง่ายว่าอะไรคือสิ่งที่ทดสอบจริงๆ) และ Assert ตรวจผลลัพธ์หรือพฤติกรรมที่เกิดขึ้นจริง — สามขั้นนี้แยกกันชัดเจนในทุก test ที่บทนี้จะเขียน ทวนกฎการตั้งชื่อจากบทที่ 1 ควบคู่กันไปด้วย: MethodName_Scenario_Expectation
version ดิบ vs test ที่ดี — assert อะไรถึงจะนับว่า test
หัวข้อที่มีชื่อว่า “version ดิบ vs test ที่ดี — assert อะไรถึงจะนับว่า test”ก่อนเขียน test จริง ขอทวน type ที่จะใช้ตลอดบทนี้ให้ครบก่อน — ทั้งหมดนี้คือของเดิมจาก Course A และ Course B บทที่ 2/บทที่ 3 ไม่มีอะไรใหม่ใน domain เลยสักตัว:
// ทวนจาก FoodOrdering.Domain/Orders/ (Course A บทที่ 2 + Course B บทที่ 2–3)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 → ArgumentException "จำนวนต้องมีอย่างน้อย 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 static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้" public static Money operator *(Money unitPrice, int quantity);}
// OrderLine เป็น entity เทียบด้วย Id เท่านั้น (ยกระดับจาก record ใน Course B บทที่ 3)public sealed class OrderLine : Entity<OrderLineId>{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; } public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id);}
public sealed class Order{ public OrderId Id { get; } public Money Total { get; private set; }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"); // invariant ① // ... สร้าง Order ใหม่ คำนวณ Total จาก items แล้วปล่อย OrderPlaced (Course A บทที่ 2) }}มี test อยู่แบบหนึ่งที่ compile ผ่าน รันแล้วขึ้นเขียว แต่ไม่ได้พิสูจน์อะไรเลย:
// ❌ version ดิบ — test นี้ "เขียว" เสมอ ไม่ว่า Place จะคำนวณ Total ถูกหรือผิด หรือปล่อย invariant ทะลุออกไปแค่ไหน[Fact]public void Place_CreatesOrder(){ var line = new OrderLine(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(50m)); var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { line });
Assert.NotNull(order); // ผ่านเสมอตราบใดที่ Place ไม่ throw — ไม่ตรวจ Total, ไม่ตรวจ Status, ไม่ตรวจอะไรที่เป็นพฤติกรรมจริงเลย}Assert.NotNull(order) ไม่มีทางล้มเหลว ตราบใดที่ Place(...) ไม่ throw exception ออกมา — ถ้าวันหนึ่งมีคนแก้สูตรคำนวณ Total ผิด test ตัวนี้ก็ยัง “ผ่าน” อยู่ดี เพราะมันไม่เคยเช็ก Total เลยสักครั้ง อีกอาการหนึ่งที่แย่พอกันคือการ mock Money — Money เป็น record บริสุทธิ์ ไม่มี dependency ภายนอกให้ mock สักตัว การยัด mock framework เข้าไป test ค่าที่คำนวณตรงๆ ได้อยู่แล้ว มีแต่ทำให้ test อ่านยากขึ้นโดยไม่ได้อะไรเพิ่มเลย (ดูอาการแบบนี้เพิ่มที่ Poorly Written Tests ใน DevIQ)
เทียบกับ test ที่ตรวจพฤติกรรมจริงตามรูปแบบ AAA:
// ✅ test AAA จริง — ตรวจผลลัพธ์ที่ Place ควรสร้างขึ้น ไม่ใช่แค่ว่ามัน "ไม่ throw"[Fact]public void Place_WithItems_SetsTotalFromLines(){ // Arrange — จัดเตรียม input var line = new OrderLine(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(50m));
// Act — เรียกพฤติกรรมที่ทดสอบบรรทัดเดียว var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { line });
// Assert — ตรวจผลลัพธ์จริง: 50 บาท x 2 ต้องรวมเป็น 100 บาทพอดี ผ่าน operator * ของ Money Assert.Equal(Money.Thb(100m), order.Total);}test ตัวนี้ล้มเหลวทันทีถ้าใครแก้สูตรคำนวณ Total ผิด — นั่นคือความต่างระหว่าง test ที่ “มีอยู่” กับ test ที่ “ป้องกันได้จริง” ข้อสังเกตสำคัญอีกอย่าง: ไม่มี mock framework ปรากฏใน test นี้เลยสักบรรทัด เพราะ Order.Place(...) ไม่พึ่งพา dependency ภายนอกใดๆ — Arrange แค่ประกอบ value object ธรรมดา Act เรียก function บริสุทธิ์ Assert เทียบค่าที่ได้กับค่าที่คาดหวัง
test invariant ของ Order — ตะกร้าว่างและ transition ที่ผิดกฎ
หัวข้อที่มีชื่อว่า “test invariant ของ Order — ตะกร้าว่างและ transition ที่ผิดกฎ”Order.Place(...) ที่ทวนไว้ข้างบนรักษา invariant ①: ตะกร้าว่างสร้างออเดอร์ไม่ได้ ทวนทีเซอร์จากบทที่แล้วให้เต็มรูป:
[Fact]public void Place_WithEmptyCart_Throws() => Assert.Throws<InvalidOperationException>(() => Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine>()));ทีนี้มาดู invariant ที่ซับซ้อนกว่านั้น — Course B บทที่ 5 เพิ่ม state machine ให้ Order ผ่าน OrderStatus กับ method transition ที่รักษาเส้นทางที่ถูกกฎ (invariant ⑤) ทวนสั้นๆ ก่อน test:
// ทวนจาก FoodOrdering.Domain/Orders/OrderStatus.cs + Order.cs (Course B บทที่ 5)public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public sealed class Order{ // ... Id, Total เหมือนที่ทวนไว้ข้างบนทุกตัวอักษร public OrderStatus Status { get; private set; } // เริ่มที่ Placed เสมอทันทีที่ Place(...) สำเร็จ
public void Confirm() // invariant ⑤: ยืนยันได้เฉพาะจากสถานะ Placed เท่านั้น { if (Status != OrderStatus.Placed) throw new InvalidOperationException( $"ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ {Status} ไม่ใช่ Placed"); Status = OrderStatus.Confirmed; // ... ปล่อย OrderConfirmed }
// method transition ที่เหลือเดินตามลายเดียวกันทุกตัว — เช็กสถานะก่อนเปลี่ยน แล้วปล่อย event (Course B บทที่ 5) public void StartPreparing(); // Confirmed -> Preparing public void MarkPickedUp(); // Preparing -> PickedUp public void MarkDelivered(); // PickedUp -> Delivered}พา Order เดินไปจนถึงปลายทาง Delivered ผ่าน method transition ที่ถูกกฎทุกขั้น แล้วลองยืนยันซ้ำ — ต้อง throw เสมอ เพราะ Delivered ไม่ใช่ Placed:
[Fact]public void Confirm_OnDeliveredOrder_Throws(){ // Arrange — เดินออเดอร์ไปจนถึง Delivered ผ่าน transition ที่ถูกกฎทุกขั้น var line = new OrderLine(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(120m)); var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { line }); order.Confirm(); order.StartPreparing(); order.MarkPickedUp(); order.MarkDelivered();
// Act + Assert — ยืนยันซ้ำจากสถานะปลายทางต้อง throw เสมอ (invariant ⑤) var ex = Assert.Throws<InvalidOperationException>(() => order.Confirm()); Assert.Equal("ยืนยันออเดอร์ไม่ได้ — สถานะปัจจุบันคือ Delivered ไม่ใช่ Placed", ex.Message);}สังเกตว่า Arrange ของ test นี้ยาวกว่า test ก่อนหน้า เพราะต้องพาออเดอร์ผ่านทุกสถานะที่ถูกกฎก่อนจะไปถึงสถานการณ์ที่อยากทดสอบจริงๆ — นี่คือข้อดีของการที่ Order เก็บกฎไว้ในตัวเองทั้งหมด: test ไม่ต้อง “โกง” ด้วยการยัดค่า Status ตรงๆ ผ่าน reflection เลยแม้แต่นิดเดียว มันต้องเดินผ่าน method transition จริงเหมือน code โปรดักชันทุกเส้นทาง
ปัดเศษสตางค์และ PriceBreakdown — invariant ⑧
หัวข้อที่มีชื่อว่า “ปัดเศษสตางค์และ PriceBreakdown — invariant ⑧”Course B บทที่ 2 เพิ่ม Round() ให้ Money สำหรับปัดเศษหลังคำนวณ VAT และเพิ่ม PriceBreakdown ที่รักษาความสัมพันธ์ระหว่าง Money ห้าก้อนไว้ในตัวเอง ทวนทั้งคู่:
// ทวนจาก FoodOrdering.Domain/Orders/Money.cs (Course B บทที่ 2)public sealed record Money(decimal Amount, string Currency){ // ... guard, Thb, operator +, operator * เหมือนเดิมทุกตัวอักษรตามที่ทวนไว้ข้างบน public Money Round() => new(Math.Round(Amount, 2, MidpointRounding.AwayFromZero), Currency);}
// ทวนจาก FoodOrdering.Domain/Orders/PriceBreakdown.cs (Course B บทที่ 2)public sealed record PriceBreakdown(Money Subtotal, Money Discount, Money DeliveryFee, Money Vat, Money Total){ public PriceBreakdown { // invariant ⑧: Total ต้องเท่ากับ Subtotal หักด้วย Discount บวก DeliveryFee บวก Vat เสมอ if (Total + Discount != Subtotal + DeliveryFee + Vat) throw new InvalidOperationException( "PriceBreakdown ไม่ถูกต้อง: Total ต้องเท่ากับ Subtotal หักด้วย Discount บวก DeliveryFee บวก VAT เสมอ"); }}Round() ไม่มี dependency อะไรให้ mock เลย — เป็น function คณิตศาสตร์ล้วนๆ test จึงสั้นแค่บรรทัดเดียว:
[Fact]public void Round_RoundsHalfUpToSatang() => Assert.Equal(Money.Thb(10.47m), Money.Thb(10.465m).Round());ส่วน PriceBreakdown test ที่ยืนยันว่า invariant ⑧ ยังโยน exception ทันทีเมื่อมีคนลืมหัก Discount (bug เดียวกับที่ Course B บทที่ 2 ยกตัวอย่างไว้):
[Fact]public void PriceBreakdown_WhenTotalMismatch_Throws(){ // Arrange — Discount ผิด ควรเป็น 0 แต่พิมพ์เป็น 15 บาท โดย Total ยังคำนวณจาก Discount เดิม var subtotal = Money.Thb(149.50m); var discount = Money.Thb(15m); var deliveryFee = Money.Thb(20m); var vat = Money.Thb(149.50m * 0.07m).Round(); // 10.47 var total = Money.Thb(179.97m); // 149.50 + 20 + 10.47 (ไม่ได้หัก Discount ก้อนใหม่)
// Act + Assert — Total + Discount (194.97) != Subtotal + DeliveryFee + Vat (179.97) Assert.Throws<InvalidOperationException>(() => new PriceBreakdown(subtotal, discount, deliveryFee, vat, total));}ทั้ง2 test นี้ไม่มี mock สักตัว เพราะ Money และ PriceBreakdown เป็น value object บริสุทธิ์ทั้งคู่ — Arrange ประกอบค่า Act/Assert รวมกันเป็นก้อนเดียวเพราะพฤติกรรมที่ทดสอบคือ “constructor โยน exception” ไม่ใช่ค่าที่คืนกลับมา
ส่วนลดสะสมและเพดาน 500 บาท — PromotionEngine.StackedDiscountFor
หัวข้อที่มีชื่อว่า “ส่วนลดสะสมและเพดาน 500 บาท — PromotionEngine.StackedDiscountFor”Course B บทที่ 7 แกะ PromotionEngine เป็น Domain Service ที่รวมส่วนลดจากโปรโมชันหลายตัวพร้อมกัน แล้วครอบด้วยเพดาน 500 บาทต่อออเดอร์ ทวนก่อน test:
// ทวนจาก FoodOrdering.Domain/Promotions/ + FoodOrdering.Application/Promotions/PromotionEngine.cs (Course B บทที่ 7)public sealed record Discount(decimal Amount); // guard: Amount<0 → ArgumentException "ส่วนลดต้องไม่ติดลบ"
public interface IPromotion{ bool IsEligible(Order order); Discount CalculateDiscount(Order order);}
public sealed class PromotionEngine{ public PromotionEngine(IEnumerable<IPromotion> promotions); public Discount StackedDiscountFor(Order order); // รวมส่วนลดจากทุกโปรโมชันที่เข้าเงื่อนไข แล้วครอบเพดาน 500 บาท}IPromotion เป็นแค่ interface — test ไม่ต้องยุ่งกับโปรโมชันจริงอย่าง NewYearPromotion เลยก็ได้ แค่ประกาศ test double เล็กๆ ที่คุมยอดส่วนลดตรงๆ ใน file test เอง:
// test double เล็ก ๆ ที่คุมยอดส่วนลดตรง ๆ — ไม่ต้องพึ่งกฎธุรกิจจริงของโปรโมชันตัวไหนเลยprivate sealed class FixedDiscountPromotion : IPromotion{ private readonly decimal _amount; public FixedDiscountPromotion(decimal amount) => _amount = amount; public bool IsEligible(Order order) => true; public Discount CalculateDiscount(Order order) => new(_amount);}ทีนี้ใช้ [Theory] รันชุดข้อมูลเดียวกันซ้ำหลายกรณี — สะสมปกติ, โดนเพดานตัด, และชนเพดานพอดี:
[Theory][InlineData(100, 150, 250)] // สะสมได้ปกติ ไม่ชนเพดาน[InlineData(300, 400, 500)] // รวมได้ 700 แต่โดนเพดานตัดเหลือ 500[InlineData(500, 0, 500)] // ก้อนเดียวเท่ากับเพดานพอดี ไม่โดนตัดpublic void StackedDiscount_CapsAt500(decimal first, decimal second, decimal expected){ // Arrange var line = new OrderLine(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(1000m)); var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { line }); var engine = new PromotionEngine(new IPromotion[] { new FixedDiscountPromotion(first), new FixedDiscountPromotion(second), });
// Act var discount = engine.StackedDiscountFor(order);
// Assert Assert.Equal(expected, discount.Amount);}สามชุดข้อมูลนี้พิสูจน์กฎธุรกิจสองข้อพร้อมกันโดยไม่ต้องเขียน test แยก: ส่วนลดจากหลายโปรโมชันสะสมกันได้จริง (100+150=250) และเพดาน 500 บาทตัดยอดรวมที่เกินเสมอ (300+400=700 → 500) — PromotionEngine เองก็ไม่พึ่งพา dependency ภายนอกใดๆ เช่นกัน _promotions เป็นแค่ collaborator ที่ฉีดเข้ามาตอนสร้าง ไม่มีฐานข้อมูลหรือ HTTP call ให้ต้อง mock เลยสักจุด
ทั้ง5 test ในบทนี้ — Place_WithEmptyCart_Throws, Confirm_OnDeliveredOrder_Throws, Round_RoundsHalfUpToSatang, PriceBreakdown_WhenTotalMismatch_Throws, StackedDiscount_CapsAt500 — ไม่มี mock framework ปรากฏสักบรรทัดเดียว เพราะทุกอย่างที่ทดสอบคือ domain บริสุทธิ์ตามที่บทที่ 1 อธิบายไว้: Arrange ประกอบ value object ธรรมดา Act เรียกพฤติกรรมบรรทัดเดียว Assert เทียบผลลัพธ์หรือจับ exception ที่ควรเกิด บทที่ 4 (Test Doubles) จะกลับมาเมื่อของที่ทดสอบเริ่มพึ่งพา port ภายนอกอย่าง IPaymentGateway — ตอนนั้นแหละที่ fake/stub/mock ถึงจะเข้ามามีบทบาทจริงๆ
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Poorly Written Tests — อาการของ test ที่ compile ผ่านแต่ไม่พิสูจน์อะไร แบบ version ดิบที่เห็นข้างบน
- Test Driven Development — วินัยที่ทำให้ test แบบ AAA กลายเป็นข้อกำหนดพฤติกรรมก่อนเขียน code จริง
- Value Object — Course B บทที่ 2 — ต้นทางของ
Money.Round()และPriceBreakdownที่บทนี้ test - Order State Machine — Course B บทที่ 5 — ต้นทางของ
OrderStatusและ method transition ที่บทนี้ test
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3รูปแบบสามส่วนของ Arrange-Act-Assert (AAA) เรียงลำดับถูกต้องตามข้อใด?