【單元測試】爲什麼要寫單元測試?怎麼寫?

爲什麼不想寫單元測試

  • 單元測試太浪費時間了。
    隨着系統的複雜度增加,你的一次改動可能引發出5個bug,或者你的bug被發現的時間延後了,堆積到了一起,那麼一段時間後,別人加班半小時寫單元測試,你會加班到天亮改BUG。
  • 有測試人員幫我測,我還寫什麼單元測試。
    測試分很多階段,比如單元測試、集成測試、系統測試、驗收測試等,只有單元測試屬於開發人員的工作,單元測試是開發人員在知道代碼內部邏輯的情況下,有目的的測試,爲自己的代碼編寫單元測試並通過是對測試人員負責,更是對自己負責。
  • 代碼編譯通過就對了,不需要測試。
    趁早告別代碼,代碼不需要你。
  • 這是個老系統、這個部分代碼不是我寫的,它們本來就沒有單元測試。
    如果你要修改這部分沒有單元測試代碼,那麼首先編寫單元測試將會是一個很好了解代碼邏輯的過程,並且可以保證你的修改是可測試的,有了足夠的單元測試,今後的修改、重構、擴展纔會有最基本的保障。
  • 不知道怎麼寫單元測試。
    你需要學習。

爲什麼要寫單元測試

  • 讓我們對自己的代碼有信心。
    如果修改一個功能如果沒有任何測試代碼,只能靠接口測試,當業務邏輯相對簡單的時候可能還比較可行,但當業務邏輯非常複雜流程很長的時候,可能一個很小的代碼錯誤就會讓這次精心設計的測試場景泡湯,又得重新來過,而這個很小的代碼錯誤在跑整個流程之前可以通過一個簡單的單元測試發現。如果沒有單元測試,長此以往,我們總是覺得自己的代碼可能有問題,但是又無從檢查,如果修改多次仍然有問題,慢慢地就失去了耐心和信心。
  • 讓BUG發現的時間提前。
    測試時會將一些邊界條件作爲測試用例,而這些邊界條件在實際使用中可能會很晚才發現,到時候程序異常出現BUG,改動起來一定會比一開始就發現這些問題更費時間。如果基於TDD的模式開發,一些代碼邏輯的BUG、邊界條件的疏忽問題都會提前得到解決。
  • 爲代碼重構保駕護航。
    重構代碼和重寫代碼的重要區別是:重寫可能導致系統暫時不可用,但重構系統一定是隨時可用的,那怎麼保證重構過程中系統功能不受影響呢?單元測試可以爲重構提供一定的幫助,如果我們用提取函數的手法重構了一段代碼,直接跑原有的測試用例就可以保證重構沒有破壞原來的邏輯結構。
  • 通過單元測試快速熟悉代碼。
    單元測試中的用例可以說是一種很好的文檔,通過查看單元測試可以很快了解代碼邏輯,特別是一些邊界條件和歷史bug。
  • 優化設計。
    編寫單元測試驅動開發從調用者的角度去設計代碼,讓代碼易於調用和測試,特別是使用TDD測試驅動開發的方式,開發者會在開發中不斷調整和重構,讓代碼有一個較好的結構和設計,並解除軟件中的耦合。
  • 可快速持續迴歸。
    結合持續集成,任何代碼的修改都可以快速回歸,而不是需要通過接口測試覆蓋可能修改的路徑,後者太麻煩不知能,也不一定每次都能覆蓋所有路徑。

什麼時候寫單元測試

  • 寫代碼之前。
    這正是TDD提倡的開發模式:在寫任何業務代碼之前,甚至都不用定義接口、方法,先寫測試用例,如果測試用例不通過,那麼回去修改業務代碼,直到所有測試用例寫完並通過,其中編譯不通過也算一種測試用例不通過。這種方式的好處是測試代碼和業務代碼先後完成,在不斷修改業務代碼過程中,重構時刻進行,讓代碼有更好的結構,並保證代碼可以滿足所有測試用例。
  • 寫代碼的時候。
    先編寫少量業務代碼,然後編寫單元測試,測試通過後再繼續寫業務代碼,直到業務代碼寫完並且所有單元測試通過。這種方式和第一種時間上基本一致,但是側重點不同,本方法有明顯的的缺陷:如果沒有完全瞭解需求,沒有足夠的測試用例,那麼可能會在編寫業務代碼過程中對之前的邏輯進行修改,這可能會同時修改之前的測試用例及斷言。而TDD也可以理解爲Task-Driven Development,在開發之前要對問題進行分析並進行任務分解,是基於瞭解需求的情況下開發,所以重複工作相對較少。
  • 寫完代碼再寫測試。
    這樣的好處無非寫業務代碼的時候不考慮單元測試,不好的地方也比較明顯:可能寫業務邏輯比較任性,不會站在測試和調用者的角度去改善代碼的設計;寫完代碼再寫測試,可能會爲了提高單元測試覆蓋率而測試,並沒有特別多有意義的測試;寫完代碼後的單元測試可能出現粒度較大的情況,導致測試之間的耦合度較高,可讀性較差,可維護性不高;更有可能因爲寫完業務代碼來不及寫單元測試甚至懶得寫單元測試而不寫單元測試。

