開發必備之單元測試

小懶程式設計日記發表於2021-09-13

禍亂生於疏忽 單元測試先於交付。穿越暫時黑暗的時光隧道,才能迎來系統的曙光。

單元測試的相關介紹

​ 計算機世界裡的軟體產品通常是由模組組合而成的 模組又可以分成諸多子模組。 比如淘寶系統由搜尋模組、商品模組、交易模組等組成,而交易模組又分成下單模組、 支付模組、發貨模組等子模組,如此細分下去,最終的子模組是由不可再分的程式單 元組成的。對這些程式單元的測試,即稱為單元測試(Unit Testing ,簡稱單測)。單元的粒度要根據實際情況判定,可能是類、方法等,在物件導向程式設計中,通常認為最小單元就是方法。單元測試的目的是在整合測試和功能測試之前對軟體中的可測試單 元進 逐一檢查和驗證。單元測試是程式功能的基本保障,是軟體產品上線非常重要的環。

​ 雖然單元測試的概念眾所周知,但是能夠深入理解的人卻屈指可數 道的工程師更是鳳毛麟角。在很多人看來,單元測試是一件功不在當下的事情,快速完成業務功能開發才是王道,特別是在評估工作量的時候,如果開發工程師說需要額外時間來寫單測,並因此延長專案工期,估計有些專案經理就接捺不住了。其實單元測試是一件有情懷、有技術素養、有長遠收益的工作,它是保證軟體質量和效率的重要手段之一。單元測試的好處包括但不限於以下幾點:

  1. 提升軟體質量

    ​ 優質的單元測試可以保障開發質量和程式的魯棒性。在大多數網際網路企業中 開發工程師在研發過程中都會頻繁地執行測試用例,執行失敗的單測能幫助我們快速 排查和定位問題 使問題在被帶到線上之前完成修復。正如軟體工程界的一條金科玉 律一 越早發現的缺陷,其修復成本越低。一流的測試能發現未發生的故障;二流的 測試能快速定位故障的發生點 三流的測試則疲於奔命,一直跟在故障後面進行功能 迴歸。

  2. 促進程式碼優化

    ​ 單元測試是由開發工程師編寫和維 這會促使開發工程師不斷重新審視自己 的程式碼 白盒地去思考程式碼邏輯 更好地對程式碼進行設計,甚至想方設法地優化測試用例的執行效率。這個過程會促使我們不斷地優化自己的程式碼,有時候這種優化的衝 動是潛意識的。

  3. 提升研發效率

    ​ 編寫單測表面上佔用了專案研發時間 但磨刀不誤砍柴工 在後續的聯調、整合、 迴歸 試階段 單元測試覆蓋率高的程式碼通常缺陷少、問題易修復 有助於提升專案的整體研發效率。

  4. 增加重構自信

    ​ 程式碼重構往往是牽一髮而動全身的。當修改底層資料結構時,上層服務經常會受到影響。有時候只是簡單地修改一個欄位就會引起 系列錯誤。但是在有單元 測試保障的前提下 重構程式碼時我們會很 然地多一分勇氣,看到單元測試 100 行通過的剎那充滿自信和成就感。

​ 單元測試的好處不言而喻,同時我們也要摒 諸如單元 試是測試 員的工作 單元測試程式碼不需要維護等常見誤解。對於開發工程師來說 編寫並維護單元測試不 僅僅為了保證程式碼的正確性 更是一種基本素養的體現。

單元測試的基本原則

​ 巨集觀上,單元測試要符合 AIR 原則;微觀上 單元測試的程式碼層面要符合 BCDE 原則。

​ AIR 即空氣 單元測試亦是如此。當業務程式碼線上上執行時 可能感覺不到測試用例的存在和價值,但在程式碼質 的保障上,卻是非常關鍵的。新增程式碼應該同步新增測試用例,修改程式碼邏輯時也應該同步測試用例成功執行。 AIR 具體包括 :

  • A : Automatic (自動化)
  • I : Independent (獨立性)
  • R : Repeatable (可重複)

​ 單元測試應該是全自動執行的。測試用例通常會被頻繁地觸發執行 執行過程必須完全自動化才有意義 如果單元測試的輸出結果需要人工介入檢查,那麼它一定是不合格的。單元測試中不允許使用 System.out 來進行人工驗證,而必須使用斷言來驗證。

