禍亂生於疏忽 單元測試先於交付。穿越暫時黑暗的時光隧道,才能迎來系統的曙光。
單元測試的相關介紹
計算機世界裡的軟體產品通常是由模組組合而成的 模組又可以分成諸多子模組。 比如淘寶系統由搜尋模組、商品模組、交易模組等組成,而交易模組又分成下單模組、 支付模組、發貨模組等子模組,如此細分下去,最終的子模組是由不可再分的程式單 元組成的。對這些程式單元的測試,即稱為單元測試(Unit Testing ,簡稱單測)。單元的粒度要根據實際情況判定,可能是類、方法等,在物件導向程式設計中,通常認為最小單元就是方法。單元測試的目的是在整合測試和功能測試之前對軟體中的可測試單 元進 逐一檢查和驗證。單元測試是程式功能的基本保障,是軟體產品上線非常重要的環。
雖然單元測試的概念眾所周知,但是能夠深入理解的人卻屈指可數 道的工程師更是鳳毛麟角。在很多人看來,單元測試是一件功不在當下的事情,快速完成業務功能開發才是王道,特別是在評估工作量的時候,如果開發工程師說需要額外時間來寫單測,並因此延長專案工期,估計有些專案經理就接捺不住了。其實單元測試是一件有情懷、有技術素養、有長遠收益的工作,它是保證軟體質量和效率的重要手段之一。單元測試的好處包括但不限於以下幾點:
-
提升軟體質量
優質的單元測試可以保障開發質量和程式的魯棒性。在大多數網際網路企業中 開發工程師在研發過程中都會頻繁地執行測試用例,執行失敗的單測能幫助我們快速 排查和定位問題 使問題在被帶到線上之前完成修復。正如軟體工程界的一條金科玉 律一 越早發現的缺陷,其修復成本越低。一流的測試能發現未發生的故障;二流的 測試能快速定位故障的發生點 三流的測試則疲於奔命,一直跟在故障後面進行功能 迴歸。
-
促進程式碼優化
單元測試是由開發工程師編寫和維 這會促使開發工程師不斷重新審視自己 的程式碼 白盒地去思考程式碼邏輯 更好地對程式碼進行設計,甚至想方設法地優化測試用例的執行效率。這個過程會促使我們不斷地優化自己的程式碼,有時候這種優化的衝 動是潛意識的。
-
提升研發效率
編寫單測表面上佔用了專案研發時間 但磨刀不誤砍柴工 在後續的聯調、整合、 迴歸 試階段 單元測試覆蓋率高的程式碼通常缺陷少、問題易修復 有助於提升專案的整體研發效率。
-
增加重構自信
程式碼重構往往是牽一髮而動全身的。當修改底層資料結構時,上層服務經常會受到影響。有時候只是簡單地修改一個欄位就會引起 系列錯誤。但是在有單元 測試保障的前提下 重構程式碼時我們會很 然地多一分勇氣,看到單元測試 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,例如:
- 功能因素。 比如被測試方法內部呼叫的功能不可用。
- 時間因素。 比如雙十一還沒有到來,與此時間相關的功能點。
- 環境因素。 政策環境,如支付寶政策類新功能,多端環境 PC 、手機等。
- 資料因素。 線下資料樣本過小,難以覆蓋各種線上真實場景。
- 其他因素。 為了簡化測試編寫,開發者也可以將某些複雜的依賴採用 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 提供了一些輔助測試的註解,常用的測試註解說明如下表所示:
-
註解 釋義 @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");
}
}