1. 程式人生 > 程式設計 >Java SE基礎鞏固(十二):單元測試

Java SE基礎鞏固(十二):單元測試

1 概述

總所周知,測試是軟體開發中一個非常重要的環節,用來驗證程式執行是否符合預期(這個預期包括了程式的正確性、效能質量等),如果不符合預期,就根據測試的結果報告定位問題,修復問題,然後再次測試,這個過程往往需要重複多次,直到程式的執行狀況符合預期才可以嘗試釋出、上線,否則就是對產品,軟體不負責。

根據分類方式不同,測試可以分成不同的型別,一般最常見也是最重要的是根據開發階段劃分,可以劃分出4個主要的測試型別:

  • 單元測試
  • 整合測試
  • 系統測試
  • 驗收測試

本文主要介紹的就是第一個:單元測試。作為開發人員,其他三個可以不那麼熟悉,但單元測試必須要非常熟悉。

下面是從維基百科上摘取的單元測試的定義:

計算機程式設計中,單元測試(英語:Unit Testing)又稱為模組測試,是針對程式模組軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在過程化程式設計中,一個單元就是單個程式、函式、過程等;對於面向物件程式設計,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。

可以說單元測試的目的就是檢驗程式的正確性,對於效能、可用性等沒有要求,所以也常常說單元測試是最基本的測試,如果單元測試都無法通過,後面的測試完全沒必要進行。

Java社群中有很多第三方優秀的開源測試框架,例如JUnit,Mockito,TestNG等,下面我將介紹Junit和Mockito的使用。

本文不涉及軟體測試的理論知識,僅會談到測試工具的使用。

2 JUnit

JUnit是一款非常出名的開源測試框架,甚至很多非Java開發者都或多或少聽說過。Junit現在(2018-10-15)已經發布了Junit5,多了一些特性,而且最低支援的Java版本的是Java8,但本文不打算使用Junit5,而是採用JUnit4。關於JUnit5的變化,建議到官網檢視。

2.1 下載安裝

官網中提供了JUnit的jar包的下載地址,匯入jar包即可使用。如果專案是Maven專案的話,也可以往pom.xml檔案里加入junit依賴,如下所示:

<dependency>
  <groupId
>
junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> 複製程式碼

2.2 初嘗JUnit

從JUnit4開始,我們可以在需要測試的方法上加上@Test註解來表示該方法是一個待測試的方法。在JUnit3的時候,要想測試一個方法,只能使用“命名模式”將待測試方法的方法名設定成testXXX的形式,命名模式有很多缺點和不足,所以推薦大家儘量使用JUnit4之後的版本。下面是一個JUnit4的簡單使用案例:

public class ApplicationTest {

    private int calculateSum(int a,int b) {
        return a + b;
    }

    @Test
    //這裡的方法名只是一種習慣用法,JUnit4並不強制要求必須是testXXX
    public void testCalculate() {
        Assert.assertEquals(10,calculateSum(5,5));       //通過
        Assert.assertEquals(10,calculateSum(20,-10));    //通過
        Assert.assertEquals(10,calculateSum(0,0));        //不通過,一般不會這樣寫,這裡只是為了演示
        Assert.assertNotEquals(10,calculateSum(10,10));  //通過
    }
}
複製程式碼

有@Test註解方法是待測試方法,當程式啟動的時候,會依次呼叫所有的待測試方法,如果在方法裡丟擲異常,那麼該方法就算是測試失敗了。Assert是org.junit包下的一個類,提供了豐富的斷言API供我們使用,例如assertEquals用來斷言期待值和實際值相等,assertNull用來斷言引數是一個null值。在案例程式碼中,只有一個待測試方法,該方法的測試目標是calculateSum方法,其中的4個斷言都是為了驗證calculateSum方法的返回值是否符合預期,啟動程式,控制檯輸出內容大致如下所示:


