優秀的單元測試
定義
一個單元測試是一段自動化的程式碼,這段程式會呼叫被測試的工作單元,並對這個單元的單一最終結果的某些假設或期望進行驗證。
單元測試可靠、易讀、並且很容易維護,只要產品程式碼不發生變化,單元測試的執行結果是穩定一致的。
特點
- 可信賴
表示測試案例本身的是可以信賴的,測試本身沒有 bug,而且測試正確的事情。事情。
反之,如果測試案例是不可信賴的,那不管結果是對還是錯,對開發人員來說都是沒有意義的。 - 可維護
無法維護的測試會拖慢整個專案開發的進度,而且會讓開發人員花費大量的時間改測試,結果就是開發人員不再維護這個測試案例,直接忽略。 - 可讀
在維護程式碼,不僅僅是要閱讀程式,還需要在測試發生問題時,找到問題的癥結,如果無法理解測試的意圖,那維護就會變得異常困難。
原則
單元測試本身也是 Porduction Code 的一部分,就好比開發功能時會有基本的開發原則,同樣的,單元測試也會有一些原則需要遵守。
FIRST 原則
- Fast (快速執行):單元測試應該快速執行,以便開發人員可以頻繁地執行它們,及時發現問題並修復錯誤。
- Isolated (隔離性):每個測試案例應獨立於其他測試執行。
- Repeatable (可重複執行):可重複執行,不管執行幾次都會是一樣的結果。
- Self Validating (自我驗證):測試應該有一個明確的通過/失敗結果,當錯誤發生可以明確簡單地告訴我們問題是什麼,不需要手動比對結果來確定測試是否成功。
- Timely (即時性):單元測試應該與產品代碼同步撰寫。這有助於當代碼進行修改或擴展時,立即發現引入的問題。
開發原則
- 可讀性好:一看就知道是要驗證什麼。
- 容易維護:跟一般的開發功能一樣,當內容過於複雜,維運的難度就越高,就會讓後續的開發人員不願意或是無法知道這個測試案例該怎麼維護。
- 可靠。
- 撰寫測試很容易,執行起來快速。
- 一個測試只驗證一個預期的結果,降低不必要的測試負擔。
- 非臨時性的需求:當需求只是用來做 POC 或是 Demo,完整的測試案例只會讓開發變得綁手綁腳。
- 可以被自動化執行。
撰寫可信任的測試
決定是否 刪除/修改 測試
- 產品 bug(
無須調整測試
)
當我們調整 Porduction Code,透過測試發現了問題,這是屬於最好的情況,表示單元測試發揮了功用,讓我們提前確保問題不會發生。 - 測試 bug
一旦測試程式內有 bug,開發人員不知道到底是現有的 Production Code 有問題,還是這個測試 code 有問題,這時候就需要花時間好好釐清,先把這個問題確認後,後續才能決定是否修正這個測試。
當我們修復這個 bug 後,一定要再次確認測試通過是因為修復 bug,並不是測試了錯誤的內容。 - 語意或是 API 變更
如果測試的 API 一直持續的在變化,那可能會導致測試案例需要不斷的變調整,這種情況也許可以考慮使用工廠方法來產生測試物件解決,之後可能只需要針對工廠方法去做調整即可。 - 矛盾或無效的測試
當需求發生變化,需求已經跟既有功能不一樣,導致既有的測試發生錯誤,那就必須要刪除過時的測試,重新撰寫新的單元測試來做測試。 - 重構測試
當我們發現測試的程式碼太過雜亂,那就需要透過重構 (不影響原測試邏輯的前提下) 來整理程式碼,提升維護性。
避免測試中帶有邏輯
- 如果測試程式內包含下列語句
-
switch
if else
for
foreach
while
那表示這個測試基本上不算是單元測試,而是整合測試。
-
用來驗證單元測試的結果應該是固定的,而不是動態產生的,例如下面的程式碼
[Test]
public void ProductionLogicProblem()
{
string user ="USER";
string greeting="GREETING";
string actual= MessageBuilder.Build(user,greeting);
// 其中 user + greeting => 這個是邏輯,表示在單元測試內,我們做了跟 Build 方法內一樣的事情
Assert.AreEqual(user + greeting,actual);
}下面的做法會比較好
[Test]
public void ProductionLogicProblem()
{
string actual= MessageBuilder.Build("user","greeting");
// "user greeting" => 直接定義好結果是甚麼,讓單元測試看起來更直觀
Assert.AreEqual("user greeting",actual);
}
每個測試只測試一種情境
讓每個測試案例都只關注一個關注點,這樣不管是後續的命名,或是驗證失敗,都可以很明確的知道問題在哪裡。
程式碼覆蓋率
透過程式碼覆蓋率,可以很快地知道目前的系統有多少的功能是被保護的,當然,覆蓋率本身也只是一個參考的指標,如果沒有正確測試應該要測試的功能,就算覆蓋率是 100%,也不會有任何意義;不過反過來,如果發現目前系統的覆蓋率過低,那就可以很明確的知道,目前的系統是沒有被測試所保護的。
撰寫可維護的測試
測試私有或是被保護的方法
方法被定義為private
或是protected
,表示這個內容不想要公開給外部使用,或是希望未來異動時,不影響外部的 API。
在測試時,只應該考慮測試公開的方法,因為每一個私有方法都應該會被某個公開方法所使用,如果有一個私有方法值得被測試,那它應該是公開,或是靜態的,又或者至少是internal
的。
有了這個觀念後,我們就可以適當地去想看看,這些私有方法能否抽到新的類別中,又或是改成靜態方法之類的...等。
不要濫用Setup
方法
為每個測試方法做一些前置作業,也許是讓測試看起來簡潔的做法之一,但是實際上如果不小心沒注意,例如,把很多的準備動作都寫在這邊,對於整個測試專案來說,Setup
的複雜程度,也會直接影響測試的可讀性和可維護性。
實作測試 隔離
每個測試都應該是獨立且互不影響的,下面是一些基本的Bad Taste
,可以很明顯的感受到,有這些特性的測試,大部分都是有問題的。
- 強烈的測試順序
- 使用新版本的框架,導致測試執行順序不一致,結果測試就錯了。
- 無法只執行單一測試,因為必須要把前面的測試都執行才能執行這個測試。
- 測試維護很困難,需要考量測試的相依性和順序性,甚至還要考慮每一步導致的狀態變化。
- 無法刪除或是很難修改測試,因為其他測試會依賴這個測試的狀態改變接續執行。
- 在測試方法內,呼叫其他測試方法
兩者互相依賴,導致無法單一執行 - 共享狀態毀損
測試維護很困難,撰寫每個測試可能都需要考慮目前的共享狀態為何,無法獨立的針對單一情境去做測試。 - 依賴外部資源
這點跟共享狀態有點類似,依賴外部的資源 (ex: DB),如果沒有把資料作重置,可能會讓測試發生異常。
撰寫具有可讀性的測試
命名原則
- 測試類別:
{nameof(sut)}Tests
- 測試方法:
[UnitOfWork_[Scenario]_[ExpectedBehavior]
- UnitOfWork:被測試的方法名稱,可以透過 IDE 的提示,很快找到被測試的目標。
- Scenario:說明此測試案例的情境,例如「登入失敗」、「無效的使用者」、「密碼正確」。
- ExpectedBehavior:在此設定情境下的預期結果。