綜上所述,寫單元測試最好的時機是寫業務代碼之前;如果做不到這樣,可以先寫部分業務代碼,再寫部分單元測試,兩者幾乎同時完成;相對不好的時機是寫完整個業務代碼之後,但對於沒有單元測試的代碼,可能只有通過這種方式增加單元測試。

怎麼寫單元測試

  • 使用單元測試框架。
    Java中最流行的莫過於JUnit,這些工具遠比自己寫一個執行、驗證流程來得方便,比如JUnit支持註解、允許忽略某些測試、允許特定順序執行某些測試、支持初始化和清理、支持在不同運行環境中測試、可以通過Maven等構建工具來做持續集成和自動執行測試。
  • 強烈建議使用測試驅動開發模式(TDD)。
    TDD會專門在後面的一個小節總結。
  • 使它們短。
    簡單明瞭,一個有意義的單元測試不需要太長。
  • 不要寫重複代碼。
    複製粘貼並不是好事,儘量使用setup/teardown或輔助方法減少重複代碼。
  • 組合好過繼承。
    base class可能包含一些常用代碼,除非規定base class不能再添加功能,否則base class會越來越臃腫,難以管理和共享。
  • 快。
    儘量移除外部依賴,比如用h2內存數據庫代替真實數據庫、mock其他服務的接口、mock文件操作等,否則執行一次單元測試可能花費巨長的時間,這讓測試和開發無法快速切換。
  • 評估代碼覆蓋率。
    高代碼覆蓋率並不能保證不出BUG,甚至不能保證代碼邏輯是完美的,但絕對可以保證更多的代碼被某一處執行過,至少在現有的測試用中不會拋出異常,斷言關注的點也沒有問題。所以寫單元測試的時候結合jacoco、sonar或IDEA覆蓋率工具評估覆蓋率,可以查看自己是否遺漏了某些測試用例,是否有一些邏輯從未到達過。
  • 儘可能將測試數據外置化。
    JUnit測試可以通過@Parameterized將入參和斷言等作爲參數傳入,當需要添加用例的時候就不要添加代碼了,直接添加用例即可:
    package github.clyoudu.util;
     
    import org.junit.Assert;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.junit.runners.Parameterized;
     
    import java.util.Collection;
     
    /**
     * Create by IntelliJ IDEA
     *
     * @author chenlei
     * @dateTime 2019/8/6 20:23
     * @description FileUtil1Test
     */
    @RunWith(Parameterized.class)
    public class FileUtil1Test {
     
        private String path;
        private String expected;
     
        public FileUtil1Test(String path, String expected) {
            this.path = path;
            this.expected = expected;
        }
     
        @Parameterized.Parameters
        public static Collection<Object[]> getTestData() {
            return ArraysUtil.asList(new Object[][]{
                    {"///a//b//////c/d/e.txt", "/a/b/c/d/e.txt"},
                    {"C:\\a\\\\b\\\\\\\\\\c\\d\\\\\\e.txt", "C:\\a\\b\\c\\d\\e.txt"}
            });
        }
     
        @Test
        public void test() {
            Assert.assertEquals(expected, FileUtil.formatFilePath(path));
        }
     
    }
    
  • 使用斷言而不是打印語句。
    使用JUnit工具就是要通過斷言來自動判斷執行結果和期望是否一致,千萬不要使用log打印到控制檯來肉眼判斷。
  • 生成具有確定性結果的測試。
    如果一個方法每次返回的結果是不可預期的,比如一個生成隨機數的方法,那爲這個方法編寫單元測試幾乎沒有意義,因爲不能得到可以預測的結果,這時候可以針對返回值的範圍等作出斷言,讓返回值是有可比較的。
  • 不要忽略測試。
    測試跑不過了,發現是環境問題,直接加@Ignore註解忽略掉,這不是解決測試不通過的方法,應該從其他方面來讓這個測試重新通過,比如剔除外部依賴、mock其他服務的接口等等。
  • 測試錯誤情景、邊界情景和正常情景。
    比如雪球規定某個字段只能輸入2-30個字符,那麼長度小於2、大於30、2-30、2、30的字符串都應該被作爲輸入驗證,同時null值也應當作爲異常情景測試用例。
  • 設計測試。
    測試並不是不需要設計的,業務代碼中的壞味道在測試代碼中一樣存在,需要清除這些壞味道,讓測試代碼理解和維護起來一樣順心順手。

