單元測試準則

 摘要:單元測試曾一直都是阿里的痛,在進入阿里的這段日子裏,單元測試逐步成熟起來,也開始帶來了一些收益,這些方面都是很不錯的一些思路。後面我打算仔細的研究和整理這一過程,希望能提供一個比較可行的方案供大家參考。

網上一直流傳的一份挺不錯的單元測試準則,先保存下來。

1. 保持單元測試小巧, 快速

理論上, 任何代碼 Check-in 之前都應該把所有測試套件完整的跑一遍. 所以保持測試代碼輕快能減少開發迭代週期.

2. 單元測試應該是全自動/非交互式的

測試套件通常是定期執行的, 執行過程也必須是完全自動化纔有意義. 輸出結果需要人工檢查的測試不是一個好的單元測試.

3. 讓單元測試很容易跑起來

對開發環境進行配置, 最好是敲一條命令或是點擊一個按鈕就能把單個測試用例和測試套件跑起來.

4. 對測試進行評估

對執行的測試進行覆蓋率分析, 以便得到精確的代碼執行覆蓋率, 調查哪些代碼未被執行.

5. 立即修正失敗的測試

每個開發人員都應該保證新 Check-in 的測試用例能夠跑成功, 並且當有代碼 Check-in 現有測試用例也都能跑通過.

6. 把測試維持在單元級別

單元測試即類 (Class) 的測試. 一個 “測試類” 應該只對應於一個 “被測類”, 並且對 “被測類” 行爲的測試環境應該是隔離的. 必須謹慎的避免使用單元測試框架來測試整個程序的工作流, 這樣的測試即低效又難維護. 工作流測試 (譯註: 指跨模塊/類的數據流測試) 有它自己的地盤, 但它絕不是單元測試, 必須單獨設置和執行.

7. 由簡入繁

再簡單的測試也遠遠勝過完全沒有測試. 一個簡單的 “測試類” 會促使建立 “被測類” 基本的測試骨架, 可以對構建環境, 單元測試環境, 執行環境以及覆蓋率分析工具等有效性進行檢查, 同時也確保 “被測類” 能夠整合並被調用.

下面便是單元測試版的 Hello, world! :

void testDefaultConstruction()
{
      Foo foo = new Foo();
      assertNotNull(foo);
}

8. 保持測試的獨立性

爲了保證測試穩定可靠且便於維護, 測試用例之間決不能有相互依賴, 也不能依賴執行的先後次序.

9. Keep tests close to the class being tested

[譯註: 我未翻譯該規則, 個人認爲本條規則值得商榷, 大部分 C++ 和 Python 庫均把測試代碼從功能代碼目錄中獨立出來, 通常是創建一個和 src 目錄同級的 tests 目錄, 被測模塊/類名之前也常常 不加Test 前綴. 這麼做保證功能代碼和測試代碼隔離, 目錄結構清晰, 並且發佈源碼的時候更容易排除測試用例.]

If the class to test is Foo the test class should be called FooTest (not TestFoo) and kept in the same package (directory) as Foo. Keeping test classes in separate directory trees makes them harder to access and maintain.

Make sure the build environment is configured so that the test classes doesn’t make its way into production libraries or executables.

10. 合理的命名測試用例

確保每個測試方法只測試 “被測類” 的一個明確特性, 並且相應的給測試方法命名. 典型的命名俗定是 test[what], 比如 testSaveAs(),testAddListener(), testDeleteProperty()等.

11. 只測試公有接口

單元測試可以被定義爲 通過類的公有 API 對類進行進行測試. 一些測試工具允許測試一個類的私有成員, 但這種做法應該避免, 它讓測試變得繁瑣而且更難維護. 如果有私有成員確實需要進行直接測試, 可以考慮把它重構到工具類的公有方法中. 但要注意這麼做是爲了改善設計, 而不是幫助測試.

12. 看成是黑盒

從在第三方使用者的角度, 測試類是否滿足規定的需求. 並設法讓它出問題 (譯註: 原文 tear it apart, 本意 “將它撕碎”, 我的理解是崩潰, 出問題, 不能正確工作).

13. 看成是白盒

畢竟被測試類是程序員自寫自測的, 應該在最複雜的邏輯部分多花些精力測試.

14. 芝麻函數也要測試

