聊聊 Java 的單元測試

語言: CN / TW / HK

單元測試框架

Java​ 中,​JUnit ​和 ​TestNG ​是最受歡迎的單元測試框架。

  • JUnit
  • TestNG

JUnit

首先是大名鼎鼎的 JUnit ,JUnit 已經成為 Java 應用程式單元測試的事實標準

JUnit 是一個開源的 Java 語言的單元測試框架,專門針對 Java 設計,使用最廣泛。JUnit 目前最新版本是 5

JUnit5 的組成:JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

JUnit5 建議使用 Java8 及以上版本

  • JUnit Platform 是在 JVM 上啟動測試框架的基礎,它定義了TestEngine在平臺執行的新測試框架的 API
  • JUnit Jupiter 它用於編寫測試程式碼的新的程式設計和擴充套件模型。它具有所有新的 Junit 註釋和TestEngine實現來執行這些註釋編寫的測試。
  • JUnit Vintage JUnit4 已經存在了很長時間,並且有許多以 JUnit4 編寫的測試。JUnit Jupiter 還需要支援這些測試。為此,開發了 JUnit Vintage 子專案。提供了一個測試引擎,用於在平臺上執行基於 JUnit 3 和 JUnit 4 的測試。它要求 JUnit 4.12 或更高版本出現在類路徑或模組路徑中。從它的名字 Vintage(古老的;古色古香的)中也能有所體會。

簡單例子

我們先來個最簡單的例子,別看簡單,很多人會犯錯

Java @SpringBootTest @RunWith(SpringRunner.class) public class JunitTest { @Test public void testJunit(){ System.out.println("junit test"); } }

很簡單對吧,如果你用了 SpringBoot 簡單到好像沒啥說的,其實不然,我們來聊聊:

首先,這段程式碼使用的是 JUnit 4 還是 JUnit5 ? 你可能會覺得,4 和 5 沒啥區別吧,用哪個不一樣嗎? 程式碼能跑不就行了?

不是的,4 和 5 肯定有區別這個不用我說了。能跑沒問題,但如果你不管是 4 還是 5 都認為一樣,API 混用,甚至亂用,那這時候測試出現的各種報錯,導致你很懵逼,而且不知道為什麼,一通亂查也不知所然。

上面這段程式碼其實是 JUnit 4 版本,我們看一下 import 就一目瞭然了,然而可能你在開發的時候沒太注意這裡是 4 還是 5

Java import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner;

這裡確定了,使用的是 4 的版本,這裡有幾個要注意的點:

  • @Test的包是org.junit.Test ,不要搞錯了,因為有好幾個同名包
  • 需要@RunWith(SpringRunner.class)
  • 測試類和測試方法需要public修飾

我們看下完整的例子:

```Java import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner;

@SpringBootTest @RunWith(SpringRunner.class) public class JunitTest { @Test public void testJunit(){ System.out.println("junit test "); } }

```

這裡強調下環境 ,springboot2.2.x 之前支援 JUnit 4

上面有一點提到了 需要 public 修飾的問題,這不很正常嗎,為什麼要強調?

那是因為 JUnit 5 不需要了,我們看一下用 JUnit 5 來實現的同樣的例子 (SpringBoot 2.2.x 之後支援 JUnit 5):

```Java import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest class JunitTest { @Test void testJunit5(){ System.out.println("junit5"); } } ```

這麼簡單嗎? 對,就是這麼簡單,所以我說 4 和 5 不一樣。我們來看區別的地方:

  • @Test的包是org.junit.jupiter.api.Test
  • 不需要@RunWith(SpringRunner.class)
  • 測試類和測試方法不需要public修飾

我見過很多同學在寫測試用例時出現的所謂詭異問題,都是因為他自己都沒搞清楚用的是 4 還是 5 的情況下將 4 和 5 混用導致的。

如果你的測試用例是 4 ,可以遷移到 5 了,有關 JUnit 4 遷移到 JUnit5 的話題可以參考這篇文章 ,通過工具可能節省很多時間:https://blog.jetbrains.com/idea/2020/08/migrating-from-junit-4-to-junit-5/

我們再來看一下 pom 依賴這裡,你是不是經常看到有關 test 的依賴是這樣寫的:

XML <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency>

為啥? 為什麼要排除 junit-vintage-engine ?如果你認真閱讀了前文,你應該能猜到為什麼了。

JUnit Vintage 是為了相容 3 和 4 的一個 engine,如果我們的測試程式碼都用 5 實現,不需要相容 3 和 4 ,那要它幹嘛? 當然是幹掉呀,哈哈。

但如果你需要相容,那請不要那麼魯莽。上面的這段 dependency 主要用於 spring-boot-starter-test2.2.x2.3.x 版本中。spring-boot-starter-test 2.4.x 版本中,已經不再包含 junit-vintage-engine 這個依賴項了