​ 為了保證單元測試穩定可靠且便於維護,需要保證其獨立性。用例之間不允許互相呼叫,也不允許出現執行次序的先後依賴。如下警示程式碼所示,testMethod2 需要呼叫 testMethod1。在執行 testMethod2 時會重複執行驗證testMethod1,導致執行效率降低。更嚴重的是,testMethod1的驗證失敗會影響 testMethod2 的執行。

@Test
public void testMethod1() {
	```
}

@Test
public void testMethod2 () {
    testMethod1();
	```
}

​ 在主流測試框架中, JUnit 的用例執行順序是無序的,而 TestNG 支援測試用例的順序執行(預設測試類內部各測試用例是按字典序升序執行的,也可以通過XML或註解 priority 的方式來配置執行順序)。

​ 單元測試是可以重複執行的,不能受到外界環境的影響。比如,單元測試通常會被放到持續整合中,每次有程式碼提交時單元測試都會被觸發執行。如果單測對外部環境(網路、服務、中介軟體等)有依賴 ,則容易導致持續整合機制的不可用。 編寫單元測試時要保證測試粒度足夠小,這樣有助於精確定位問題,單元測試 用例預設是方法級別的。單測不負責檢查跨類或者跨系統的互動邏輯,那是整合測試需要覆蓋的範圍。編寫單元測試用例時,為了保證被測模組的交付質量,需要符合BCDE原則:

  • B: Border,邊界值測試,包括迴圈邊界、特殊取值、特殊時間點、資料順序等。
  • C: Correct,正確的輸入,並得到預期的結果。
  • D: Design,與設計文件相結合,來編寫單元測試。
  • E : Error,單元測試的目標是證明程式有錯,而不是程式無錯。為了發現代程式碼中潛在的錯誤 我們需要在編寫測試用例時有一些強制的錯誤輸入(如非法資料、異常流程、非業務允許輸入等)來得到預期的錯誤結果。 由於單元測試只是系統整合測試前的小模組測試,有些因素往往是不具備的,因 此需要進行Mock,例如:
    1. 功能因素。 比如被測試方法內部呼叫的功能不可用。
    2. 時間因素。 比如雙十一還沒有到來,與此時間相關的功能點。
    3. 環境因素。 政策環境,如支付寶政策類新功能,多端環境 PC 、手機等。
    4. 資料因素。 線下資料樣本過小,難以覆蓋各種線上真實場景。
    5. 其他因素。 為了簡化測試編寫,開發者也可以將某些複雜的依賴採用 Mock 方式實現

​ 最簡單的 Mock 方式是硬編碼,更為優雅的方式是使用配置檔案,最佳的方式是使用相應的 Mock 框架,例如 JMockit、EasyMock、JMock 等。 Mock 的本質是讓我們寫出更加穩定的單元測試 隔離上述因素對單元測試的影響 使結果變得可預測,做到真正的“單元”測試。

單元測試的編寫

單元測試編寫是開發工程師的日常工作之一,利用好各種測試框架並掌握好單元測試編寫技巧,往往可以達到事半功倍的效果。本節主要介紹如何編寫 JUnit 測試用例。 我們先簡要了解一下 JUnit 單元測試框架。

JUnit 單元測試框架

​ Java 語言的單元測試框架相對統一,JUnit和TestNG 幾乎始終處於市場前兩位。 其中 JUnit 以較長的發展歷史和源源不斷的功能演進,得到了大多數使用者的青睞,也是阿里內部目前使用最多的單元測試框架。 JUnit專案的起源可以追溯到 1997 年。兩位參加“物件導向程式系統語言 和應用大會”( Conference for Object-Oriented Programming Systems, Languages & Applications )的極客開發者 Kent Beck和Erich Gamma 在從瑞士蘇黎世飛往美國亞特蘭大的飛機上,為了打發長途飛行的無聊時間,他們聊起了對當時 Java 測試過程中缺乏成熟工具的無奈,然後決定一起設計一款更好用的測試框架,於是採用結對程式設計的方式在飛機上完成了 JUnit 雛形,以及世界上第一個 JUnit單元測試用例。經過 20 餘年的發展和幾次重大版本的躍遷, JUnit 2017 月正式釋出了 5.0 定版本。 JUnit5對JDK8 及以上版本有了更好的支援(如增加了對Lambda 表示式的支援), 並且加入了更多的測試形式,如重複測試、引數化測試等。因此本書的測試用例會使 JUnit5 採編寫,部分寫法如果在 JUnit4 中不相容,則會提前說明。

