Java 中常見的單元測試
我們?yōu)槭裁磳懖缓脝卧獪y試
寫不好單元測試的情況有很多,很多時候我們也是被需求壓著身不由己的就開始 “ 胡編亂寫” 了。甚至有的時候我們都不知道這個項目可以運行多長時間,項目剛發(fā)布完就可能進入到另一個項目的開發(fā)周期中,周而復(fù)始,更沒有時間寫單元測試了。
開發(fā)人員有一萬種理由不寫單元測試:
- 沒有充分的時間:通常項目中迭代周期短,時間短任務(wù)重,領(lǐng)導(dǎo)昨天晚上的奇思妙想,恨不得今早上就能上線,開發(fā)人員疲于應(yīng)付,哪有時間編寫單元測試??。
- 需求不確定:對于需求變化特別大的項目,今天寫的單元測試,明天就不能用了,甚至剛寫完單元測試,需求改了,什么有些是邊開發(fā)邊磨合需求,這樣更沒法提前寫好單元測試或者事后補足。如果大家已經(jīng)習(xí)慣了天天改需求,誰還會寫單元測試呀 ??。
- 開發(fā)過分依賴測試團隊:認為測試是測試團隊的事情,如果不寫兩個 bug,他們的績效怎么辦 ??。
- 對單元測試的意識不強:又不是不能用,自己過了一遍就得了 ??。
- 對單元測試沒有明確的要求。公司或者 QA 團隊,甚至開發(fā) Leader 對于單元測試沒有明確的要求,所以不寫單元測試。(大家都不寫,我不能卷死他們呀 ??)
- 缺乏單元測試必要的技能和工具:大多數(shù)還停留在通過
main和System.out方法來做測試,效率不高,還留下了很多無用的方法 ??。
當(dāng)然不只是單元測試,其實開發(fā)連注釋都不寫的 ???????。
單元測試的重要性
1. 代碼質(zhì)量
單元測試提高了代碼的質(zhì)量。在實際編碼之前編寫測試會讓你去更多的思考方法或者對象的邊界,使您編寫更好的代碼。
2. 及早發(fā)現(xiàn)軟件缺陷
問題是在早期階段發(fā)現(xiàn)的。由于單元測試是由在集成之前測試單個代碼的開發(fā)人員執(zhí)行的,因此可以很早就發(fā)現(xiàn)問題,并且可以在不影響其他代碼的情況下解決問題。這既包括開發(fā)者實現(xiàn)中的bug,也包括單元規(guī)范中的缺陷或缺失部分。
3. 易于重構(gòu)
完善的單元測試可以驗證在重構(gòu)代碼或者更新某些依賴的情況下,確保整個系統(tǒng)依然能正常的工作。當(dāng)然如果重構(gòu)已經(jīng)改變原來的整體邏輯,單元測試也要跟著改動
當(dāng)開發(fā)者向軟件添加越來越多的功能時,有時需要更改舊的設(shè)計和代碼。然而,更改已經(jīng)測試過的代碼既有風(fēng)險又代價高昂。如果我們有適當(dāng)?shù)膯卧獪y試,那么我們就可以自信地進行重構(gòu)。
4. 簡化調(diào)試過程
單元測試有助于簡化調(diào)試過程。如果測試失敗,那么只需要調(diào)試代碼中的最新更改。
5. 提供文檔
單元測試提供了系統(tǒng)的文檔。希望了解單元提供什么功能以及如何使用它的開發(fā)人員可以查看單元測試,以獲得對單元接口(API)的基本理解。
6. 設(shè)計
編寫測試首先迫使您在編寫代碼之前仔細考慮您的設(shè)計以及它必須完成的任務(wù)。這不僅能讓你集中注意力,還能讓你創(chuàng)造更好的設(shè)計。測試一段代碼迫使您定義該代碼負責(zé)什么。如果您可以很容易地做到這一點,那就意味著代碼的職責(zé)定義良好,因此它具有很高的內(nèi)聚性。
當(dāng)然有興趣的可以看看「測試驅(qū)動開發(fā) TDD」
7. 降低成本
由于bug很早就被發(fā)現(xiàn)了,單元測試有助于降低bug修復(fù)的成本。想象一下在開發(fā)的后期階段,比如在系統(tǒng)測試或驗收測試期間發(fā)現(xiàn)的bug的成本。當(dāng)然,較早檢測到的bug更容易修復(fù),因為稍后檢測到的bug通常是許多更改的結(jié)果,并且您不知道是哪一個導(dǎo)致了bug。
如何寫單元測試
上面講了這么多啰里啰嗦的問題,那我們應(yīng)該怎么寫呢?首先我們要明確我們寫單元測試的目的和原則:
目的
- 在開發(fā)階段提前減少 Bug
- 提高單元測試覆蓋率
- 在重構(gòu)時候,可以進行驗證測試
原則
- 獨立(可獨立運行,不影響業(yè)務(wù),且不要依賴于第三方服務(wù)的結(jié)果)
- 可重復(fù)(多次測試,結(jié)果是一樣的)
- 自動化(總不能運行一次,改一次代碼吧)
- 有明確預(yù)期(根據(jù)傳參知道結(jié)果,總不能單元測試測試隨機數(shù))
一些技巧(讓我們開始寫單測吧 ??)
注意: 以下代碼使用 Java 8 和 Maven 環(huán)境下運行,其他環(huán)境不保證不出錯
放棄寫 main 和 sysout 吧 ??
比如我們寫了一個工具類(為了展示方便,刪除了具體的實現(xiàn)),這是幾個比較常用的
package com.example.ut.util;
import java.util.Objects;
public final class StringUtil {
private StringUtil() {}
public static String firstNonBlank(String... params) {}
public static String firstNonNull(String... params) {}
public static boolean isNullOrEmpty(String string) {}
public static boolean isBlank(String string) {}
public static boolean hasText(String string) {}
public static boolean hasLength(String string) {}
public static String commonPrefix(CharSequence a, CharSequence b) {}
public static String commonSuffix(CharSequence a, CharSequence b) {}
public static String lenientFormat(String template, Object... args) {}
}
比如我們可以看到很多通過直接在 StringUtil 里面通過 main 方法來測試一下各個方法能不能用,比如這樣:
public final class StringUtil {
public static void main(String[] args) {
System.out.println(firstNonBlank(null, "", "b", "", "d"));
}
...
}
這樣的測試有意義嗎?或許當(dāng)時寫代碼的時候確實可以用,但是如何檢驗正確性呢?如果重構(gòu)的時候,如果發(fā)現(xiàn)已經(jīng)和原來的行為不一致了呢?
使用 JUnit5 來進行簡單的測試
What is JUnit 5?
Unlike previous versions of JUnit, JUnit 5 is composed of several different modules from three different sub-projects.
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
The JUnit Platform serves as a foundation for launching testing frameworks on the JVM. It also defines the TestEngine API for developing a testing framework that runs on the platform. Furthermore, the platform provides a Console Launcher to launch the platform from the command line and a JUnit 4 based Runner for running any TestEngine on the platform in a JUnit 4 based environment. First-class support for the JUnit Platform also exists in popular IDEs (see IntelliJ IDEA, Eclipse, NetBeans, and Visual Studio Code) and build tools (see Gradle, Maven, and Ant).
JUnit Jupiter is the combination of the new programming model and extension model for writing tests and extensions in JUnit 5. The Jupiter sub-project provides a TestEngine for running Jupiter based tests on the platform.
JUnit Vintage provides a TestEngine for running JUnit 3 and JUnit 4 based tests on the platform.
JUnit 是一個在 Java 比較基礎(chǔ)的單元測試框架,主要為了單元測試而生,現(xiàn)在已經(jīng)到了 JUnit 5, 這里也主要使用 JUnit 5,而不是 JUnit 4。
第一步:引入依賴
這里的版本隨意,能用就行
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.6.3</version>
<scope>test</scope>
</dependency>
第二步:生成測試代碼
在 IDEA 中,如果要為某個類或者方法寫單元測試很簡單,直接在指定的類或者方法 ctrl + enter, 即可彈出生成代碼的快捷提示,選擇 Test 即可,這里選擇 firstNonNull,hasText,commonPrefix 來測試一下。

