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

1 概述

總所周知,測試是軟件開發中一個很是重要的環節,用來驗證程序運行是否符合預期(這個預期包括了程序的正確性、性能質量等),若是不符合預期,就根據測試的結果報告定位問題,修復問題,而後再次測試,這個過程每每須要重複屢次,直到程序的運行情況符合預期才能夠嘗試發佈、上線,不然就是對產品,軟件不負責。java

根據分類方式不一樣,測試能夠分紅不一樣的類型,通常最多見也是最重要的是根據開發階段劃分,能夠劃分出4個主要的測試類型:web

  • 單元測試
  • 集成測試
  • 系統測試
  • 驗收測試

本文主要介紹的就是第一個:單元測試。做爲開發人員,其餘三個能夠不那麼熟悉,但單元測試必需要很是熟悉。spring

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

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

能夠說單元測試的目的就是檢驗程序的正確性,對於性能、可用性等沒有要求,因此也經常說單元測試是最基本的測試,若是單元測試都沒法經過,後面的測試徹底不必進行。數據庫

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

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

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, test.calcSum(10, 10));
        assertEquals(20, test.calcSum(10, 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, test.calcSum(10, 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, test.calcSum(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其實也算是一門學問,若是想要深刻學習,推薦到這裏看看。

相關文章
相關標籤/搜索