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

ยูนิต test domain — ไม่​ต้อง mock

บท​ที่​แล้ว​ปิด​ท้าย​ด้วย​ที​เซอร์​บรรทัด​เดียว: Place_WithEmptyCart_Throws — test ที่​พิสูจน์ invariant ① ของ Order โดย​ไม่​ต้อง mock อะไร​เลย บท​นี้​คือ​จุด​ที่​เรา​กลับ​มา​ขยาย​มัน​เต็ม​รูป พร้อม test invariant ที่​เหลือ​ของ domain Order ที่ Course A และ Course B ปั้น​ไว้​ทั้งหมด

📦 code ตัวอย่าง

code เต็ม​ของ​บท​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — path FoodOrdering.Domain.Tests/OrderTests.cs, MoneyTests.cs, PriceBreakdownTests.cs, PromotionEngineTests.cs

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

ก่อน​เขียน 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 ที่ assert ไม่มี​ความหมาย
// ❌ 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 MoneyMoney เป็น 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 เทียบ​ค่าที่​ได้​กับ​ค่าที่​คาด​หวัง

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 โปร​ดัก​ชัน​ทุก​เส้นทาง

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” ไม่ใช่​ค่าที่​คืน​กลับ​มา

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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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) เรียงลำดับถูกต้องตามข้อใด?