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

test use case ด้วย test double

บท​ที่ 2 test Order เดี่ยวๆ ไม่​ต้อง mock อะไร​เลย​เพราะ​มัน​บริสุทธิ์ บท​ที่ 4 แนะนำ test double ทั้ง​ห้า​แบบ​ไป​แล้ว บท​นี้​เอา​สอง​เรื่อง​มา​ต่อ​กัน — test PlaceOrderHandler ที่​ชั้น Application ซึ่งไม่​บริสุทธิ์เหมือน Order มัน​มี dependency จริง​สาม​สี่​ตัว (เมนู, payment gateway, repository) แต่​เรา​จะ​ไม่ mock ทุก​ตัว จะ​ให้ Order ทำงาน​จริง​เต็ม​รูป แล้ว​สลับ​เฉพาะ port วงนอก​เป็น​ของ​ปลอม​เบาๆ

📦 code ตัวอย่าง

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

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 กับ 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.cs
public 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 ตัวแรกของทั้งไตรภาค) verbatim
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; }
}
// 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 ที่ "ใช้งานได้จริง" (เก็บ/ค้นออเดอร์ในหน่วยความจำ) แทนของจริงที่คุย Postgres
public 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(...) จบ

FoodOrdering.Application.Tests/PlaceOrderHandlerTests.cs
[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 ถูก​เรียก​กี่​ครั้ง​หรือ​ตาม​ลำดับ​ไหน

บท​ที่ 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));
}

บท​ที่ 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 ไม่ใช่​สิ่ง​ที่​ต้อง​รอ

ทีนี้​ย้อน​กลับ​ไป​ดู test PlaceOrder_ChargesAndSaves ด้าน​บน แล้ว​ลอง​จินตนาการ​ว่า​ถ้า​เขียน​แบบ mock ทุก interaction แทน​จะ​เป็น​ยังไง:

❌ version ดิบ — verify ทุก​การ​เรียก​จน test ผูก​กับ​วิธี​ทำ
// ❌ 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 พูด​ถึง​ไว้

ปิด​ท้าย​ด้วย​ตัว​อย่างสั้นๆ ของ​อีก 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

sociable test ของ PlaceOrderHandler ในบทนี้ทำงานยังไง?