java.lang.AssertionError: 
Expected :10
Actual   :0
 <Click to see difference>


	at org.junit.Assert.fail(Assert.java:88)
	at org.junit.Assert.failNotEquals(Assert.java:834)
	at org.junit.Assert.assertEquals(Assert.java:645)
	at org.junit.Assert.assertEquals(Assert.java:631)
	at top.yeonon.ApplicationTest.testCalculate(ApplicationTest.java:21)
	.......
複製程式碼

可以看到方法丟擲了一個AssertionError異常,並列印了異常堆疊,用於定位問題所在,除此之外,JUnit還給出了一個簡單的測試報告,即:

java.lang.AssertionError: 
Expected :10
Actual   :0
複製程式碼

Expected即期待值,使我們在程式中自定義的,Actual是calculateSum的返回值,JUnit想要告訴我們的是:你期待的值是10,但實際值卻是0,即不符合預期,應該嘗試修復問題。

下面是一個相對比較複雜的例子(只是和上面的例子比較,實際開發中不會那麼簡單):

public class AppTest {

    @Test
    public void testAssertEqualAndNotEqual() {
        String name = "yeonon";
        Assert.assertEquals("yeonon",name);
        Assert.assertNotEquals("weiyanyu",name);
    }

    @Test
    public void testArrayEqual() {
        byte[] expected = "trial".getBytes();
        byte[] actual = "trial".getBytes();
        Assert.assertArrayEquals("failure - byte arrays not same",expected,actual);
    }

    @Test
    public void testBoolean() {
        Assert.assertTrue(true);
        Assert.assertFalse(false);
    }

    @Test
    public void testNull() {
        Assert.assertNull(null);
        Assert.assertNotNull(new Object());

    }

    @Test
    public void testThatHashItems() {
        Assert.assertThat(Arrays.asList("one","two","three"),CoreMatchers.hasItems("one","two"));
    }

    @Test
    public void testThatBoth() {
        Assert.assertThat("yeonon",CoreMatchers.both(
                        CoreMatchers.containsString("e"))
                        .and(CoreMatchers.containsString("o")));

    }
}

複製程式碼

其實就是試試Assert的各種API,不多說了,看看方法名字大概就知道功能了。

順便說一下,如果覺得太多的Assert和CoreMatchers看著煩,可以使用靜態匯入包的方式匯入包,例如:

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
複製程式碼

JUnit的使用就是那麼簡單粗暴直接,這也是為什麼JUnit如此火爆的原因之一。當然,JUnit不僅僅只有那麼點功能,關於JUnit更高階的功能,建議到JUnit官網檢視官方檔案,它的檔案寫的還是不錯的。

3 Mockito

Mockito是一款非常強大的測試框架,其最大的特點就是“Mock”,即模擬。單元測試的一個很重要的關鍵點就是儘量在不涉及依賴關係的情況下測試程式碼,儘量的模擬真實的環境去做測試。Mockito可以做到這一點,他會將用到的類包裝成一個Mock物件,該Mock物件是可配置的,即可以將其行為配置成我們想要的樣子。

例如在通常的Web開發中,後端會分為3層,即MVC,負責控制層的同學可能已經把控制層寫好了,但負責模型層的同學還沒寫好,這時候控制層的同學想要對控制層的功能做測試,就可以使用Mock模擬出一個模型層(假設介面以及定義好了,只是功能還沒實現),然後進行測試,這樣就不需要等待負責模型層的同學寫完了。

3.1 下載和安裝

和JUnit一樣,可以下載jar包並匯入專案,如果專案是Maven專案的話,可以在pom.xml檔案里加入如下依賴:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>2.21.0</version>
    <scope>test</scope>
</dependency>
複製程式碼

在實際用的時候,還需要加入JUnit的依賴。(但不是說mockito依賴JUnit,僅僅是專案依賴了JUnit)

3.2 簡單使用

下面僅介紹一個簡單例子,如下所示:

public class ApplicationTest {

    //有返回值方法
    public int calcSum(int a,int b) {
        return 1;
    }

    //無返回值方法
    public void noReturn() {

    }