通常建議所有重要的函數都應該被測試到, 一些芝麻方法, 如簡單的 settergetter 都可以忽略. 但是仍然有充分的理由支持測試芝麻函數:

  • 芝麻 很難定義. 對於不同的人有不同的理解.

  • 從黑盒測試的觀點看, 是無法知道哪些代碼是普通的.

  • 即便是再芝麻的函數, 也可能包含錯誤, 通常是 “複製粘貼” 代碼的後果:

    privatedouble weight_;
    privatedouble x_, y_;
    
    publicvoid setWeight(int weight)
    {
      weight = weight_;  // error
    }
    
    publicdouble getX()
    {
      return x_;
    }
    
    publicdouble getY()
    {
      return x_;  // error
    }

因此建議測試所有方法. 畢竟芝麻函數也容易測試.

15. 先關注執行覆蓋率

區別對待 執行覆蓋率實際測試覆蓋率. 測試的最初目標應該是確保較高的執行覆蓋率. 這樣能保證代碼在 某些 參數輸入時能有效執行. 一旦執行覆蓋率就緒, 就應該開始改進測試覆蓋率了. 注意, 實際的測試覆蓋率很難衡量 (而且往往趨近於 0%).

思考以下公有方法:

void setLength(double length);

調用 setLength(1.0) 你可能會得到 100% 的執行覆蓋率. 要達到 100% 的實際測試覆蓋率, 有多少個 double 浮點數這個方法就必須被調用多少次, 並且要一一驗證行爲的正確性. 這無疑是不可能的任務.

16. 覆蓋邊界值

確保參數邊界值均被覆蓋. 對於數字, 測試負數, 0, 正數, 最小值, 最大值, NaN (非數字), 無窮大等. 對於字符串, 測試空字符串, 單字符, 非 ASCII 字符串, 多字節字符串等. 對於集合類型, 測試空, 1, 第一個, 最後一個等. 對於日期, 測試 1月1號, 2月29號, 12月31號等. 被測試的類本身也會暗示一些特定情況下的邊界值. 基本要點是儘可能徹底的測試這些邊界值, 因爲它們都是主要 “疑犯”.

17. 提供一個隨機值生成器

當邊界值都覆蓋了, 另一個能進一步改善測試覆蓋率的簡單方法就是生成隨機參數, 這樣每次執行測試都會有不同的輸入.

想要做到這點, 需要提供一個用來生成基本類型 (如: 浮點數, 整型, 字符串, 日期等) 隨機值的工具類. 生成器應該覆蓋各種類型的所有取值範圍.

如果測試時間比較短, 可以考慮再裹上一層循環, 覆蓋儘可能多的輸入組合. 下面的例子是驗證兩次轉換 little endian 和 big endian 字節序後是否返回原值. 由於測試過程很快, 可以讓它跑上個一百萬次.

void testByteSwapper()
{
  for (int i = 0; i < 1000000; i++) {
    double v0 = Random.getDouble();
    double v1 = ByteSwapper.swap(v0);
    double v2 = ByteSwapper.swap(v1);
    assertEquals(v0, v2);
  }
}

18. 每個特性只測一次

在測試模式下, 有時會情不自禁的濫用斷言. 這種做法會導致維護更困難, 需要極力避免. 僅對測試方法名指示的特性進行明確測試.

因爲對於一般性代碼而言, 保證測試代碼儘可能少是一個重要目標.

19. 使用顯式斷言

應該總是優先使用 assertEquals(a, b) 而不是 assertTrue(a == b), 因爲前者會給出爲何導致測試失敗的更有意義的信息. 在事先不確定輸入值的情況下, 這條規則尤爲重要, 比如之前使用隨機參數值組合的例子.

20. 提供反向測試

反向測試是指刻意編寫問題代碼, 來驗證魯棒性和能否正確的處理錯誤.

假設如下方法的參數如果傳進去的是負數, 會立馬拋出異常:

void setLength(double length) throws IllegalArgumentExcepti

可以用下面的方法來測試這個特例是否被正確處理:

try {
  set Length(-1.0);
  fail();  // If we get here, something went wrong
}
catch (IllegalArgumentException exception) {
  // If we get here, all is fine
}

21. 代碼設計時謹記測試

編寫和維護單元測試的代價是很高的, 減少代碼中的公有接口和循環複雜度是降低成本, 使高覆蓋率測試代碼更易於編寫和維護的有效方法.

