Test Doubles — mock/stub/fake/spy/dummy
บทที่แล้วเราวัดว่า test “ดี” แค่ไหนด้วยสี่เสาหลัก — กันรีเกรสชัน, ทนต่อการ refactor, ฟีดแบ็กเร็ว, ดูแลรักษาง่าย บทนี้ขยับไปที่คำถามที่ปฏิบัติกว่านั้น: เวลา test ต้องคุยกับ dependency ที่ไม่อยากลากของจริงเข้ามา (payment gateway, menu catalog ภายนอก) เราจะเอา “ของปลอม” แบบไหนมาแทนที่ ถึงจะไม่ทำให้ test เปราะจนพังทุกครั้งที่ refactor
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain.Tests/Fakes/ และ FoodOrdering.Application.Tests/Fakes/
Test double ห้าแบบ — dummy, stub, spy, mock, fake
หัวข้อที่มีชื่อว่า “Test double ห้าแบบ — dummy, stub, spy, mock, fake”Martin Fowler เขียนบทความชื่อ “Mocks Aren’t Stubs” ไว้เพื่อแก้ปัญหาว่าคนมักเรียกของปลอมทุกแบบว่า “mock” ปนกันหมด ทั้งที่จริงๆ แล้วมันมีอย่างน้อยห้าแบบที่ทำหน้าที่ต่างกัน คำรวมของทั้งห้าแบบคือ Test DoubleTest Doubleของปลอมที่ใช้แทน dependency จริงใน test (Fowler แบ่งเป็น dummy/stub/spy/mock/fake) เพื่อตัดสิ่งที่ช้า/ไม่แน่นอน/ยากออก แล้วโฟกัสที่พฤติกรรมที่กำลังทดสอบProcess — ของปลอมที่ใช้แทน dependency จริงใน test เพื่อตัดสิ่งที่ช้า/ไม่แน่นอน/ยากออก แล้วโฟกัสที่พฤติกรรมที่กำลังทดสอบจริงๆ
- Dummy — ส่งผ่านเข้าไปเฉยๆ เพื่อให้ signature ครบ (เช่น
CancellationToken.None) ไม่มีใครเรียกใช้งานมันจริงใน test - StubStubtest double ที่ป้อนค่าคงที่ตามที่ test ต้องการ (canned answer) เช่น IMenuCatalog ปลอมที่คืนราคาคงที่เสมอ — ไม่บันทึกการเรียกใด ๆ ต่างจาก spy/mockProcess — ป้อนค่า input คงที่กลับให้ระบบที่ทดสอบ (SUT) เรียก ไม่สนใจว่าใครเรียกมันกี่ครั้ง สนใจแค่ว่ามันต้อง “ตอบ” อะไรกลับไป
- SpySpytest double ที่ทำหน้าที่เหมือน stub แต่ยังบันทึกไว้ว่าถูกเรียกอย่างไรบ้าง ให้ test ตรวจย้อนหลังได้ภายหลังว่ามีการเรียกเกิดขึ้นจริงหรือไม่Process — เป็น stub บวกความสามารถ “จดบันทึก” ว่าใครเรียกมันด้วยอะไรบ้าง เพื่อให้ test ย้อนมาตรวจสอบทีหลังได้
- MockMocktest double ที่ 'ตรวจว่ามีการเรียกเกิดขึ้น' (interaction-based) เช่น ยืนยันว่า ChargeAsync ถูกเรียกด้วยจำนวนเงินที่ถูกต้อง — ใช้เมื่อสิ่งที่สนใจคือ 'สิ่งที่เกิดขึ้น' ไม่ใช่สถานะProcess — ตั้งความคาดหวังไว้ล่วงหน้าว่าจะถูกเรียกแบบไหน แล้ว “verify” ว่ามีการเรียกเกิดขึ้นจริงตามนั้น (ถ้าไม่ตรง test fail) — เน้นตรวจ การเรียก ไม่ใช่ผลลัพธ์
- FakeFaketest double ที่ implement ใช้งานได้จริงแต่เบากว่าของจริง เช่น FakePaymentGateway ที่บันทึก Charges/Refunds ไว้ใน list แทนการยิงเครือข่ายจริง — ตรวจผลลัพธ์ผ่านสถานะได้โดยไม่ต้อง verify interactionProcess — implementation ที่ใช้งานได้จริง แต่ลัดขั้นตอนที่ไม่เหมาะกับโปรดักชัน เช่น in-memory repository แทนฐานข้อมูลจริง
flowchart LR D["Dummy<br/>ส่งผ่านเฉย ๆ ไม่ถูกเรียกใช้จริง"] S["Stub<br/>ป้อนค่า input คงที่ให้ SUT"] SP["Spy<br/>Stub + จดว่าใครเรียกอะไรบ้าง"] M["Mock<br/>ตั้งความคาดหวังล่วงหน้า แล้ว verify การเรียก"] F["Fake<br/>impl ใช้งานได้จริงแต่เบา เช่น in-memory"] D --> S --> SP --> M --> F classDef state fill:#16a34a,stroke:#065f46,color:#f8fafc; classDef interaction fill:#dc2626,stroke:#7f1d1d,color:#f8fafc; class D,S,F state; class SP,M interaction;
คำบรรยายภาพ: ห้าแบบนี้ไม่ได้เรียงตามความซับซ้อนเฉยๆ แต่แบ่งเป็นสองค่าย — กลุ่มเขียว (dummy, stub, fake) ให้ “สถานะ” ที่จับต้องได้ให้ test ตรวจผลลัพธ์สุดท้าย ส่วนกลุ่มแดง (spy, mock) เน้นตรวจว่า “การเรียก” เกิดขึ้นจริงหรือเปล่า บทนี้จะไล่ดูทีละกลุ่มผ่านของจริงจากคอร์ส A
FakePaymentGateway ของ Course A — fake ตัวอย่างที่มีอยู่แล้ว
หัวข้อที่มีชื่อว่า “FakePaymentGateway ของ Course A — fake ตัวอย่างที่มีอยู่แล้ว”Course A บทที่ 7 เคยแนะนำ FakePaymentGateway ไว้แล้วตอน test seam ของการคืนเงิน ทวน IPaymentGateway (port ที่ประกาศใน FoodOrdering.Application) ก่อน:
// FoodOrdering.Application/Orders/IPaymentGateway.cs (Course A บทที่ 3, เพิ่ม RefundAsync บทที่ 7)public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}IPaymentGateway คุยกับ Money (record จาก FoodOrdering.Domain/Orders/Money.cs, Course A บทที่ 2) ทวนแบบย่อ เพราะเราจะเรียก Money.Thb(...) ตลอดบทนี้:
// FoodOrdering.Domain/Orders/Money.cs (Course A บทที่ 2)public sealed record Money(decimal Amount, string Currency){ // guard: Amount<0 -> "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" -> "ตอนนี้รองรับเฉพาะสกุลเงิน THB" public static Money Thb(decimal amount) => new(amount, "THB");}แล้วนี่คือ FakePaymentGateway ตัวเดิมจาก A บทที่ 7 เป๊ะๆ — implement IPaymentGateway ครบทั้ง2 method และ “จำ” ทุกการเรียกไว้ใน list:
// FoodOrdering.Domain.Tests/Fakes/FakePaymentGateway.cs (Course A บทที่ 7)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; }}ทำไม FakePaymentGateway ถึงเป็น fake ไม่ใช่ mock? เพราะมันมี implementation ที่ทำงานได้จริง (เก็บเงินที่ตัดไว้ใน Charges เก็บเงินที่คืนไว้ใน Refunds) เพียงแต่ลัดขั้นตอนที่ไม่จำเป็นตอน test — ไม่ยิง HTTP จริงไปหา payment gateway ข้างนอก ไม่มีการตั้งความคาดหวังล่วงหน้าแบบ mock ที่ throw ทันทีถ้าเรียกผิด มันแค่ “รับแล้วจำ” เหมือน adapter ตัวจริงตัวหนึ่งที่เบากว่าปกติ
Stub ป้อน input, Mock ตรวจการเรียก
หัวข้อที่มีชื่อว่า “Stub ป้อน input, Mock ตรวจการเรียก”IMenuCatalog เป็น port อีกตัวจาก Course A บทที่ 3 ที่คืนราคาสินค้า:
// FoodOrdering.Application/Orders/IMenuCatalog.cs (Course A บทที่ 3)public interface IMenuCatalog{ Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct);}
// FoodOrdering.Domain/Orders/ProductId.cs (Course A บทที่ 2)public sealed record ProductId(Guid Value);ถ้า test ต้องการแค่ “ราคาคงที่” กลับไปเพื่อประกอบ OrderLine โดยไม่สนใจว่าใครเรียก GetPriceAsync กี่ครั้ง นั่นคือหน้าที่ของ stub — ป้อน input ให้ SUT ไหลต่อไปได้เท่านั้น ไม่มี assertion ผูกอยู่กับตัวมันเองเลย:
public sealed class StubMenuCatalog : IMenuCatalog{ private readonly Money _fixedPrice;
public StubMenuCatalog(Money fixedPrice) => _fixedPrice = fixedPrice;
public Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct) => Task.FromResult(_fixedPrice);}ใช้ตอน test ก็แค่ new StubMenuCatalog(Money.Thb(89)) — ไม่มีการ verify ว่าใครเรียกมันเลย มันมีหน้าที่เดียวคือ “ตอบ” ทุกครั้งที่ถูกถาม
ส่วนถ้า test ต้องการพิสูจน์ว่า “มีการตัดเงินเกิดขึ้นจริง” นั่นคืองานของฝั่งตรวจการเรียก — และไม่ต้องพึ่ง mocking framework เลยสักตัวก็เขียนได้ ใช้ IPaymentGateway ที่ทวนไว้ข้างบนตัวเดิม:
// FoodOrdering.Domain.Tests/Fakes/SpyPaymentGateway.cs — สาธิต spy มือเขียนเอง ไม่ใช้ mocking frameworkpublic sealed class SpyPaymentGateway : IPaymentGateway{ public int ChargeCallCount { get; private set; } public Money? LastCharged { get; private set; }
public Task ChargeAsync(Money amount, CancellationToken ct) { ChargeCallCount++; LastCharged = amount; return Task.CompletedTask; }
public Task RefundAsync(Money amount, CancellationToken ct) => Task.CompletedTask;}[Fact]public async Task ChargeAsync_WhenCalledOnce_IsRecordedForVerification(){ var gateway = new SpyPaymentGateway();
await gateway.ChargeAsync(Money.Thb(250), CancellationToken.None);
Assert.Equal(1, gateway.ChargeCallCount); Assert.Equal(Money.Thb(250), gateway.LastCharged);}SpyPaymentGateway เป็นตัวอย่าง spy ตามนิยาม — มันจดว่าใครเรียกมันกี่ครั้งด้วยอะไร แต่การ Assert.Equal(1, gateway.ChargeCallCount) ใน test คือสไตล์การ verify แบบ mock: ตรวจว่า “การเรียกเกิดขึ้น” ไม่ใช่ตรวจ “ผลลัพธ์ทางธุรกิจ” สังเกตว่านี่คือสิ่งเดียวกับที่ FakePaymentGateway.Charges/Refunds ทำได้อยู่แล้วข้างบน — เพียงแต่ FakePaymentGateway เก็บเป็น list เต็ม (เผื่อต้องเช็กหลายการเรียก) ส่วน SpyPaymentGateway เก็บแค่ตัวล่าสุด (เผื่อแค่ต้องมั่นใจว่าถูกเรียกครั้งเดียว)
state-based กับ interaction-based — สองสำนักของการ verify
หัวข้อที่มีชื่อว่า “state-based กับ interaction-based — สองสำนักของการ verify”ความต่างระหว่าง stub/fake กับ spy/mock ไม่ใช่แค่เรื่อง implementation แต่คือปรัชญาการ verify คนละแบบ — Vladimir Khorikov เรียกสองแนวนี้ว่า Classical school กับ London school:
- State-based (Classical) — ป้อน input ผ่าน stub/fake แล้วเรียก SUT จากนั้น assert ที่ “ผลลัพธ์” หรือ “สถานะ” ที่สังเกตได้จากภายนอก เช่น
gateway.Chargesมีสมาชิกที่ถูกต้อง หรือค่าที่Orderคืนกลับมาถูก การ verify แบบนี้ไม่สนใจว่า code ข้างในเดินทางยังไง สนใจแค่ผลลัพธ์สุดท้าย - Interaction-based (London) — verify ว่ามีการเรียก method ไหน กี่ครั้ง ด้วย argument อะไร ตรงตามที่ตั้งไว้ล่วงหน้าหรือเปล่า (เช่น
SpyPaymentGateway.ChargeCallCountข้างบน หรือmock.Verify(x => x.ChargeAsync(...), Times.Once)ถ้าใช้ mocking framework) การ verify แบบนี้ผูกกับ “วิธีทำ” ตรงๆ
Fowler เรียกคนที่ยึดแนว state-based ว่า classicist และคนที่ยึดแนว interaction-based ว่า mockist ในบทความ “Mocks Aren’t Stubs” เดียวกัน — ทั้งสองแนวใช้ได้จริง แต่ให้การรับประกันคนละแบบ และมี trade-off ต่างกันชัดเจน
เลือกตัวไหนดี — decision guide
หัวข้อที่มีชื่อว่า “เลือกตัวไหนดี — decision guide”หลักที่ใช้ได้ในสถานการณ์ส่วนใหญ่: เลือก fake/stub แล้ว assert ที่ state ก่อนเสมอ เพราะมันทนต่อการ refactor กว่า — ถ้าวันหนึ่งเปลี่ยนวิธีคำนวณข้างในโดยที่ผลลัพธ์ยังถูก state-based test จะยังผ่านอยู่ ในขณะที่ interaction-based test อาจ fail ทั้งที่ code ยังทำงานถูกต้อง (สิ่งที่บทที่แล้วเรียกว่า “ไม่ทนต่อการ refactor”)
เก็บ mock/interaction-based verification ไว้ใช้เฉพาะกรณีที่ dependency นั้นเป็น out-of-process จริงๆ และไม่มี “สถานะ” อะไรให้ตรวจ — IPaymentGateway.ChargeAsync คืนแค่ Task เปล่า ไม่มีค่าอะไรให้ assert กลับมาโดยตรง ถ้าเราไม่บันทึกอะไรไว้เลย test จะพิสูจน์ไม่ได้เลยว่าการตัดเงินเกิดขึ้นจริงหรือเปล่า นี่คือเหตุผลที่ FakePaymentGateway ฉลาด: มันแปลง interaction (การเรียก ChargeAsync) ให้กลายเป็น state ที่ตรวจได้ (Charges เป็น list) — ทำให้ test ยัง assert แบบ state-based ได้ โดยไม่ต้องพึ่ง mock framework หรือ Verify(...) เลยสักครั้ง บทที่ 5 จะเอาแนวคิดนี้ไปใช้เต็มรูปตอน test PlaceOrderHandler ทั้งก้อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Test Driven Development — วินัยเขียน test ก่อน code ที่ต้องเลือก test double ให้ถูกตั้งแต่รอบแรกเพื่อไม่ให้ test เปราะภายหลัง
- test ครั้งแรก — ทำไมสถาปัตยกรรมนี้ test ง่าย (Course A บทที่ 7) — จุดกำเนิดของ
FakePaymentGatewayที่บทนี้ทวนและขยายความ
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3stub กับ mock ต่างกันตรงไหน?