常規套路

|Annotations|描述| |-|-| |@BeforeEach|在方法上註解,在每個測試方法執行之前執行。| |@AfterEach|在方法上註解,在每個測試方法執行之後執行| |@BeforeAll|該註解方法會在所有測試方法之前執行,該方法必須是靜態的。| |@AfterAll|該註解方法會在所有測試方法之後執行,該方法必須是靜態的。| |@Test|用於將方法標記為測試方法| |@DisplayName|用於為測試類或測試方法提供任何自定義顯示名稱| |@Disable|用於禁用或忽略測試類或方法| |@Nested|用於建立巢狀測試類| |@Tag|用於測試發現或過濾的標籤來標記測試方法或類| |@TestFactory|標記一種方法是動態測試的測試工場|

常規套路不說了,比較簡單,一看就明白,說幾個有意思的。

重複性測試

```Java @RepeatedTest(5) void repeatTest(TestInfo testInfo,RepetitionInfo repetitionInfo){

  System.out.println("repeat:" + testInfo.getDisplayName());
  System.out.println("這是第 "+ repetitionInfo.getCurrentRepetition()+ "次重複");

} ```

不用自己寫 for 迴圈了,人家自己帶重複的註解,上面兩個變數也是自己帶的,方便拿到重複資訊。

基於引數測試

Java @ParameterizedTest @ValueSource(strings = {"java", "python", "go"}) void containsChar(String candidate) { assertTrue(candidate.contains("o")); }

如果你的引數少,也不用寫迴圈了,直接寫註解裡,還挺方便的。

超時測試