JUnit5.x 由以下三個主要模組組成:

  • JUnit Platform: 用於在 JVM 上啟動測試框架,統一命令列、 Gradle和Maven等方式執行測試的入口
  • JUnit Jupiter:包含 JUnit5.x 全新的程式設計模型和擴充套件機制。
  • JUnit Vintage:用於在新的框架中相容執行 JUnit3.x和JUnit4.x的測試用例。 為了便於開發者將注意力放在測試編寫上,即不必關心測試的執行流程和結果展示,JUnit 提供了一些輔助測試的註解,常用的測試註解說明如下表所示:
  1. 註解 釋義
    @Test 註明一個方法是測試方法, JUnit 框架會在測試階段自動找出所有使用該註解標明的測試方法並執行。需要注意的是,在 JUnit5 版本中,取消了該註解的 timout引數的支援
    @TestFactory 註明一 方法是基於資料驅動的動態測試資料來源
    @ParameterizedTest 註明一個方法是測試方法,這一點同@Test註解的作用一樣。此外,該註解還可以讓一個測試方法使用不同的入參執行多次
    @RepeatedTest 從字面意思就可以看出,這個註釋可以讓測試方法自定義重複執行次數
    @BeforeEach 與JUnit4 中的@Before類似 ,可以在每一個測試方法執行前,都執行一個指定的方法,在JUnit5 中, 除了執行@Test註解的方法,還額外支援執行@ParameterizedTest 和@RepeatedTest註解的方法
    @AfterEach 與JUnit4 中的@After類似 ,可以在每一個測試方法執行後,都執行一個指定的方法,在JUnit5 中, 除了執行@Test註解的方法,還額外支援執行@ParameterizedTest 和@RepeatedTest註解的方法
    @BeforeAll 與JUnit4 中的@BeforeClass 類似,可以在每一個測試類執行前,都執行一個指定的方法
    @AfterAll 與JUnit4 中的@AfterClass 類似,可以在每一個測試類執行後,都執行一個指定的方法
    @Disabled 與JUnit4 中的@Ignore類似,註明某個測試的類或方法不再執行
    @Nested 為測試新增巢狀層級,以便組織用例結構
    @Tag 為測試類或方法新增標籤,以便有選擇性地執行

下面是個典型的 JUnit5 測試類結構:

// 定義一個測試類並指定用例在測試報告中展示名稱
@DisplayName("售票器型別測試")
public class TicketSellerTest {
    // 定義一個待測類的例項
    private TicketSeller ticketSeller;

    /**
     * 定義在整個測試類開始前執行的操作
     * 通常包括全域性和外部資源(包括測試樁)的建立和初始化
     */
    @BeforeAll
    public static void init() {
        // doSomeThing...
    }

    /**
     * 定義在整個測試類完成後執行的操作
     * 通常包括全域性和外部資源的釋放或銷燬
     */
    @AfterAll
    public static void cleanup() {
        // doSomeThing...
    }

    /**
     * 定義在每個測試用例開始前執行的操作
     * 通常包括基礎資料和執行環境的準備
     */
    @BeforeEach
    public void create() {
        this.ticketSeller = new TicketSeller();
        // doSomeThing...
    }

    /**
     * 定義在每個測試用例完成後執行的操作
     * 通常包括執行環境的清理
     */
    @AfterEach
    public void destroy() {
        // doSomeThing...
    }

