test use case ด้วย test double
บทที่ 2 test Order เดี่ยวๆ ไม่ต้อง mock อะไรเลยเพราะมันบริสุทธิ์ บทที่ 4 แนะนำ test double ทั้งห้าแบบไปแล้ว บทนี้เอาสองเรื่องมาต่อกัน — test PlaceOrderHandler ที่ชั้น Application ซึ่งไม่บริสุทธิ์เหมือน Order มันมี dependency จริงสามสี่ตัว (เมนู, payment gateway, repository) แต่เราจะไม่ mock ทุกตัว จะให้ Order ทำงานจริงเต็มรูป แล้วสลับเฉพาะ port วงนอกเป็นของปลอมเบาๆ
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Application.Tests/
Sociable Test — domain จริงทำงานจริง fake แค่ port วงนอก
หัวข้อที่มีชื่อว่า “Sociable Test — domain จริงทำงานจริง fake แค่ port วงนอก”test PlaceOrderHandler ต่างจาก test Order ตรงบทที่ 2 ตรงที่ handler มี dependency จริงที่ต้องมีของบางอย่างมาแทน — จะ mock ทุกตัวให้เป็น interaction test ล้วนๆ ก็ได้ (แบบที่บทที่ 4 เรียกว่า London school) หรือจะปล่อยให้ Order ตัวจริงทำงานเต็มรูป แล้ว fake แค่ port ที่คุยกับโลกภายนอกจริงๆ (IMenuCatalog, IPaymentGateway, IOrderRepository) ก็ได้ — แนวทางที่สองนี้เรียกว่า Sociable TestSociable Testtest ที่ให้อ็อบเจ็กต์จริงทำงานร่วมกันจริง เช่น handler คุยกับ Order จริง แต่ fake เฉพาะ port วงนอก (DB/gateway) — ตรงข้ามกับ solitary test ที่ mock ทุกอย่างรอบตัว class ที่ทดสอบProcess: object ภายในระบบที่ทดสอบทำงานร่วมกันจริงตามธรรมชาติ ไม่ถูกตัดขาดด้วย mock ต่างจาก solitary test ที่ mock ทุกอย่างรอบตัว class เดียวจนเหลือแค่มันตัวเดียวโดดเดี่ยว
เหตุผลที่บทนี้เลือกทางสองคือ Order เป็นตัวรักษา invariant ทั้งหมดอยู่แล้ว (บทที่ 2 พิสูจน์ไปแล้ว) — ถ้า mock Order ด้วย เท่ากับ test handler ผ่านทั้งที่ไม่รู้เลยว่า Order.Place(...) ตัวจริงจะ throw หรือเปล่า sociable test ให้ handler คุยกับ Order ตัวจริง จึงพิสูจน์ทั้งคู่พร้อมกันใน test เดียว — แค่ port ที่ยิงเครือข่าย/ฐานข้อมูลจริงเท่านั้นที่ถูกสลับเป็นของปลอม
test แรก: PlaceOrderHandler บันทึกออเดอร์และตัดเงินถูกยอด
หัวข้อที่มีชื่อว่า “test แรก: PlaceOrderHandler บันทึกออเดอร์และตัดเงินถูกยอด”ก่อนเขียน test ต้องทวน PlaceOrderHandler กับ type ที่มันพึ่งพาให้ครบก่อน — ทวนจาก Course A บทที่ 3 (ports + handler พื้นฐาน) รวมกับ Course B บทที่ 7 (PromotionEngine เป็น dependency ตัวที่สี่):
// ทวนจาก FoodOrdering.Domain (Course A บทที่ 2, B บทที่ 3 ยกระดับเป็น entity)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 → "จำนวนต้องมีอย่างน้อย 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); // ต่างสกุลเงิน → "บวกเงินต่างสกุลกันไม่ได้" public static Money operator *(Money unitPrice, int quantity);}
public abstract class Entity<TId> { public TId Id { get; } protected Entity(TId id); }
public sealed class OrderLine : Entity<OrderLineId> // entity class ตั้งแต่บทที่ 3 ของ Course B ไม่ใช่ record{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; } public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}
public sealed class Order{ public OrderId Id { get; } public IReadOnlyCollection<OrderLine> Lines { get; } public Money Total { get; private set; }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items); // invariant ①: items.Count == 0 → throw InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้")}// ทวนจาก FoodOrdering.Application (Course A บทที่ 3) — ports ที่ PlaceOrderHandler พึ่งพาpublic interface IMenuCatalog { Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct); }public interface IPaymentGateway { Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct); }
// ทวนจาก Course B บทที่ 7 — ISpecification<T> ที่ overload ใหม่ของ IOrderRepository.FindAsync ต้องใช้public interface ISpecification<T> { bool IsSatisfiedBy(T candidate); }
public interface IOrderRepository{ Task SaveAsync(Order order, CancellationToken ct); Task<Order?> FindAsync(OrderId id, CancellationToken ct); Task<IReadOnlyList<Order>> FindAsync(ISpecification<Order> spec, CancellationToken ct); // overload จาก B บทที่ 8}
public sealed record PlaceOrderCommand(IReadOnlyList<PlaceOrderItem> Items) : IRequest<OrderId>;public sealed record PlaceOrderItem(ProductId ProductId, int Quantity);
// ทวนจาก Course B บทที่ 7 — PromotionEngine เป็น dependency ตัวที่สี่public sealed record Discount(decimal Amount);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 บาท}
// FoodOrdering.Application/Orders/PlaceOrderHandler.cspublic sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>{ private readonly IMenuCatalog _menu; private readonly IOrderRepository _orders; private readonly IPaymentGateway _payments; private readonly PromotionEngine _promotions;
public PlaceOrderHandler(IMenuCatalog menu, IOrderRepository orders, IPaymentGateway payments, PromotionEngine promotions) { _menu = menu; _orders = orders; _payments = payments; _promotions = promotions; }
public async Task<OrderId> Handle(PlaceOrderCommand request, CancellationToken ct) { var orderId = new OrderId(Guid.NewGuid()); var lines = new List<OrderLine>(); foreach (var item in request.Items) { var unitPrice = await _menu.GetPriceAsync(item.ProductId, ct); lines.Add(new OrderLine(new OrderLineId(Guid.NewGuid()), item.ProductId, new Quantity(item.Quantity), unitPrice)); }
var order = Order.Place(orderId, lines); // invariant รักษาในตัว Order เอง — ไม่ mock
var discount = _promotions.StackedDiscountFor(order); var payable = Money.Thb(order.Total.Amount - discount.Amount);
await _payments.ChargeAsync(payable, ct); await _orders.SaveAsync(order, ct);
return order.Id; }}ทีนี้ประกอบ test double สามตัวที่ handler ต้องการ — 2 fake จาก Course A/B ที่ทวนมาแล้ว บวกอีกสองตัวที่บทนี้เขียนเพิ่ม (StubMenuCatalog, InMemoryOrderRepository):
// FakePaymentGateway — ทวนจาก Course A บทที่ 7 (fake ตัวแรกของทั้งไตรภาค) verbatimpublic 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; }}
// StubMenuCatalog — ใหม่ในบทนี้: ป้อนราคาคงที่ ไม่บันทึกอะไรเลย (สมชื่อ stub จากบทที่ 4)public sealed class StubMenuCatalog : IMenuCatalog{ private readonly Money _price; public StubMenuCatalog(Money price) => _price = price; public Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct) => Task.FromResult(_price);}
// InMemoryOrderRepository — ใหม่ในบทนี้: fake ที่ "ใช้งานได้จริง" (เก็บ/ค้นออเดอร์ในหน่วยความจำ) แทนของจริงที่คุย Postgrespublic sealed class InMemoryOrderRepository : IOrderRepository{ private readonly Dictionary<OrderId, Order> _store = new(); public IReadOnlyCollection<Order> Saved => _store.Values.ToList().AsReadOnly();
public Task SaveAsync(Order order, CancellationToken ct) { _store[order.Id] = order; return Task.CompletedTask; }
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) => Task.FromResult(_store.TryGetValue(id, out var order) ? order : null);
public Task<IReadOnlyList<Order>> FindAsync(ISpecification<Order> spec, CancellationToken ct) => Task.FromResult<IReadOnlyList<Order>>(_store.Values.Where(spec.IsSatisfiedBy).ToList());}sequenceDiagram
participant T as Test
participant H as PlaceOrderHandler
participant O as Order (domain จริง)
participant CAT as StubMenuCatalog
participant PAY as FakePaymentGateway
participant REPO as InMemoryOrderRepository
T->>H: Handle(PlaceOrderCommand)
H->>CAT: GetPriceAsync(productId)
CAT-->>H: ราคาที่ stub ไว้
H->>O: Order.Place(id, lines)
O-->>H: order (ผ่าน invariant จริงทุกข้อ)
H->>PAY: ChargeAsync(payable)
PAY-->>H: บันทึกลง Charges
H->>REPO: SaveAsync(order)
REPO-->>H: บันทึกลง Saved
H-->>T: orderId
T->>REPO: ตรวจ Saved
T->>PAY: ตรวจ Charges
คำบรรยายภาพ: Order.Place(...) อยู่กลางไดอะแกรมเป็นของจริงล้วนๆ ไม่มีลูกศรไหนบอกว่ามันถูก mock — ส่วนสามตัวทางขวา (StubMenuCatalog, FakePaymentGateway, InMemoryOrderRepository) เป็นของปลอมเบาๆ ที่แทน port วงนอกเท่านั้น test ไม่ได้ verify ว่าใครเรียกใครกี่ครั้ง — มันตรวจแค่สถานะปลายทางสองจุด (Saved, Charges) หลัง Handle(...) จบ
[Fact]public async Task PlaceOrder_ChargesAndSaves(){ var catalog = new StubMenuCatalog(Money.Thb(120)); var gateway = new FakePaymentGateway(); var repo = new InMemoryOrderRepository(); var promotions = new PromotionEngine(Enumerable.Empty<IPromotion>()); // ไม่มีโปรโมชันเข้าเงื่อนไข var handler = new PlaceOrderHandler(catalog, repo, gateway, promotions);
var command = new PlaceOrderCommand(new List<PlaceOrderItem> { new(new ProductId(Guid.NewGuid()), 2) });
var orderId = await handler.Handle(command, CancellationToken.None);
Assert.Single(repo.Saved); Assert.Equal(orderId, repo.Saved.Single().Id); Assert.Single(gateway.Charges); Assert.Equal(240m, gateway.Charges[0].Amount); // 2 x 120 บาท ไม่มีส่วนลด}test นี้ไม่มี Verify สักบรรทัด — มันตรวจแค่ ผลลัพธ์ปลายทาง สองจุด: มี Order ถูกบันทึกไหม และยอดที่ตัดเงินตรงไหม ไม่สนใจว่า _menu.GetPriceAsync ถูกเรียกกี่ครั้งหรือตามลำดับไหน
C3: ยกเลิกแล้วต้องคืนเงิน
หัวข้อที่มีชื่อว่า “C3: ยกเลิกแล้วต้องคืนเงิน”บทที่ 5 ของ Course B เพิ่ม state machine ให้ Order — ทวนส่วนที่บทนี้ต้องใช้:
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public interface IDomainEvent { DateTimeOffset OccurredOn { get; } }public sealed record OrderCancelled(OrderId OrderId, Money RefundAmount, DateTimeOffset OccurredOn) : IDomainEvent;
public sealed class Order{ // ... Id, Lines, Total เหมือนที่ทวนไว้ด้านบน public OrderStatus Status { get; private set; } public IReadOnlyCollection<IDomainEvent> DomainEvents { get; } // _domainEvents ภายในเป็น List<IDomainEvent>
public void Cancel(); // invariant ⑥ cancel-before-pickup ผิดกฎ → throw InvalidOperationException // ผ่านกฎ → Status = Cancelled, ปล่อย OrderCancelled(Id, Total, ...) — RefundAmount = ยอดที่เคยตัดไป}OrderCancelled พก RefundAmount ติดมาด้วยตั้งแต่ต้นทาง — test ต่อไปนี้จำลอง “ขั้นคืนเงิน” ที่ตัว dispatcher (บทที่ 6 ของ Course B) จะเรียกให้อัตโนมัติในระบบจริง โดยเรียก FakePaymentGateway.RefundAsync(...) ตรงๆ:
[Fact]public async Task Cancel_RefundsCapturedAmount(){ var lines = new List<OrderLine> { new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(300)) }; var order = Order.Place(new OrderId(Guid.NewGuid()), lines);
var gateway = new FakePaymentGateway(); await gateway.ChargeAsync(order.Total, CancellationToken.None); // จำลองยอดที่ถูกตัดไปตอน Place
order.Cancel(); // Status: Placed -> Cancelled, ปล่อย OrderCancelled(..., RefundAmount)
var cancelled = Assert.IsType<OrderCancelled>(order.DomainEvents.Last()); await gateway.RefundAsync(cancelled.RefundAmount, CancellationToken.None); // ขั้นคืนเงินที่ dispatcher จริงจะเรียกให้
Assert.Single(gateway.Refunds); Assert.Equal(300m, gateway.Refunds[0].Amount);}test นี้พิสูจน์ เส้นทางที่สำเร็จ — ยกเลิกแล้วคืนเงินยอดถูกต้อง ส่วน “ชดเชยความล้มเหลว” (compensation-on-failure) เช่นกรณี payment gateway จริงล่มกลางทางตอนคืนเงิน ไม่ต้องรอ error จริงจากเครือข่ายมาพิสูจน์เลย — แค่เขียน fake อีกตัวที่ throw แทนบันทึกเงียบๆ:
// fake ที่จำลอง payment gateway ล่มตอนคืนเงิน — ใช้พิสูจน์ path การชดเชยความล้มเหลวได้ทันที ไม่ต้องรอเครือข่ายจริงพังpublic sealed class ThrowingPaymentGateway : IPaymentGateway{ public Task ChargeAsync(Money amount, CancellationToken ct) => Task.CompletedTask; public Task RefundAsync(Money amount, CancellationToken ct) => throw new InvalidOperationException("payment gateway ล่มระหว่างคืนเงิน (จำลอง)");}
[Fact]public async Task Refund_WhenGatewayFails_Throws(){ var gateway = new ThrowingPaymentGateway();
await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.RefundAsync(Money.Thb(300), CancellationToken.None));}C2: timeout 5 นาที โดยไม่ต้องรอ 5 นาทีจริง
หัวข้อที่มีชื่อว่า “C2: timeout 5 นาที โดยไม่ต้องรอ 5 นาทีจริง”บทที่ 5 ของ Course B ยังแนะนำ ConfirmationTimeoutPolicy — กฎ “ร้านไม่ยืนยันใน 5 นาทีให้ยกเลิกอัตโนมัติ” ทวน port กับ policy ที่บทนี้ต้องใช้:
public interface IClock { DateTimeOffset UtcNow { get; } }public interface IScheduler { void ScheduleAfter(TimeSpan delay, Func<CancellationToken, Task> action); }
public sealed class ConfirmationTimeoutPolicy{ public ConfirmationTimeoutPolicy(IClock clock, IScheduler scheduler, IOrderRepository orders); public void ScheduleFor(OrderId orderId); // ภายใน: _scheduler.ScheduleAfter(5 นาที, async ct => { หา order, ถ้ายังเป็น Placed ก็เรียก order.Cancel() แล้ว SaveAsync }) — เช็กก่อนว่ายังไม่ถูกยืนยัน/ยกเลิกไปก่อนหน้า}จุดสำคัญคือ IScheduler.ScheduleAfter รับ TimeSpan มาแค่เป็นค่าที่ต้อง “จำ” — implementation จริงของ IScheduler ต่างหากที่ตัดสินใจว่าจะรอจริงกี่นาที test จึงสลับ IScheduler ปลอมที่ไม่รอเลย แต่เรียก action ที่ส่งมาทันที:
// FakeClock — ทวนแนวคิดจาก Course A/B (IClock เป็น port มาตั้งแต่บทที่ 5 ของ Course B)public sealed class FakeClock : IClock{ public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;}
// FakeScheduler — ใหม่ในบทนี้: รัน action ที่ถูก schedule ทันที ไม่ต้องรอ TimeSpan จริงเลยpublic sealed class FakeScheduler : IScheduler{ public void ScheduleAfter(TimeSpan delay, Func<CancellationToken, Task> action) => action(CancellationToken.None).GetAwaiter().GetResult();}[Fact]public async Task Timeout_AutoCancelsWhenUnconfirmed(){ var lines = new List<OrderLine> { new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(150)) }; var order = Order.Place(new OrderId(Guid.NewGuid()), lines); // Status: Placed
var repo = new InMemoryOrderRepository(); await repo.SaveAsync(order, CancellationToken.None);
var policy = new ConfirmationTimeoutPolicy(new FakeClock(), new FakeScheduler(), repo);
policy.ScheduleFor(order.Id); // FakeScheduler รัน callback ทันที — ไม่มีการรอ 5 นาทีจริงเกิดขึ้นเลยสักมิลลิวินาที
var reloaded = await repo.FindAsync(order.Id, CancellationToken.None); Assert.Equal(OrderStatus.Cancelled, reloaded!.Status);}test นี้รันจบในเสี้ยววินาที ทั้งที่พิสูจน์กฎที่ในระบบจริงกินเวลา 5 นาที — เพราะ IClock/IScheduler เป็น port มาตั้งแต่แรก เวลาจึงกลายเป็นสิ่งที่ควบคุมได้ใน test ไม่ใช่สิ่งที่ต้องรอ
version ดิบ — mock ทุก interaction จน test เปราะ
หัวข้อที่มีชื่อว่า “version ดิบ — mock ทุก interaction จน test เปราะ”ทีนี้ย้อนกลับไปดู test PlaceOrder_ChargesAndSaves ด้านบน แล้วลองจินตนาการว่าถ้าเขียนแบบ mock ทุก interaction แทนจะเป็นยังไง:
// ❌ version ดิบ — ใช้ mock framework สมมติ (เช่น Moq) verify interaction ภายในทุกจุดvar mockMenu = new Mock<IMenuCatalog>();mockMenu.Setup(m => m.GetPriceAsync(It.IsAny<ProductId>(), It.IsAny<CancellationToken>())) .ReturnsAsync(Money.Thb(120));var mockPayments = new Mock<IPaymentGateway>();var mockOrders = new Mock<IOrderRepository>();var handler = new PlaceOrderHandler(mockMenu.Object, mockOrders.Object, mockPayments.Object, new PromotionEngine(Enumerable.Empty<IPromotion>()));
var command = new PlaceOrderCommand(new List<PlaceOrderItem> { new(new ProductId(Guid.NewGuid()), 2) });await handler.Handle(command, CancellationToken.None);
mockMenu.Verify(m => m.GetPriceAsync(It.IsAny<ProductId>(), It.IsAny<CancellationToken>()), Times.Once);mockPayments.Verify(p => p.ChargeAsync(It.Is<Money>(m => m.Amount == 240m), It.IsAny<CancellationToken>()), Times.Once);mockOrders.Verify(o => o.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);ทุกบรรทัด Verify ข้างบนตรวจว่า “มีการเรียก method นี้กี่ครั้ง” ไม่ใช่ “ผลลัพธ์คืออะไร” — ถ้าวันหนึ่งมีคน refactor Handle(...) ให้เรียก GetPriceAsync ครั้งเดียวแบบ batch แทนวน loop ทีละ item (พฤติกรรมข้างนอกเหมือนเดิมทุกประการ ยอดตัดเงินเท่าเดิม ออเดอร์บันทึกเหมือนเดิม) mockMenu.Verify(..., Times.Once) จะพังทันที ทั้งที่ไม่มี bug อะไรเกิดขึ้นเลย — นี่คือ test ที่ผูกกับ วิธีทำ ไม่ใช่ ผลลัพธ์ (ดูเพิ่มที่ Poorly Written Tests ใน DevIQ)
ทางแก้คือกลับไปใช้ FakeFaketest double ที่ implement ใช้งานได้จริงแต่เบากว่าของจริง เช่น FakePaymentGateway ที่บันทึก Charges/Refunds ไว้ใน list แทนการยิงเครือข่ายจริง — ตรวจผลลัพธ์ผ่านสถานะได้โดยไม่ต้อง verify interactionProcess ที่ตรวจสถานะปลายทางแบบที่ PlaceOrder_ChargesAndSaves เขียนไว้ข้างบนแล้ว — repo.Saved กับ gateway.Charges เป็นแค่ list ธรรมดา ไม่ใช่ mock ที่ผูกกับลำดับการเรียก refactor Handle(...) ยังไงก็ได้ ตราบใดที่ผลลัพธ์ปลายทางสองจุดนี้ยังถูกต้อง test ก็ยังผ่าน — นี่คือความหมายของการทนต่อการ refactorที่บทที่ 3 พูดถึงไว้
C4 (เบาๆ): DeliveryFeeCalculator กับระยะทางที่ stub ไว้
หัวข้อที่มีชื่อว่า “C4 (เบาๆ): DeliveryFeeCalculator กับระยะทางที่ stub ไว้”ปิดท้ายด้วยตัวอย่างสั้นๆ ของอีก use case หนึ่งที่ใช้ pattern เดียวกัน — บทที่ 7 ของ Course B แยกสูตรค่าส่งออกจากระยะทางที่มาจาก API แผนที่จริง ทวนตัว domain service:
// ทวนจาก FoodOrdering.Domain/Delivery/DeliveryFeeCalculator.cs (Course B บทที่ 7)public sealed class DeliveryFeeCalculator{ private const decimal BaseFee = 10m; private const decimal PerKmRate = 5m; private const double FreeDeliveryRadiusKm = 1.0;
public Money Calculate(double distanceKm) { if (distanceKm <= FreeDeliveryRadiusKm) return Money.Thb(0);
var billableKm = (decimal)(distanceKm - FreeDeliveryRadiusKm); return Money.Thb(Math.Round(BaseFee + PerKmRate * billableKm, 2, MidpointRounding.AwayFromZero)); }}Calculate(...) ไม่รู้จัก IMapsDistance เลยด้วยซ้ำ — มันรับแค่ double distanceKm ที่คำนวณมาแล้ว test จึงไม่ต้อง stub port ใดๆ เลย แค่ป้อนระยะทางตรงๆ เหมือนเป็นค่าที่ IMapsDistance เคย stub ไว้ให้แล้วในชั้น Application:
[Theory][InlineData(0.5, 0)] // ในระยะฟรี 1 กม. — ไม่คิดค่าส่ง[InlineData(3.0, 20)] // (3 - 1) กม. x 5 บาท + ฐาน 10 บาท = 20 บาทpublic void Calculate_GivenStubbedDistance_ReturnsExpectedFee(double distanceKm, decimal expectedFee){ var calculator = new DeliveryFeeCalculator();
var fee = calculator.Calculate(distanceKm);
Assert.Equal(expectedFee, fee.Amount);}test นี้ไม่ต้องมี test double สักตัวเลย เพราะ “ระยะทาง” ถูกแยกออกจาก “สูตรคำนวณ” มาตั้งแต่ตอนออกแบบ domain service — นี่คือรูปแบบเดียวกับที่บทที่ 2 test Money.Round() โดยไม่ต้อง mock อะไรเลย เพียงแต่ตอนนี้เป็น domain service ที่มี dependency วงนอกอยู่ (ระยะทาง) แต่ถูกแยกออกไปเป็น input ธรรมดาแทนที่จะฉีด port เข้ามาตรงๆ
บทนี้ test PlaceOrderHandler เป็น sociable test — Order ตัวจริงทำงานเต็มรูป fake แค่ IMenuCatalog, IPaymentGateway, IOrderRepository แล้วขยายไปสามเคสซับซ้อน: C3 ยกเลิกแล้วคืนเงินถูกยอด (พร้อมโน้ตเรื่องชดเชยความล้มเหลว), C2 timeout 5 นาทีที่ test จบในเสี้ยววินาทีเพราะ IClock/IScheduler เป็น port, และ C4 สูตรค่าส่งที่ทดสอบได้โดยไม่ต้องมี test double เลยเพราะระยะทางถูกแยกเป็น input ธรรมดา ตรงกลางคือบทเรียนสำคัญที่สุด: test ที่ตรวจสถานะปลายทางผ่าน fake ทนต่อการ refactor กว่า test ที่ Verify ทุก interaction ผ่าน mock — เพราะมันผูกกับ สัญญาที่ handler ต้องรักษา ไม่ใช่ ขั้นตอนภายในที่เปลี่ยนได้ตลอดเวลา
บทถัดไปขยับขึ้นพีระมิดอีกชั้น — integration test ที่ไม่ fake repository อีกต่อไป แต่คุยกับ Postgres จริงผ่าน Testcontainers
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Poorly Written Tests — กลิ่นของ test ที่ผูกกับวิธีทำแทนผลลัพธ์ ที่ตัวอย่าง mock เกินจำเป็นข้างบนสาธิตไว้
- Order เป็น state machine (Course B บทที่ 5) — ที่มาของ
Cancel(),OrderCancelledและConfirmationTimeoutPolicyที่บทนี้ test - Domain Service & Specification (Course B บทที่ 7) — ที่มาของ
PromotionEngineและDeliveryFeeCalculatorที่บทนี้ test
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3sociable test ของ PlaceOrderHandler ในบทนี้ทำงานยังไง?