    @Test
    //設定單個返回值
    public void testOneReturn() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(10,10)).thenReturn(10);
        assertEquals(10,test.calcSum(10,10));
    }

    @Test
    //設定多個返回值,按順序校驗
    public void testMultiReturn() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(10,10)).thenReturn(10).thenReturn(20);
        assertEquals(10,10));
        assertEquals(20,10));
    }

    @Test
    //根據輸入引數不同來定義不同的返回值
    public void testMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(0,0)).thenReturn(1);
        when(test.calcSum(1,1)).thenReturn(0);
        assertEquals(1,test.calcSum(0,0));
        assertEquals(0,test.calcSum(1,1));
    }

    @Test
    //返回值不依賴輸入
    public void testNotMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(anyInt(),anyInt())).thenReturn(-1);
        assertEquals(-1,10));
        assertEquals(-1,test.calcSum(100,-100));
    }

    @Test
    //根據返回值的型別來決定輸出
    public void testReturnTypeOfMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(isA(Integer.class),isA(Integer.class))).thenReturn(-100);
        assertEquals(-100,100));
        assertEquals(-100,test.calcSum(111,111));
    }

    @Test
    //行為驗證,主要用於驗證方法是否被呼叫
    public void testBehavior() {
        ApplicationTest test = mock(ApplicationTest.class);
        test.calcSum(10,10);
        test.calcSum(10,10);
        //times(2)表示被呼叫兩次
        verify(test,times(2)).calcSum(10,10);
    }
}

複製程式碼

首先,我們在每個方法裡都構造了一個Mock物件,即

ApplicationTest test = mock(ApplicationTest.class);
複製程式碼

構造完畢之後,就可以做一些配置了,拿testOneReturn方法來說,使用了when(...).thenReturn(...)的方式來對mock物件進行配置,when的引數是一個方法呼叫,例如test.calcSum(10,10),threnReturn的引數就是設定該方法呼叫的返回值。所以when(test.calcSum(10,10)).thenReturn(10);這行程式碼的意思就是“當呼叫test.calcSum(10,10)的時候,應該返回10”,然後呼叫assertEquals(10,test.calcSum(10,10));來驗證是否正確。

這裡你可能會有點奇怪,程式碼中的calcSum無論如何都應該返回-1才對啊,那這行程式碼是否能通過測試呢?答案是能!因為我們使用when(...).thenReturn(...)就是在對這個方法呼叫做設定,即這裡定義的返回值是我們自定義的,無論calcSum是如何實現的,只要我們按照when裡規定的呼叫形式(例子中是test.calcSum(10,10)),那麼就一定會返回配對的thenReturn()裡設定的值。

其他方法就不多說了,和testOneReturn()差不多,而且也做了註釋,應該不難理解。

4 SpringBoot Test

4.1 簡單演示

SpringBoot Test模組包含了JUnit、Mockito等依賴,在對Spring Boot專案進行測試的時候,只需要新增一個Spring Boot Test的依賴即可,如下所示:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>根據官網釋出的版本進行選擇,記得避免版本衝突</version>
    <scope>test</scope>
</dependency>
複製程式碼

標準的Spring Boot的MVC三層程式碼,我就省略了,非常簡單,直接來看測試類。