如何編寫優秀的單元測試

  • 編寫可靠的測試
    • 依據實際情況合理地刪除或修改單元測試: 如果確定是測試缺陷,而不是產品缺陷(被測試代碼缺陷)時,需要立刻修改相關單元測試代碼;如果被測試的產品代碼的語義或者API變更導致測試失敗,這時是需要修改測試,使用新的語義;如果看到測試名含義不清或者單元測試的可維護性差就應該在保證單元測試基本功能前提下修改測試名稱或者重構測試;如果同一個功能多個單元測試,請刪除重複測試。
    • 避免在單元測試代碼中包含邏輯: 包含邏輯的測試是指測試代碼中包含switch、if/else、for/while等控制流語句。這樣的測試可讀性差,代碼脆弱,測試代碼的複雜度高,容易包含缺陷,測試結果不容易重現。
    • 每個單元測試只測試一個關注點: 所謂的一個關注點就是指一個工作單元的一個最終結果:一個返回值、系統狀態的一個改變、對第三方對象的一個調用。測試多個關注點一方面不利於測試命名,另一方面很多單元測試框架中,一個失敗斷言就會拋出一個特殊類型的異常,後面代碼不會繼續執行,這樣不利於收集測試失敗原因。
    • 區分單元測試和集成測試
    • 用代碼審查確保代碼覆蓋率: 如果你做了代碼審查、測試審查、確保測試優秀而且覆蓋了所有代碼,那麼就可以避免犯簡單愚蠢的錯誤,同時也可以從持續的學習中獲益。
  • 編寫可讀的測試
    • 單元測試的命名標準: 合理地命名測試,主要目的是爲了使後來的開發者從爲了理解測試而閱讀代碼的負擔中解脫出來。測試名應該包含三部分:被測試方法名、測試場景(即測試使用的條件)、預期行爲(即被測試方法的最終結果)。
    • 單元測試中的變量命名規範: 單元測試除了主要的測試功能之外,它還爲API提供某種形式的文檔。通過合理命名變量,幫助閱讀測試的人可以儘快理解你要驗證什麼(從而更加理解產品代碼中想要實現什麼功能)。
    • 斷言和操作分離
    • 避免濫用setup和teardown(before和after): 比如在setup中準備stub和mock對象,這種情況就會導致閱讀測試的人意識不到測試中使用了模擬對象,也不知道這些模擬對象預期是什麼。
  • 編寫可維護的測試
    • 只測試公共契約,避免測試私有或者受保護的方法: 私有方法可以看做是系統內部契約,這個內部契約是動態,在系統重構時可能會被隨時修改,因此針對這些內部契約的單元測試也很可能會失敗。而內部契約最終都會被一個公共契約(公共方法、整體功能)所調用,也就是說任何私有方法通常都是一個更大的工作單元的一部分。
    • 去除重複代碼: 可以使用輔助方法或者setup來去除重複代碼的問題
    • 實施測試隔離: 測試隔離是指每個測試都只生活在自己的小世界中,它與其他測試之間沒有任何依賴關係,甚至不知道其他測試存在。
      幾種常見的測試隔離的反模式:
      • 測試結果依賴測試執行的順序
      • 測試調用其他測試方法
      • 測試中使用的共享資源(內存或外部資源)沒有得到清理或回滾
    • 避免對不同關注點多次斷言,儘量使用參數化測試或者對每個關注點設計單獨的測試用例
    • 避免過度指定
      常見的過度指定例子
      • 對系統內部契約進行斷言
      • 使用過多的模擬對象
      • 精確匹配
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章