單元測試 (Unit Test)


Posted by WayneCheng on 2021-02-13

關於 單元測試 (Unit Test) 本篇將討論以下幾個問題

1. 關於 單元測試 (Unit Test)

2. 學習資源


測試環境:

OS:Windows 10
IDE:Visual Studio 2019


1. 關於 單元測試 (Unit Test)

有幾個關於單元測試的小細節想特別提出來說一下

  1. 單元測試中的「單元」?
  2. 測試涵蓋範圍?
  3. 單元測試如何命名?
  4. 3A Principle
  5. 假物件(fake)、虛設常式(Stub)、模擬物件(Mock)
  6. 假物件(fake)、虛設常式(Stub)、模擬物件(Mock)如何命名?
  7. 測試中不帶邏輯 & 每次驗證一個關注點
  8. 靜態物件

1. 單元測試中的「單元」?

「單元」指得是一個工作單元,可以小到一個方法,也可以大到好幾個類別,所以單元測試並非針對每個方法去做測試。

2. 測試涵蓋範圍?

測試涵蓋率並非高就一定好,有邏輯就可能會有問題,無論這段邏輯多麼簡單,所以重點是要涵蓋所有包含邏輯的程式碼。

3. 單元測試如何命名?

[UnitOfWork]_[Scenario]_[ExpectedBehavior]

by 單元測試的藝術

將命名分為三段

  1. 被測試目標
  2. 測試內容/情境
  3. 預期結果

不用害怕命名很長,命名最重要的就是包含所有重要資訊,且最好能讓人一眼看懂這個測試在做什麼,若團隊短中甚至長期都不會有不懂中文的人的話,用中文來命名也是不錯的選擇,接手處理時能更快了解這個測試的目的(e.g. GetUserInfoById_使用者Id不存在_回傳Null())。

4. 3A Principle

撰寫測試的其中一個重點就是可讀性,3A Principle 將測試分成三個階段

  1. Arrange:初始化相依物件
  2. Act:執行被測試目標
  3. Assert:驗證結果
[Fact]
public void GetUsers_取得兩筆UserData_回傳兩筆UserData()
{
    // Arrange
    var userRepo = Substitute.For<IRepository<User>>();
    var userData = new List<User>
                    {
                        new User { Id = 1, Name = "User01" },
                        new User { Id = 2, Name = "User02" }
                    };
    // 不包含搜尋條件,filter = null,直接將 userData 轉 IQueryable 回傳
    userRepo.Query(Arg.Any<Expression<Func<User, bool>>>())
            .Returns(userData.AsQueryable());
    var getData = new GetDataService(userRepo);

    // Act
    var result = getData.GetUsers();

    // Assert
    Assert.Equal(result.Any(x => x.Id == 1 && x.Name == "User01"), true);
    Assert.Equal(result.Any(x => x.Id == 2 && x.Name == "User02"), true);
}

上面例子可以看出,透過 3A Principle 可以很容易知道整個測試每一段的行為,有效加速閱讀。

5. 假物件(fake)、虛設常式(Stub)、模擬物件(Mock)

撰寫測試時,會將相依物件模擬出來,避免測試因為相依物件而失敗,這個模擬出來的相依物件就稱作「假物件(fake)」,且假物件(fake)又可細分為虛設常式(Stub) & 模擬物件(mock)。

該如何區分虛設常式(Stub)、模擬物件(mock)?

通常以這個模擬出來的假物件(fake)在測試中的使用方式來區分,簡單來說我們可以以單元測試最後驗證的對象是被測試目標自己內部的結果或是假物件(fake)互動的結果來區分。

舉例來說,我們測試一個在UserService中的GetUserNameById()方法

  • UserService.GetUserNameById()會藉由UserRepository.GetUserNameById()來取得 DB 資料
  • DB 中沒有資料會回傳 Null
  • UserService.GetUserNameById()取得 Null 時要拋出 Exception

基於以上設定我們將有以下兩種測試方式

  1. 測試UserService.GetUserNameById()UserRepository.GetUserNameById()回傳 Null 時是否有拋出 Exception
  2. UserRepository.GetUserNameById()這個假物件(fake)中記錄被呼叫次數,確認UserService.GetUserNameById()是否有正確呼叫

第一種測試方式驗證的目標是UserService.GetUserNameById()最終有沒有拋出 Exception,而不是去驗證UserRepository.GetUserNameById()是否有回傳 Null,這樣的使用方式就會將相依的假物件稱作「虛設常式(Stub)」。

第二種測試方式則會在相依的假物件中記錄狀態(呼叫次數),直接驗證假物件中狀態的結果,這樣的使用方式就會將相依的假物件稱作「模擬物件(mock))」。

6. 假物件(fake)、虛設常式(Stub)、模擬物件(Mock)如何命名?

通常會在名稱前/後加上「Stub/Mock」(e.g. 變數名稱_Stub),實際命名方式還是依據團隊的開發規範為主。

有使用過 NSubstitute 的朋友可能會有疑問覺得 NSubstitute 都處理掉了為什麼還要管他是虛設常式(Stub)還是模擬物件(Mock)?

這部分主要還是為了可讀性,透過變數的命名我們可以快速了解到這個單元測試的測試目標,讓接手的人不需要一一確認過後才知道這個相依是否被驗證。

7. 測試中不帶邏輯 & 每次驗證一個關注點

測試中不應包含 if/else、各種迴圈之類的邏輯,或是同時驗證多種結果,這會造成測是錯誤時還要一一排除每一種可能才知道是哪一部份的測試錯誤,而最簡單的評斷方式就是是否能容易的命名,不包含邏輯且只驗證一種結果的命名應該會很容易才是。

8. 靜態物件

在執行測試時是多執行緒同時執行的,所以我們知道測試之間不應相依,但若是使用到靜態物件則會讓情況變得複雜,若是靜態物件中存放了狀態,則靜態物件中的狀態可能在測試過程中被其他測試改變,造成測試時過時不過,雖然靜態物件還是有幾種測試方式,但若是可以還是建議重構解除靜態物件與被測試方法間的相依。


2. 學習資源

書籍

時間允許的話還是首推

線上資源

建議都看看,選擇自己較適合的即可
像是單元測試的藝術譯者 91 大 2012 年的鐵人競賽文章

或是 Will 保哥 2010 年寫的單元測試系列文

個人推薦 Toyo 的這個系列文

相依物件的處理

  1. NSubstitute 官網
  2. 聊聊程式 NSub說明書
  3. 余小章 @ 大內殿堂 #NSubstitute
  4. 如何以 NSubstitute 處理 Expression & IQueryable

完整程式碼 & 單元測試

GitHub:NSubstituteWithExpression

GitHub:NSubstituteWithExpressionTests


總結

由於太多優質的單元測試文章了,所以就不再另開系列文,只提供學習資源。另外也針對在撰寫單元測試時常被忽略的一些重點做了一些補充說明。


參考資料

  1. 單元測試的藝術

新手上路,若有錯誤還請告知,謝謝


#unittest #NSubstitute







Related Posts

DAY41:Equal Sides Of An Array

DAY41:Equal Sides Of An Array

JS30 Day 6 筆記

JS30 Day 6 筆記

SQL Injection SQL注入

SQL Injection SQL注入


Comments