自動生成的代碼如下(如果你熟悉了就可以自己手寫,但是 IDEA 能生成,我就不手寫了),被標記 @Test 的方法可以單獨測試執(zhí)行,如果你在 IDEA 上可以看到側(cè)邊欄有綠色的帶箭頭的小圓圈,你可以點擊對應(yīng)的執(zhí)行 run 或者 debug
import org.junit.jupiter.api.Test;
class StringUtilTest {
@Test
void firstNonBlank() {}
@Test
void hasText() {}
@Test
void commonPrefix() {}
}
第三步:使用 JUnit 5 寫一些代碼
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class StringUtilTest {
@Test
void firstNonBlank() {
// 調(diào)用方法得到第一個非空的字符串,這里應(yīng)該 a
String shouldIsA = StringUtil.firstNonBlank("", null, "a", "c");
// 通過斷言類來判定結(jié)果
Assertions.assertEquals("a", shouldIsA);
String shouldIsC = StringUtil.firstNonBlank("c", null, "a", "c");
Assertions.assertEquals("c", shouldIsC);
}
// 可以使用 DisplayName 來修改原型單元測試時的項目名稱
@DisplayName("測試字符串是不是有文本,空白字符串不認為有文本")
@Test
void hasText() {
// 這里應(yīng)該是 false, 因為 null 沒有內(nèi)容
Assertions.assertFalse(StringUtil.hasText(null));
// 這里應(yīng)該是 false, 因為 空字符串 沒有內(nèi)容
Assertions.assertFalse(StringUtil.hasText(""));
// 這里應(yīng)該是 false, 因為 空白字符串 沒有內(nèi)容
Assertions.assertFalse(StringUtil.hasText(" "));
// 這里應(yīng)該是 true, 因為 a 沒有內(nèi)容
Assertions.assertTrue(StringUtil.hasText(" a "));
}
@DisplayName("測試公共前綴")
@Test
void commonPrefix() {
// 無公共前綴
Assertions.assertEquals("", StringUtil.commonPrefix(" a ", "b"));
Assertions.assertEquals(" ", StringUtil.commonPrefix(" a ", " b"));
Assertions.assertEquals("abab", StringUtil.commonPrefix("ababa", "ababc"));
Assertions.assertNotEquals("aba", StringUtil.commonPrefix("ababa", "ababc"));
}
}