Java @Test @Timeout(value = 500, unit = TimeUnit.MILLISECONDS) void failsIfExecutionTimeExceeds500Milliseconds() { // fails if execution time exceeds 500 milliseconds }

可以設定 超時的單位和時長

在 assert 中也可以測超時,可以這樣寫:

```Java // timed out after 5 seconds @Test void test_timeout_fail() { // assertTimeout(Duration.ofSeconds(5), () -> delaySecond(10)); // this will fail

    assertTimeout(Duration.ofSeconds(5), () -> delaySecond(1)); // pass
}

void delaySecond(int second) {
    try {
        TimeUnit.SECONDS.sleep(second);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

```

並行測試

以上測試用例都是用主執行緒或者單執行緒跑的,下面我們玩兒個多執行緒並行 test

首先你要在你的 classpath 下面建一個檔案 junit-platform.properties

接著加兩行配置

```.properties junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent

```

行了,再跑你的用例就是多執行緒並行執行的了,當然如果用例本來就設計成單執行緒的看不出來,那可以使用 Repeat 試一下,比如上面講過的這個:

```Java @RepeatedTest(5) void repeatTest(TestInfo testInfo,RepetitionInfo repetitionInfo){

  System.out.println("repeat:" + testInfo.getDisplayName());
  System.out.println("這是第 "+ repetitionInfo.getCurrentRepetition()+ "次重複");

} ```

上面這個是對一個方法的重複執行並行,有時候我們是想讓一個類中的多個方法並行,能不能做到? 可以,改下配置就好了

.properties junit.jupiter.execution.parallel.enabled = true junit.jupiter.execution.parallel.mode.default = concurrent junit.jupiter.execution.parallel.mode.classes.default = same_thread

如果反過來呢? 多個類並行,類中的方法序列 也可以,還是改配置:

.properties junit.jupiter.execution.parallel.enabled = true junit.jupiter.execution.parallel.mode.default = same_thread junit.jupiter.execution.parallel.mode.classes.default = concurrent

MockMVC

你測個 service 測個 dao 很簡單,把 Bean 注入就可以了,Controller 怎麼測? 我們要利用下 MockMVC 了

MockMvc 實現了對 Http 請求的模擬,能夠直接使用網路的形式,轉換到 Controller 的呼叫,這樣可以使得測試速度快、不依賴網路環境,而且提供了一套驗證的工具,這樣可以使得請求的驗證統一而且很方便。

我們先看一個簡單的例子:

```Java import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest @AutoConfigureMockMvc class HelloControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private HelloController helloController;

@Test
public void shouldReturnDefaultMessage() throws Exception {

    this.mockMvc.perform(get("/hello"))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("Hello World")));
}

} ```

解釋下沒見過的註解:

  • @AutoConfigureMockMvc:用於自動配置 MockMvc, 配置後 MockMvc 類可以直接注入

此外我們利用 @Autowired 注入了一個 MockMvc 的 Bean 例項。我們通過這個例子來模擬請求 /hello 這個 Controller 資源,並且通過判斷返回的 content 內容是否包含 Hello World 字串來決定這個用例的執行是否成功。

注意 imports 部分,我們匯入了 MockMvcRequestBuilders 的一些靜態方法。整個方法就一行程式碼,解釋一下:

  • perform : 執行一個請求
  • andDo : 新增一個結果處理器,表示要對結果做點什麼事情,比如此處使用 print():輸出整個響應結果資訊
  • andExpect : 新增執行完成後的斷言

我們看下執行結果:

```text MockHttpServletRequest: HTTP Method = GET Request URI = /hello Parameters = {} Headers = [] Body = null Session Attrs = {}

Handler: Type = com.xiaobox.springbootdemo.controller.HelloController Method = com.xiaobox.springbootdemo.controller.HelloController#hello(String)

Async: Async started = false Async result = null

Resolved Exception: Type = null

ModelAndView: View name = null View = null Model = null

FlashMap: Attributes = null

MockHttpServletResponse: Status = 200 Error message = null Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"12"] Content type = text/plain;charset=UTF-8 Body = Hello World! Forwarded URL = null Redirected URL = null Cookies = [] ```

我們來看下一個例子

```Java

@WebMvcTest class HelloControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private HelloController helloController;

@Test
public void shouldReturnDefaultMessage() throws Exception {

    this.mockMvc.perform(get("/hello"))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("Hello World")));
}

} ```

你發現我們只是把 class 頭上的註解換成了 @WebMvcTest,其實的沒變,是的。但卻比上一段程式碼快 3 倍。為什麼?

因為之前的寫法會把 Spring 完整的應用上下文全啟動了,而 @WebMvcTest 是將測試範圍縮小到僅啟動 web 層,所以會快。當你只想測試 http 到 controller 這層的時候,可以用 @WebMvcTest 註解。

你甚至還可以告訴框架只啟動某一個 controller 這樣更快,比如:@WebMvcTest(HomeController.class)

上面是 WebMvcTest 的第一個場景, 我們來看第二個場景:也是測 controller ,但 controller 呼叫的 service 我們也 mock,不走真正 service 程式碼邏輯。這在有時你的 service 沒準備好,或者不方便直接呼叫時會很有用

```Java @WebMvcTest class HelloControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private HelloController helloController;

@Test
public void greetingShouldReturnMessageFromService() throws Exception {

    Mockito.when(service.greet()).thenReturn("Hello, Mock");

    this.mockMvc.perform(get("/greeting")).andDo(print()).andExpect(status().isOk())
            .andExpect(content().string(containsString("Hello, Mock")));
}

} ```

上面的程式碼我們用到了 Mockito, 可能你聽過周杰倫一首新歌叫 Mojito ,對,Mockito 的命名就是對 Mojito(一種傳統的古巴高球雞尾酒)的戲稱

簡單來說 Mockito 是一個 java 做單元測試的 Mock 框架:https://site.mockito.org/

解釋下我們上面這行程式碼 Mockito.when(service.greet()).thenReturn("Hello, Mock");

意為: 當呼叫 service 的 greet 方法的時候,返回值為 “Hello Mock”,其實沒真調那個方法,就是 Mock 了一下,直接給了個返回值。用英文說就是 :When the x method is called then return y

當然 Mockito 在假造上是很有實力的,它有豐富的 API 供你組合使用,有興趣可以看一看文件和原始碼註釋。

講到這兒,一定有同學會問,只測 Controller ,那我就用 Postman 就行 了,甚至 curl 都行,為啥要寫用例,我不寫用例。

哈哈,我相信很多後端同學都沒認認真真把用例寫完,尤其是 controller 這層的,不裝逼,我也是。那我們有必要討論一下 到底是用 Postman 還是用 MockMVC ?

首先說說 MockMVC 的好處:

  • 可程式設計,這就給了你無限的自由空間,想怎麼折騰隨便你,你是上帝
  • 除了寫的時候花點時間外,除錯的時候速度快,而且可配置,你要想只測 controller,就只啟動 controller 的上下文就行了
  • 順便把測試用例寫了,測試同學省心了,給自動化測試提供了基礎
  • 間接提高程式碼質量

其他的我不說,我就說最後一點。我注意到一個現象,很多開發同學拿測試同學當工具人,自己寫的程式碼自己不怎麼測試,直接交給測試讓他們提 BUG,然後改,BUG 多也不覺得害臊。開發是爽了,由於程式碼質量差,整個專案的進度都被拖慢了。你可能會說這是軟體質量管理的問題,是規則制定的有問題,如果出 BUG 扣錢就沒這事兒了。

我要說的是,在軟體開發這個領域,很多事情不是刻板的死規則,即便是制定了這樣的規則,也不一定有效。更多的時候是整個團隊的文化和風氣,領導者有責任將整個研發團隊的文化和風氣帶向正軌。什麼是正軌 ? 其實我們都知道! 我們都知道應該寫高質量的程式碼,bug 少的程式碼,設計合理的程式碼,不斷重構、不斷維護的程式碼,我們都知道要做好自己的事就會提高整個團隊的效率,我們都知道應該寫註釋、寫文件,我們都知道.....

我們都知道,但我們也知道專案時間緊,而且專門有人一遍遍強調 deadLine ,有人關心你的開發進度,關心功能實現了沒有,關心老闆有沒有意見,沒有人關心你累不累,關心你幾點下的班,關心規劃合不合理,關心程式碼質量高不高,關心與軟體真正有關係的一切。所以做一個真正的 軟體研發團隊的 Leader 不容易,遇到好 Leader 是你的福氣。

扯多了,我們回頭來看 Postman ,Postman 的好處好像也不用我多說了,確實,如果只是簡單的做 Controller 連通測試,用 Postman 一點兒問題沒有,也比寫程式快,但如果你的需求時有正好是 MockMVC 的優點可以覆蓋的地方,那麼就動動手,寫寫程式吧。

測試報告

想成一份漂亮的測試報告 ? 後端同學說了,整那花裡胡哨的有啥用呢,簡單一點兒不好嗎?

好,簡單點兒當然可以,但 UI 帶給我們的價值不就是一圖勝千言嘛,讓無論是前端、後端、測試同學都能一目瞭然,減輕大腦處理資訊的成本。

來,我們先上成果

怎麼樣,還挺好看的吧,我們用的是 Allure 來生成了一個 web 頁面,這個頁面還有一些簡單的互動,整體簡潔好看、易用。

下面我們說一下 Allure 怎麼和 JUnit 整合的

我們仍然使用 SpringBoot 以及 JUnit 5 ,先修改一下 pom.xml 檔案,新增依賴

```XML org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine

io.qameta.allure allure-junit5 2.18.1 test

```

然而我們新增在 build 中兩個 plugin

```XML maven-surefire-plugin 2.21.0 false -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" junit.jupiter.extensions.autodetection.enabled true org.junit.platform junit-platform-surefire-provider 1.2.0 org.aspectj aspectjweaver ${aspectj.version}

</plugin>
<plugin>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-maven</artifactId>
    <version>2.11.2</version>
    <configuration>
        <reportVersion>2.4.1</reportVersion>
    </configuration>
</plugin>

```

我們用 brew 在本地安裝一下 Allure (我是 mac 就用這個裝了,如果你是其他環境參考後面說的文件)

```Bash brew install allure

```

接著我們調整專案中的測試用例,然後執行:

Bash mvn clean test

接著找到你專案中 surefire-reports 的目錄位置

然後執行類似如下命令:

```Bash

注意路徑改成你自己專案的,這裡只是示例

allure serve /home/path/to/project/target/surefire-reports/

```

顯示如下資訊會自動跳轉到瀏覽器,開啟測試報告頁面。

是不是很簡單?

有關 Allure 安裝和使用說明請參考: https://docs.qameta.io/allure-report

有關 JUnit5 就聊到這兒,日常一般的開發是夠用了。更多的細節和功能,紹假設 、 斷言等,請看官方文件 ,當然備不注它也有錯的時候。

TestNG

TestNG is a testing framework inspired from JUnit and NUnit but introducing some new functionalities that make it more powerful and easier to use, such as:

  • Annotations.
  • Run your tests in arbitrarily big thread pools with various policies available (all methods in their own thread, one thread per test class, etc...).
  • Test that your code is multithread safe.
  • Flexible test configuration.
  • Support for data-driven testing (with @DataProvider).
  • Support for parameters.
  • Powerful execution model (no more TestSuite).
  • Supported by a variety of tools and plug-ins (Eclipse, IDEA, Maven, etc...).
  • Embeds BeanShell for further flexibility.
  • Default JDK functions for runtime and logging (no dependencies).
  • Dependent methods for application server testing.

上面是 TestNG 的官方介紹,看起來比 JUnit 功能還強大。有了前面 Junit 作為引子, 你再看 TestNG,就好理解的多,因為概念上都差不多,只是功能和細節的不同而已。在這裡我們不會展開講 TestNG 了,但是會討論一下選型的問題。

如果在 JUnit 5 沒出來之前,比如 JUnit4 和 3 的時代,我會毫不猶豫地選擇 TestNG,為什麼?功能強大,好用啊。但是現在 JUnit5 來了,而且推廣的勢頭也很猛,重要的是從功能上也不輸 TestNG,那麼怎麼選呢?

個人覺得:

  • 如果是後端開發,一般還是選 JUnit 5 寫單元測試方便簡單些,SpringBoot 也內建了 JUnit 開箱即用,從生態和社群上講即使有坑也好解決些
  • 如果是搞自動化測試的同學,更多的可能還是用 TestNG 方便些,之前很多遺留專案都是用的 TestNG,另外它和自動化測試工具 selenium 的搭配也早已深入人心。從設計理念到 API,都更符合測試同學的思維。

參考