package top.yeonon.springtest;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import top.yeonon.springtest.controller.UserController;
import top.yeonon.springtest.repository.UserRepository;
import top.yeonon.springtest.service.UserService;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
 * @Author yeonon
 * @date 2018/10/15 0015 18:21
 **/
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {

    private MockMvc mockMvc;

    @Autowired
    private UserController userController;

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Before
    public void setUp() {
        //構造mockMvc
        mockMvc = MockMvcBuilders.standaloneSetup(userController,userService,userRepository).build();
    }

    @Test
    public void testUserService() throws Exception {
        RequestBuilder request = null;

        //1. 註冊使用者

        request = post("/users")
                .param("username","yeonon")
                .param("password","admin")
                .contentType(MediaType.APPLICATION_JSON_UTF8);

        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("註冊成功"));  //在業務程式碼中,如果成功就會返回“註冊成功”;

        //2. 根據id獲取使用者
        request = get("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("{\"id\":1,\"username\":\"yeonon\",\"password\":\"admin\"}"));

        //3. 修改使用者資訊
        request = put("/users")
                .param("username","weiyanyu")
                .param("password","aaa")
                .param("id","1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("更新成功"));

        //4. 再次獲取資訊
        request = get("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("{\"id\":1,\"username\":\"weiyanyu\",\"password\":\"aaa\"}"));

        //5. 刪除使用者
        request = delete("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("刪除成功"));

    }
}

複製程式碼

mockMvc是Spring封裝的一個類,從名字可以看出來是針對MVC的一個模擬,實際上也確實如此。整個測試過程可以分為以下幾個步驟:

  1. 構造mockMvc物件,可以通過MockMvcBuilders並傳入相應的Bean(如果傳入不完整,可能會導致Bean注入失敗並報出空指標異常)。
  2. 獲取一個RequestBuilder物件,可以通過MockMvcRequestBuilders.get(),MockMvcRequestBuilders.post()等方法獲取。
  3. 將RequestBuilder物件傳入mockMvc.perform()方法中,該方法會返回一個ResultActions物件,表示某種行為。
  4. 通過返回的ResultActions物件提供的API來對結果做驗證,例如andExpect,andDo,andReturn等。其中andExpect接受的引數是一個ResultMatcher型別的物件,在MockMvcResultMatchers中有很多使用的方法可以供我們使用,例如status,content等。

這就完成了一次web測試。這裡順便說一下編碼問題,在這個測試環境下,預設的編碼方式不是UTF-8(好像是ISO-xxx,具體忘了),所以如果controller返回的有中文且不做特殊處理的話,可能會出錯。一個解決方案是,修改controller中的@RequestMapping上的produces屬性,如下所示:

@DeleteMapping(value = "{id}",produces = "application/json;charset=UTF-8")
public String deleteUser(@PathVariable("id") Long id) {
    return userService.deleteUser(id);
}
複製程式碼

4.2 h2記憶體資料庫

該小測試專案中,其實用到了h2資料庫。h2是一款用Java語言開發的資料庫,可直接嵌入到應用程式中,與應用程式打包釋出,不受平臺限制,它還支援記憶體模式,所以非常適合用於測試環境。一般為了方便,在測試環境使用的時候,會將專案的.sql檔案載入到h2中,然後使用記憶體模式進行測試,在記憶體模式下,所有的操作都在記憶體中進行,不會進行持久化,所以無需擔心會弄髒生產環境的資料庫。

spring boot對h2也有支援,我們只需要在專案中加入h2的相關依賴並做少量配置即可使用,如下所示:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
</dependency>
複製程式碼

配置如下所示:

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:h2test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.username=admin
spring.datasource.password=admin

spring.jpa.database=h2
spring.jpa.hibernate.ddl-auto=update

spring.h2.console.enabled=true
spring.h2.console.path=/console

複製程式碼
  1. datasource的常規四個配置就不多說了,不難理解。
  2. spring.jpa.database。因為專案中使用了jpa,所以這裡配置一下jpa的目標資料庫型別是h2。
  3. spring.h2.console.enabled。是否啟用h2控制檯,h2提供了一個web控制檯,方便使用者增刪改查資料。
  4. spring.h2.console.path。控制檯的路徑,例如上面配置的是/console,在使用的時候就可以在瀏覽器位址列輸入http://localhost:port/console進入控制檯。

啟動專案之後,在瀏覽器裡輸入url進入h2控制檯,如下所示:

iaBLm4.png

做好配置之後,輸入使用者名稱密碼,點選Connect即可進入控制檯介面,如下所示:

iaBO0J.png

在空白處可以輸入符合SQL規範的語句對資料庫進行操作,左側邊欄可以看到有一個T_USER資料庫表,這是JPA幫我們建立的,在h2中,表的名字預設都是大寫的,但是在寫SQL語句的時候可以使用小寫,h2會幫我們轉換成大寫形式。如下所示:

iaDC6O.png

關於h2資料庫的介紹就先這樣,因為h2的介面也符合JDBC規範,所以如果熟悉JDBC的話,不需要太關注h2的操作細節。

5 TDD

TDD即Test-Driven Development (測試驅動開發)。名字可能不那麼好理解其意義,什麼是測測試驅動開發?為什麼要用測試來啟動開發?測試如何驅動開發的?下面將圍繞這三個問題簡單介紹一下TDD。

5.1 什麼是測試驅動開發

如果之前沒有接觸過類似的概念,大多數人對測試的認識應該是:先編寫程式碼,完成之後再進行測試,測試的目的是檢驗程式的正確性、效能質量、可用性、可伸縮性等。而測試驅動開發則恰恰相反,TDD提倡的是先編寫測試程式,然後編寫程式碼滿足測試成功,使得測試程式能通過,只要測試用例寫的好,重構程式碼的時候需要考慮的事情就可以少很多,只需要讓程式碼能通過測試即可。

5.2 為什麼需要測試驅動開發

TDD和傳統的先開發後測試的方式相比,至少有如下幾個好處:

  • 降低開發者的負擔,開發者只需要編寫程式碼通過測試用例即可,不需要在各種亂七八糟的需求中糾結。
  • 對需求變化有很強的適應性,但需求發生變化的時候,只需要根據需求修改測試,然後再次編寫或者修改程式碼來適應測試用例,避免“撿了芝麻,丟了西瓜”的情況發生。
  • 需求明確,提前編寫測試可以督促我們理清需求,而不是寫程式碼寫到一半才發現需求不明確,導致“返工”。
  • 效率高,所謂磨刀不誤砍柴工,雖然提前編寫測試需要花費很長的時間和很多的精力,但這些消耗都是值得的。如果不提前編寫測試,最終也需要自己進行手動測試,而手動測試又需要花時間去啟動應用,在各個介面之間來回跳轉,其實花費的時間比提前編寫自動化測試多得多。

5.3 測試如何驅動開發

其實上面隱隱有提到過這點,但沒有明確給出一個思路或者步驟,下面是TDD的基本流程:

  1. 編寫一個測試用例
  2. 執行測試程式,此時應該會測試不通過
  3. 編寫程式碼,目標是使程式碼能通過測試用例。
  4. 再次執行測試程式,此時如果還是測試不通過,回到步驟3,如此往復,直到測試通過。

這裡有一個問題,步驟2顯然是肯定會失敗的(因為還沒有編寫具體的程式碼),為什麼還要執行一次測試程式呢?因為失敗的原因有很多,不一定就是因為還沒有編寫具體程式碼導致,也有可能是測試環境有問題導致的,所以先執行一次,檢視錯誤報告,如果是測試環境有問題,那麼就先嚐試修復測試環境,否則如果在有問題的測試環境下進行開發,可能會導致無論怎麼編寫程式都不可能通過測試的情況(因為每次測試都會因為測試環境的問題導致測試失敗)。

實際上,TDD遠不止如此,還有很多很多好處,也有一些弊端,因為我本人對TDD的瞭解也不算多,平時因為比較懶,也沒有養成先寫測試的習慣,所以就不多說了,建議自行搜尋相關資料進行學習,這裡就當是“拋磚引玉”吧。

6 小結

本文介紹了單元測試的概念,順帶介紹了兩個測試框架JUnit,Mockito的簡單使用,隨後還結合Spring Boot專案做了一次小實踐,希望對讀者有幫助。最後還簡單介紹了TDD(測試驅動開發),TDD是敏捷開發中的一項核心技術,可以有效的提高開發效率和產品質量,TDD其實也算是一門學問,如果想要深入學習,推薦到這裡看看。