趁著年前有點時間來讀一本放在桌上有段時間的書 單元測試的藝術 - 以 JavaScript 為例 ,裡面講到 Stub/Mock/Fake 差異的時候沒有很懂,因此重新學習了這三個的含義以及應用情境。
雖然書是用 JavaScript,但我最近主要碰的是 Dotnet,因此本文章的所有內容會以 Dotnet 為主
Dotnet 10 + xUnit + Moq
情境一 假設有一個服務在算訂單總價,會去查會員折扣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class OrderService { private readonly IMemberService _memberService; public OrderService (IMemberService memberService ) { _memberService = memberService; } public decimal CalculateTotal (string memberId, decimal amount ) { var discount = _memberService.GetDiscount(memberId); return amount * (1 - discount); } }
1 2 3 4 public interface IMemberService { decimal GetDiscount (string memberId ) ; }
Stub 這個測試手法的重點是 OrderService 的結果,GetDiscount 的呼叫只需要在意可以讓程式運作下去就好
因此建立一個 Stub 物件繼承 IMemberService 並且固定回傳數字
1 2 3 4 5 6 7 public class MemberServiceStub : IMemberService { public decimal GetDiscount (string memberId ) { return 0.1 m; } }
1 2 3 4 5 6 7 8 9 10 [Fact ] public void CalculateTotal_With10PercentDiscount (){ var stub = new MemberServiceStub(); var service = new OrderService(stub); var total = service.CalculateTotal("A123" , 100 ); Assert.Equal(90 , total); }
Mock 基於 Stub 的概念更往上增加確認方法有被呼叫,以及怎麼呼叫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [Fact ] public void Should_Call_MemberService_Once (){ var mock = new Mock<IMemberService>(); mock.Setup(m => m.GetDiscount("A123" )) .Returns(0.1 m); var service = new OrderService(mock.Object); var total = service.CalculateTotal("A123" , 100 ); Assert.Equal(90 , total); mock.Verify(m => m.GetDiscount("A123" ), Times.Once); }
Fake 有邏輯但不是完整的實作,通常用於當作假的資料庫類型
例如 MemberService 實際上有去拿資料庫的資料,因此我們用 Dictionary 存一些資料並且方法裡面可以拿到對應的設定,有一定程度的邏輯但又不是很完整
在這邊可以透過傳入參數的方式,一次驗證不同使用者並且有符合預期的計算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class MemberServiceFake : IMemberService { private readonly Dictionary<string , decimal > _discounts = new () { { "VIP" , 0.2 m }, { "NORMAL" , 0.05 m } }; public decimal GetDiscount (string memberId ) { return _discounts.TryGetValue(memberId, out var d) ? d : 0 ; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 [Theory ] [InlineData("VIP" , 80) ] [InlineData("NORMAL" , 95) ] [InlineData("A123" , 100) ] public void CalculateTotal_VipMember (string memberId, int expectedTotal ){ var fake = new MemberServiceFake(); var service = new OrderService(fake); var total = service.CalculateTotal(memberId, 100 ); Assert.Equal(expectedTotal, total); }
情境二 以情境一來說 stub 和 mock 的差異性沒有到很大,因此來調整一下程式,讓兩者有比較明顯的比較
在上線後發現有些使用者會多輸入到空白在前後,而找不到會員資料,因此要將傳進來的參數處理過
1 2 3 4 5 6 7 8 public decimal CalculateTotal (string memberId, decimal amount ){ memberId = memberId.Trim(); var discount = _memberService.GetDiscount(memberId); return amount * (1 - discount); }
就算加上了這個條件測試案例不用特別調整沒錯 但我們應該要新增測試資料,要來確保這樣的情境可以符合
為了要測試多組資料,使用 InlineData 的方式將會員資料傳進去,這樣測試案例都不特別動就可以達成目的
1 2 3 4 5 6 7 8 9 10 11 12 13 [Theory ] [InlineData("A123" ) ] [InlineData("A123 " ) ] [InlineData(" A123" ) ] public void CalculateTotal_With10PercentDiscount (string memberId ){ var stub = new MemberServiceStub(); var service = new OrderService(stub); var total = service.CalculateTotal(memberId, 100 ); Assert.Equal(90 , total); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [Theory ] [InlineData("A123" ) ] [InlineData("A123 " ) ] [InlineData(" A123" ) ] public void Should_Call_MemberService_Once (string memberId ){ var mock = new Mock<IMemberService>(); mock.Setup(m => m.GetDiscount(memberId)) .Returns(0.1 m); var service = new OrderService(mock.Object); var total = service.CalculateTotal(memberId, 100 ); Assert.Equal(90 , total); mock.Verify(m => m.GetDiscount(memberId), Times.Once); }
調整以後,搭配修改前的程式沒問題,但 mock 的寫法搭配修改後的程式就掛了 因為 GetDiscount 預期拿到的是去除空白的資料也就是 A123,可以發現 stub 的寫法反而沒事
這邊就要來重新思考,在這個情境中 GetDiscount 被呼叫到是不是重要的事情 如果是重要並且就是要測試到會員資料應該是被 trim 過後的,那使用 mock 的寫法就沒有錯,那就應該要改測試的寫法,讓情境可以符合;若沒有需要,直接使用 stub 就可以了
若上面的測試要改正確,可以這樣調整
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [Theory ] [InlineData("A123" ) ] [InlineData("A123 " ) ] [InlineData(" A123" ) ] public void Should_Call_MemberService_Once (string memberId ){ var mock = new Mock<IMemberService>(); mock.Setup(m => m.GetDiscount("A123" )) .Returns(0.1 m); var service = new OrderService(mock.Object); var total = service.CalculateTotal(memberId, 100 ); Assert.Equal(90 , total); mock.Verify(m => m.GetDiscount("A123" ), Times.Once); }
總結 這三種測試替身各自有適合的使用場景:
類型
它的意義是什麼
你在測什麼
典型特徵
常見誤用
Stub
提供固定或可預期的回傳值
結果(Input → Output)
回傳資料,不驗證互動
為了方便卻改用 Mock
Mock
驗證物件之間的互動
行為(有沒有被呼叫)
可 Verify 次數、參數
驗證太多內部細節
Fake
簡化但可運作的真實實作
整體流程
有邏輯,通常是 in-memory
被拿來當 Stub 用
挑選的原則很簡單:如果只在意結果用 Stub,如果要驗證互動用 Mock,如果需要模擬複雜邏輯用 Fake