    /**
     * 測試用例,當車票售出後餘票應減少
     */
    @Test
    @DisplayName("售票後餘票應減少")
    public void shouldReduceInventoryWhenTicketSoldOut() {
        ticketSeller.setInventory(10);
        ticketSeller.sell(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(9);
    }

    /**
     * 測試用例,當餘票不足時應該報錯
     */
    @Test
    @DisplayName("餘票不足應報錯")
    public void shouldThrowExceptionWhenNoEnoughInventory() {
        ticketSeller.setInventory(0);
        assertThatExceptionOfType(TicketException.class)
                .isThrownBy(() -> ticketSeller.sell(1))
                .withMessageContaining("all ticket sold out")
                .withNoCause();
    }

    /**
     * Disabled註解將禁用測試用例
     * 該測試用例會出現在最終的報告中,但不會被執行
     */
    @Disabled
    @Test
    @DisplayName("有退票時餘票應增加")
    public void shouldIncreaseInventoryWhenTicketRefund() {
        ticketSeller.setInventory(10);
        ticketSeller.refund(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(11);
    }

}

若是使用SpringBoot基於JUnit進行單元測試時,需要注意JUnit4和JUnit5的差異,如下:

在JUnit4中:

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {
    @Test
    public void contextLoads() {
    }
}

在JUnit5中:

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class ApplicationTests {
    @Test
    public void contextLoads() {
    }
}

斷言與假設

​ 當定義好了需要執行的測試方法後,下一步則是關注測試方法的細節處理, 這就離不開斷言(assert )和假設( assume):斷言封裝好了常用的判斷邏輯 ,當不滿足條件時,該測試用例會被認定為測試失敗,假設與斷言類似,只不過當條件不滿足時,測試會直接退出而不是認定為測試失敗,最終記錄的狀態是跳過。斷言和假設是單元測試中最重要的部分,各種單元測試框架均提供了豐富的方法。以 JUnit 為例,它提供了一系列經典的斷言和假設方法。

方法 釋義
fail 斷言測試失敗
assertTrue/assertFalse 斷言條件為真或為假
assertNull/assertNotNull 斷言指定值為NULL或非NULL
assertEquals/assertNotEquals 斷言指定兩個值相等或者不相等,對於基本資料型別,使用值比較;對於物件,使用equals方法對比
assertArrayEquals 斷言陣列元素全部相等
assertSame/assertNotSame 斷言指定兩個物件是否為同一個物件
assertThrows/assertDoesNotThrows 斷言是否丟擲了一個特定型別的異常
assertTimeout/assertTimeoutPreemptively 斷言是否執行超時,區別在於測試程式是否在同一個執行緒內執行
assertIterableEquals 斷言迭代器中的元素全部相等
assertLinesMatch 斷言字串列表元素是否全部正則匹配
assertAll 斷言多個條件同時滿足

相較於斷言,假設提供的靜態方法更加簡單,被封裝在 org.junit.jupiter.api. Assumptions 類, 同樣為靜態方法,如下表所示:

方法 釋義
assumeTrue
assumeFalse
先判斷給定的條件為真或假,再決定是否繼續接下來的測試

​ 相對於假設,斷言更為重要。這些斷言方法中的大多數從 JUnit 的早期版本就已經存在,並且在最新的 JUnit5 版本中依然保持著很好的相容性。當斷言中指定的條件不滿足時,測試用例就會被標記為失敗。

​ 對於斷言的選擇,優先採用更精確的斷言,因為它們通常提供了更友好的結果輸出格式(包括預期值和實際值),例如 assetEquas(100, result) 語句優於 assertTrue(100 == result)語旬。對於非相等情況的判定,比如大於、小於或者更復雜的情況 可以使用 assertTrue、assertFalse 表達,例如 ssertTrue (result > 0)。對於特別複雜的條件判定,直接使用任何一種斷言方法都不容易表達時,則可以使用 Java 語句自行構造條件,然後在不符合預期的情況下直接使用 fail 斷言方法將測試標記為失敗。另外值得強調的是,對於所有兩引數的斷言方法,例如 assertEquals、assertSame 第一個引數是預期的結果值,第二個引數才是實際的結果值。例如:

assertEquals(0, transactioMaker.increase(10).reduce(10)) ,假如測試結果錯誤,將會在測試報告中產生如下內容:

org.opentest4j.AssertionFailedError: 
Expected : 0 
Actual : 20 

倘若將引數的位置寫反,則生成報告的預期值與實際值位置也會顛倒,從而給閱讀者帶來困擾。

assertTimeout和assertTimoutPreemptively 斷言的差異在於,前者會在操作超時後繼續執行,並在最終的測試報告中記錄操作的實際執行時間;後者在到達指定時間後立即結束,在最終的報告中只體現出操作超時,但不包含實際執行的耗時。

例如 使用 assertTimeout 斷言的錯誤報告:

org.opentest4j.AssertionFailedError: execution exceeded timeout of 1000 ms by 5003 ms

使用 assertTime utPre mp ivel 斷言的錯誤報告:

org.opentest4j.AssertionFailedError: execution timed out after 1000 ms

​ 斷言負責驗證邏輯以及資料的合法性和完整性,所以有一種說法,在單元測試方法中沒有斷言就不是完整的測試 !而在實際開發過程中,僅使用 JUnit 的斷言 往往不能滿足需求,要麼是被侷限在 JUnit 僅有的幾種斷言中,對於不支援的斷言就不再寫額外的判斷邏輯,要麼花費很大的精力,對要判斷的條件經過一系列改造後,再使用 JUnit 現有的斷言。有沒有第三種選擇?答案是:有的

AssertJ 的最大特點是流式斷言(Fluent Assertions),與 Build Chain 模式或 Java8 的stream&filter 寫法類似。它允許一個目標物件通過各種 Fluent Assertions API的連線判斷,進行多次斷言,並且對 IDE 更友好。但是 AssertJ的assertThat 的處理方法和之前有些不同,它利用 Java 的泛型,同時增加了目標型別對應的 XxxxAssert類,簽名為public static AbstractCharSequenceAssert<?,String> assertThat(String acutal),而 JUnit 中的public static void assertThat() 是void 返回,其中, AbstractCharSequenceAssert 是針對 String 物件的,這樣不同的型別有不同斷言方法,如String和Date 就有不一樣的斷言方法。

下面通過一個例子,來一起認識一下強大的 AssertJ。首先使用 JUnit 的經典斷言實現一段測試:

/**
 * 使用Junit的斷言
 */
public class JUnitSampleTest {

    @Test
    public void testUsingJUnitAssertThat() {
        // 字串判斷
        String s = "abcde";
        Assertions.assertTrue(s.startsWith("ab"));
        Assertions.assertTrue(s.endsWith("de"));
        Assertions.assertEquals(5,s.length());

        // 數字判斷
        Integer i = 50;
        Assertions.assertTrue(i > 0);
        Assertions.assertTrue(i < 100);
        
        // 日期判斷
        Date date1 = new Date();
        Date date2 = new Date(date1.getTime() + 100);
        Date date3 = new Date(date1.getTime() - 100);
        Assertions.assertTrue(date1.before(date2));
        Assertions.assertTrue(date1.after(date3));

        // List判斷
        List<String> list = Arrays.asList("a", "b", "c", "d");
        Assertions.assertEquals("a",list.get(0));
        Assertions.assertEquals(4,list.size());
        Assertions.assertEquals("d",list.get(list.size() - 1));

        //Map判斷
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        Set<String> set = map.keySet();
        Assertions.assertEquals(3, set.size());
        Assertions.assertTrue(set.containsAll(Arrays.asList("A","B","C")));
    }

}

下面,我們使用 AssertJ來完成同樣的斷言:

/**
 * 使用AssertJ斷言
 */
public class AssertJSampleTest {

    @Test
    public void testUsingAssertJ() {
        // 字串判斷
        String s = "abcde";
        Assertions.assertThat(s).as("欄位串判斷:判斷首位及長度")
                .startsWith("ab").endsWith("de").hasSize(5);

        // 數字判斷
        Integer i = 50;
        Assertions.assertThat(i).as("數字判斷:數字大小比較")
                .isGreaterThan(0).isLessThan(100);

        // 日期判斷
        Date date1 = new Date();
        Date date2 = new Date(date1.getTime() + 100);
        Date date3 = new Date(date1.getTime() - 100);
        Assertions.assertThat(date1).as("日期判斷:日期大小比較")
                .isBefore(date2).isAfter(date3);

        // List判斷
        List<String> list = Arrays.asList("a", "b", "c", "d");
        Assertions.assertThat(list).as("List的判斷:首尾元素及長度")
                .startsWith("a").endsWith("d").hasSize(4);

        //Map判斷
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        Assertions.assertThat(map).as("Map的判斷:長度及key值")
                .hasSize(3).containsKeys("A", "B", "C");
    }

}

本文程式碼地址:https://gitee.com/reminis_com/junit-demo

相關文章