在這里可以點 class 上的綠色按鈕來運行下面的全部測試,也可以選擇指定的進行測試。
這樣一個最簡單的單元測試就完成了,里面用到了: @Test (必需) 標記這是一個需要測試的方法;@DispalyName (可選)為測試方法或者類起一個好看的名字或者描述;Assertions 通過一系列的斷言來判定結(jié)果是否正確,這步寫不寫代碼都能通過,但是應(yīng)該必須寫,否則和 sout 有什么區(qū)別呢?
通過這三個的組合使用就能完成一系列的簡單的單元測試,下面來看下 Assertions 具體支持什么判定操作。其提供了 282 個方法,其中大部分有重載,這里不再展示所有的重載方法,重載的方法只取最大的那個展示一下
一下內(nèi)容來自于 org.junit.jupiter.api.Assertions 類中方法
參數(shù)說明:message 失敗后提示的信息;expected 預(yù)期的結(jié)果;actual 實際的結(jié)果;
代碼實現(xiàn)其實是只要 expected 和 actual 不相等就拋異常
| 方法簽名 | 描述 | 用途 |
|---|---|---|
| fail(String message, Object expected, Object actual) | 直接調(diào)用,標識一個測試用例失敗 | |
| assertTrue(boolean condition, String message) | 判定一個結(jié)果必須是 true | |
| assertFalse(boolean condition, String message) | 判定一個結(jié)果必須是 false | |
| assertNull(Object actual, String message) | 結(jié)果不能為 null | |
| assertEquals(Object expected, Object actual, String message) | 實際結(jié)果必須和預(yù)期結(jié)果相等 | |
| assertNotEquals(Object expected, Object actual, String message) | 實際結(jié)果必須和預(yù)期結(jié)果不相等 | |
| assertArrayEquals(Object[] expected, Object[] actual, Supplier<String> messageSupplier) | 兩個數(shù)組必須相等 | |
| assertIterableEquals(Iterable<?> expected, Iterable<?> actual, String message) | 兩個迭代器必須相等 | |
| assertSame(Object expected, Object actual, String message) | 實際結(jié)果必須和預(yù)期結(jié)果是同一個對象 | 比如單例的測試 |
| assertNotSame(Object expected, Object actual, String message) | 實際結(jié)果必須和預(yù)期結(jié)果不是同一個對象 | 比如多例的測試 |
| assertAll(Executable... executables) | 所有的 Executable 都執(zhí)行且不拋出異常 | |
| assertThrows(Class<T> expectedType, Executable executable, String message) | 必須拋出異常 | |
| assertDoesNotThrow(Executable executable, String message) | 不能拋出異常 | |
| assertTimeout(Duration timeout, Executable executable, String message) | 指定執(zhí)行時間內(nèi)執(zhí)行完,Executable 和調(diào)用者在同一個線程執(zhí)行 | 方法時長的判斷 |
| assertTimeoutPreemptively(Duration timeout, Executable executable, String message) | 指定執(zhí)行時間內(nèi)執(zhí)行完,Executable 在新的線程執(zhí)行 | 方法時長的判斷 |
| assertLinesMatch(List<String> expectedLines, List<String> actualLines, String message) | 對應(yīng)行正則匹配相等,講解麻煩,建議看代碼,或者單獨拿出一部分來講 | |
在上面的例子中,使用了 assertEquals、assertFalse、assertTrue、assertNotEquals 的使用,其他的也可以各自嘗試一下,使用方法相同。
常見工具
- JUnit
- Mockito
- Assertj
- Hamrest
- 結(jié)合 Spring 的 ut
- Mock 對象
- DB