一些建議:

  • 使類成員常量化, 在構造函數中進行初始化. 減少 setter 方法的數量.

  • 限制過度使用繼承和公有虛函數.

  • 通過使用友元類 (C++) 或包作用域 (Java) 來減少公有接口.

  • 避免不必要的邏輯分支.

  • 在邏輯分支中編寫儘可能少的代碼.

  • 在公有和私有接口中儘量多用異常和斷言驗證參數參數的有效性.

  • 限制使用快捷函數. 對於黑箱而言, 所有方法都必須一視同仁的進行測試. 考慮以下簡短的例子:

    public void scale(double x0, double y0, double scaleFactor)
    {
      // scaling logic
    }
    
    public void scale(double x0, double y0)
    {
      scale(x0, y0, 1.0);
    }

    刪除後者可以簡化測試, 但用戶代碼的工作量也將略微增加.

22. 不要訪問預定的外部資源

單元測試代碼不應該假定外部的執行環境, 以便在任何時候/任何地方都能執行. 爲了向測試提供必需的資源, 這些資源應該由測試本身提供.

比如一個解析某類型文件的類, 可以把文件內容嵌入到測試代碼裏, 在測試的時候寫入到臨時文件, 測試結束再刪除, 而不是從預定的地址直接讀取.

23. 權衡測試成本

不寫單元測試的代價很高, 但是寫單元測試的代價同樣很高. 要在這兩者之間做適當的權衡, 如果用執行覆蓋率來衡量, 業界標準通常在 80% 左右.

很典型的, 讀寫外部資源的錯誤處理和異常處理就很難達到百分百的執行覆蓋率. 模擬數據庫在事務處理到一半時發生故障並不是辦不到, 但相對於進行大範圍的代碼審查, 代價可能太大了.

24. 合理安排測試優先次序

單元測試是典型的自底向上過程, 如果沒有足夠的資源測試一個系統的所有模塊, 就應該先把重點放在較底層的模塊.

25. 爲測試失敗做好準備

考慮下面的這個例子:

Handle handle = manager.getHandle();
assertNotNull(handle);

String handleName = handle.getName();
assertEquals(handleName, "handle-01");

如果第一個斷言失敗, 緊接其後的語句會導致代碼崩潰, 剩下的測試都將不被執行. 任何時候都要爲測試失敗做好準備, 避免單個失敗的測試項中斷整個測試套件的執行. 上面的例子可以重寫成:

Handle handle = manager.getHandle();
assertNotNull(handle);
if (handle == null) return;

String handleName = handle.getName();
assertEquals(handleName, "handle-01");

26. 寫測試用例重現 bug

每上報一個 bug, 都要寫一個測試用例來重現這個 bug (即無法通過測試), 並用它作爲成功修正代碼的標準.

27. 瞭解侷限

單元測試永遠無法證明代碼的正確性

一個跑失敗的測試可能表明代碼有錯誤, 但一個跑成功的測試什麼也證明不了.

單元測試最有效的應用場合是驗證和, 以及 迴歸測試: 當新功能增加和代碼進行重構的同時,會不會影響到舊功能的正確性.

參考資料

[1] 維基百科關於單元測試的定義: Unit Testing

[2] 白盒和黑盒測試的簡短描述: What is black box/white box testing?

[3] 我們最常用的 C++ 單元測試框架: CxxTest

[4] 我們最常用的 Java 單元測試框架: TestNG

[5] 我們最常用的 C++ 覆蓋率分析工具: LCOV

[5] 我們最常用的 Java 覆蓋率分析工具: Cobertura

[5] 更多關於不允許訪問外部資源觀點: A Set of Unit Testing Rules

[6] 來自 Apple 的單元測試建議: Unit Test Guidelines

[7] JUnit 最佳實踐: JUnit best practices

譯者推薦中文資料

  1. 來自Google的單元測試技巧

  2. 淺談測試驅動開發 (TDD)

  3. TDD/BDD會導致不完整的單元測試嗎?

  4. Mock 不是測試的銀彈

  5. 不要把 Mock 當作你的設計利器

  6. TDD 推薦教程

  7. 單元測試的七種境界

  8. 關於 <<單元測試的七種境界>> 的自我總結


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章