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

Test Doubles — mock/stub/fake/spy/dummy

บท​ที่​แล้ว​เรา​วัด​ว่า test “ดี” แค่​ไหน​ด้วย​สี่​เสา​หลัก — กัน​รี​เก​รส​ชัน, ทน​ต่อ​การ refactor, ฟีด​แบ็ก​เร็ว, ดูแล​รักษา​ง่าย บท​นี้​ขยับ​ไป​ที่​คำถาม​ที่​ปฏิบัติ​กว่า​นั้น: เวลา test ต้อง​คุย​กับ dependency ที่​ไม่​อยาก​ลาก​ของ​จริง​เข้า​มา (payment gateway, menu catalog ภายนอก) เรา​จะ​เอา “ของ​ปลอม” แบบ​ไหน​มา​แทนที่ ถึง​จะ​ไม่​ทำให้ test เปราะ​จน​พัง​ทุก​ครั้ง​ที่ refactor

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Domain.Tests/Fakes/ และ FoodOrdering.Application.Tests/Fakes/

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

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 ตัว​จริง​ตัว​หนึ่ง​ที่​เบา​กว่า​ปกติ

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 ผูก​อยู่​กับ​ตัว​มัน​เอง​เลย:

FoodOrdering.Application.Tests/Fakes/StubMenuCatalog.cs
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 framework
public 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;
}
FoodOrdering.Domain.Tests/SpyPaymentGatewayTests.cs
[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 เก็บ​แค่​ตัว​ล่าสุด (เผื่อ​แค่​ต้อง​มั่นใจ​ว่า​ถูก​เรียก​ครั้ง​เดียว)

ความ​ต่าง​ระหว่าง 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 ต่าง​กัน​ชัดเจน

หลัก​ที่​ใช้ได้​ใน​สถานการณ์​ส่วน​ใหญ่: เลือก 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

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

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

ข้อ 1 / 3

stub กับ mock ต่างกันตรงไหน?