Spring Boot乾貨系列:(十二)Spring Boot使用單元測試

嘟嘟MD發表於2019-03-03

原本地址:Spring Boot乾貨系列:(十二)Spring Boot使用單元測試
部落格地址:tengj.top/

前言

這次來介紹下Spring Boot中對單元測試的整合使用,本篇會通過以下4點來介紹,基本滿足日常需求

  • Service層單元測試
  • Controller層單元測試
  • 新斷言assertThat使用
  • 單元測試的回滾

正文

Spring Boot中引入單元測試很簡單,依賴如下:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>
複製程式碼

本篇例項Spring Boot版本為1.5.9.RELEASE,引入spring-boot-starter-test後,有如下幾個庫:
• JUnit — The de-facto standard for unit testing Java applications.
• Spring Test & Spring Boot Test — Utilities and integration test support for Spring Boot applications.
• AssertJ — A fluent assertion library.
• Hamcrest — A library of matcher objects (also known as constraints or predicates).
• Mockito — A Java mocking framework.
• JSONassert — An assertion library for JSON.
• JsonPath — XPath for JSON.

image.png

Service單元測試

Spring Boot中單元測試類寫在在src/test/java目錄下,你可以手動建立具體測試類,如果是IDEA,則可以通過IDEA自動建立測試類,如下圖,也可以通過快捷鍵⇧⌘T(MAC)或者Ctrl+Shift+T(Window)來建立,如下:

image.png
image.png

自動生成測試類如下:

Spring Boot乾貨系列:(十二)Spring Boot使用單元測試

然後再編寫建立好的測試類,具體程式碼如下:

package com.dudu.service;
import com.dudu.domain.LearnResource;
import org.junit.Assert;
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.test.context.junit4.SpringRunner;
import static org.hamcrest.CoreMatchers.*;

@RunWith(SpringRunner.class)
@SpringBootTest
public class LearnServiceTest {

    @Autowired
    private LearnService learnService;
    
    @Test
    public void getLearn(){
        LearnResource learnResource=learnService.selectByKey(1001L);
        Assert.assertThat(learnResource.getAuthor(),is("嘟嘟MD獨立部落格"));
    }
}
複製程式碼

上面就是最簡單的單元測試寫法,頂部只要@RunWith(SpringRunner.class)SpringBootTest即可,想要執行的時候,滑鼠放在對應的方法,右鍵選擇run該方法即可。

測試用例中我使用了assertThat斷言,下文中會介紹,也推薦大家使用該斷言。

Controller單元測試

上面只是針對Service層做測試,但是有時候需要對Controller層(API)做測試,這時候就得用到MockMvc了,你可以不必啟動工程就能測試這些介面。

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

Controller類:

package com.dudu.controller;

/** 教程頁面
 * Created by tengj on 2017/3/13.
 */
@Controller
@RequestMapping("/learn")
public class LearnController  extends AbstractController{
    @Autowired
    private LearnService learnService;
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @RequestMapping("")
    public String learn(Model model){
        model.addAttribute("ctx", getContextPath()+"/");
        return "learn-resource";
    }

    /**
     * 查詢教程列表
     * @param page
     * @return
     */
    @RequestMapping(value = "/queryLeanList",method = RequestMethod.POST)
    @ResponseBody
    public AjaxObject queryLearnList(Page<LeanQueryLeanListReq> page){
        List<LearnResource> learnList=learnService.queryLearnResouceList(page);
        PageInfo<LearnResource> pageInfo =new PageInfo<LearnResource>(learnList);
        return AjaxObject.ok().put("page", pageInfo);
    }
    
    /**
     * 新添教程
     * @param learn
     */
    @RequestMapping(value = "/add",method = RequestMethod.POST)
    @ResponseBody
    public AjaxObject addLearn(@RequestBody LearnResource learn){
        learnService.save(learn);
        return AjaxObject.ok();
    }

    /**
     * 修改教程
     * @param learn
     */
    @RequestMapping(value = "/update",method = RequestMethod.POST)
    @ResponseBody
    public AjaxObject updateLearn(@RequestBody LearnResource learn){
        learnService.updateNotNull(learn);
        return AjaxObject.ok();
    }

    /**
     * 刪除教程
     * @param ids
     */
    @RequestMapping(value="/delete",method = RequestMethod.POST)
    @ResponseBody
    public AjaxObject deleteLearn(@RequestBody Long[] ids){
        learnService.deleteBatch(ids);
        return AjaxObject.ok();
    }

    /**
     * 獲取教程
     * @param id
     */
    @RequestMapping(value="/resource/{id}",method = RequestMethod.GET)
    @ResponseBody
    public LearnResource qryLearn(@PathVariable(value = "id") Long id){
       LearnResource lean= learnService.selectByKey(id);
        return lean;
    }
}

複製程式碼

這裡我們也自動建立一個Controller的測試類,具體程式碼如下:

package com.dudu.controller;

import com.dudu.domain.User;
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.mock.web.MockHttpSession;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringRunner.class)
@SpringBootTest

public class LearnControllerTest {
    @Autowired
    private WebApplicationContext wac;

    private MockMvc mvc;
    private MockHttpSession session;


    @Before
    public void setupMockMvc(){
        mvc = MockMvcBuilders.webAppContextSetup(wac).build(); //初始化MockMvc物件
        session = new MockHttpSession();
        User user =new User("root","root");
        session.setAttribute("user",user); //攔截器那邊會判斷使用者是否登入,所以這裡注入一個使用者
    }

    /**
     * 新增教程測試用例
     * @throws Exception
     */
    @Test
    public void addLearn() throws Exception{
        String json="{\"author\":\"HAHAHAA\",\"title\":\"Spring\",\"url\":\"http://tengj.top/\"}";
        mvc.perform(MockMvcRequestBuilders.post("/learn/add")
                    .accept(MediaType.APPLICATION_JSON_UTF8)
                    .content(json.getBytes()) //傳json引數
                    .session(session)
            )
           .andExpect(MockMvcResultMatchers.status().isOk())
           .andDo(MockMvcResultHandlers.print());
    }

    /**
     * 獲取教程測試用例
     * @throws Exception
     */
    @Test
    public void qryLearn() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/learn/resource/1001")
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON_UTF8)
                    .session(session)
            )
           .andExpect(MockMvcResultMatchers.status().isOk())
           .andExpect(MockMvcResultMatchers.jsonPath("$.author").value("嘟嘟MD獨立部落格"))
           .andExpect(MockMvcResultMatchers.jsonPath("$.title").value("Spring Boot乾貨系列"))
           .andDo(MockMvcResultHandlers.print());
    }

    /**
     * 修改教程測試用例
     * @throws Exception
     */
    @Test
    public void updateLearn() throws Exception{
        String json="{\"author\":\"測試修改\",\"id\":1031,\"title\":\"Spring Boot乾貨系列\",\"url\":\"http://tengj.top/\"}";
        mvc.perform(MockMvcRequestBuilders.post("/learn/update")
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .content(json.getBytes())//傳json引數
                .session(session)
        )
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(MockMvcResultHandlers.print());
    }

    /**
     * 刪除教程測試用例
     * @throws Exception
     */
    @Test
    public void deleteLearn() throws Exception{
        String json="[1031]";
        mvc.perform(MockMvcRequestBuilders.post("/learn/delete")
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .content(json.getBytes())//傳json引數
                .session(session)
        )
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(MockMvcResultHandlers.print());
    }

}

複製程式碼

上面實現了基本的增刪改查的測試用例,使用MockMvc的時候需要先用MockMvcBuilders使用構建MockMvc物件,如下

@Before
public void setupMockMvc(){
    mvc = MockMvcBuilders.webAppContextSetup(wac).build(); //初始化MockMvc物件
    session = new MockHttpSession();
    User user =new User("root","root");
    session.setAttribute("user",user); //攔截器那邊會判斷使用者是否登入,所以這裡注入一個使用者
}
複製程式碼

因為攔截器那邊會判斷是否登入,所以這裡我注入了一個使用者,你也可以直接修改攔截器取消驗證使用者登入,先測試完再開啟。

這裡拿一個例子來介紹一下MockMvc簡單的方法

/**
 * 獲取教程測試用例
 * @throws Exception
 */
@Test
public void qryLearn() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/learn/resource/1001")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .session(session)
        )
       .andExpect(MockMvcResultMatchers.status().isOk())
       .andExpect(MockMvcResultMatchers.jsonPath("$.author").value("嘟嘟MD獨立部落格"))
       .andExpect(MockMvcResultMatchers.jsonPath("$.title").value("Spring Boot乾貨系列"))
       .andDo(MockMvcResultHandlers.print());
}
複製程式碼
  1. mockMvc.perform執行一個請求
  2. MockMvcRequestBuilders.get("/user/1")構造一個請求,Post請求就用.post方法
  3. contentType(MediaType.APPLICATION_JSON_UTF8)代表傳送端傳送的資料格式是application/json;charset=UTF-8
  4. accept(MediaType.APPLICATION_JSON_UTF8)代表客戶端希望接受的資料型別為application/json;charset=UTF-8
  5. session(session)注入一個session,這樣攔截器才可以通過
  6. ResultActions.andExpect新增執行完成後的斷言
  7. ResultActions.andExpect(MockMvcResultMatchers.status().isOk())方法看請求的狀態響應碼是否為200如果不是則拋異常,測試不通過
  8. andExpect(MockMvcResultMatchers.jsonPath("$.author").value("嘟嘟MD獨立部落格"))這裡jsonPath用來獲取author欄位比對是否為嘟嘟MD獨立部落格,不是就測試不通過
  9. ResultActions.andDo新增一個結果處理器,表示要對結果做點什麼事情,比如此處使用MockMvcResultHandlers.print()輸出整個響應結果資訊

本例子測試如下:

image.png

mockMvc 更多例子可以本篇下方參考檢視

新斷言assertThat使用

JUnit 4.4 結合 Hamcrest 提供了一個全新的斷言語法——assertThat。程式設計師可以只使用 assertThat 一個斷言語句,結合 Hamcrest 提供的匹配符,就可以表達全部的測試思想,我們引入的版本是Junit4.12所以支援assertThat。

assertThat 的基本語法如下:

清單 1 assertThat 基本語法

assertThat( [value], [matcher statement] );
複製程式碼
  • value 是接下來想要測試的變數值;
  • matcher statement 是使用 Hamcrest 匹配符來表達的對前面變數所期望的值的宣告,如果 value 值與 matcher statement 所表達的期望值相符,則測試成功,否則測試失敗。

assertThat 的優點

  • 優點 1:以前 JUnit 提供了很多的 assertion 語句,如:assertEquals,assertNotSame,assertFalse,assertTrue,assertNotNull,assertNull 等,現在有了 JUnit 4.4,一條 assertThat 即可以替代所有的 assertion 語句,這樣可以在所有的單元測試中只使用一個斷言方法,使得編寫測試用例變得簡單,程式碼風格變得統一,測試程式碼也更容易維護。
  • 優點 2:assertThat 使用了 Hamcrest 的 Matcher 匹配符,使用者可以使用匹配符規定的匹配準則精確的指定一些想設定滿足的條件,具有很強的易讀性,而且使用起來更加靈活。如清單 2 所示:

清單 2 使用匹配符 Matcher 和不使用之間的比較

// 想判斷某個字串 s 是否含有子字串 "developer" 或 "Works" 中間的一個
// JUnit 4.4 以前的版本:assertTrue(s.indexOf("developer")>-1||s.indexOf("Works")>-1 );
// JUnit 4.4:
assertThat(s, anyOf(containsString("developer"), containsString("Works"))); 
// 匹配符 anyOf 表示任何一個條件滿足則成立,類似於邏輯或 "||", 匹配符 containsString 表示是否含有引數子 
// 字串,文章接下來會對匹配符進行具體介紹
複製程式碼
  • 優點 3:assertThat 不再像 assertEquals 那樣,使用比較難懂的“謂賓主”語法模式(如:assertEquals(3, x);),相反,assertThat 使用了類似於“主謂賓”的易讀語法模式(如:assertThat(x,is(3));),使得程式碼更加直觀、易讀。

  • 優點 4:可以將這些 Matcher 匹配符聯合起來靈活使用,達到更多目的。如清單 3 所示:

清單 3 Matcher 匹配符聯合使用

// 聯合匹配符not和equalTo表示“不等於”
assertThat( something, not( equalTo( "developer" ) ) ); 
// 聯合匹配符not和containsString表示“不包含子字串”
assertThat( something, not( containsString( "Works" ) ) ); 
// 聯合匹配符anyOf和containsString表示“包含任何一個子字串”
assertThat(something, anyOf(containsString("developer"), containsString("Works")));
複製程式碼
  • 優點 5:錯誤資訊更加易懂、可讀且具有描述性(descriptive) JUnit 4.4 以前的版本預設出錯後不會丟擲額外提示資訊,如:
assertTrue( s.indexOf("developer") > -1 || s.indexOf("Works") > -1 );
複製程式碼

如果該斷言出錯,只會丟擲無用的錯誤資訊,如:junit.framework.AssertionFailedError:null。 如果想在出錯時想列印出一些有用的提示資訊,必須得程式設計師另外手動寫,如:

assertTrue( "Expected a string containing 'developer' or 'Works'", 
    s.indexOf("developer") > -1 || s.indexOf("Works") > -1 );
複製程式碼

非常的不方便,而且需要額外程式碼。 JUnit 4.4 會預設自動提供一些可讀的描述資訊,如清單 4 所示: 清單 4 JUnit 4.4 預設提供一些可讀的描述性錯誤資訊

String s = "hello world!"; 
assertThat( s, anyOf( containsString("developer"), containsString("Works") ) ); 
// 如果出錯後,系統會自動丟擲以下提示資訊:
java.lang.AssertionError: 
Expected: (a string containing "developer" or a string containing "Works") 
got: "hello world!"
複製程式碼

如何使用 assertThat

JUnit 4.4 自帶了一些 Hamcrest 的匹配符 Matcher,但是隻有有限的幾個,在類 org.hamcrest.CoreMatchers 中定義,要想使用他們,必須匯入包 org.hamcrest.CoreMatchers.*。

清單 5 列舉了大部分 assertThat 的使用例子:

字元相關匹配符
/**equalTo匹配符斷言被測的testedValue等於expectedValue,
* equalTo可以斷言數值之間,字串之間和物件之間是否相等,相當於Object的equals方法
*/
assertThat(testedValue, equalTo(expectedValue));
/**equalToIgnoringCase匹配符斷言被測的字串testedString
*在忽略大小寫的情況下等於expectedString
*/
assertThat(testedString, equalToIgnoringCase(expectedString));
/**equalToIgnoringWhiteSpace匹配符斷言被測的字串testedString
*在忽略頭尾的任意個空格的情況下等於expectedString,
*注意:字串中的空格不能被忽略
*/
assertThat(testedString, equalToIgnoringWhiteSpace(expectedString);
/**containsString匹配符斷言被測的字串testedString包含子字串subString**/
assertThat(testedString, containsString(subString) );
/**endsWith匹配符斷言被測的字串testedString以子字串suffix結尾*/
assertThat(testedString, endsWith(suffix));
/**startsWith匹配符斷言被測的字串testedString以子字串prefix開始*/
assertThat(testedString, startsWith(prefix));
一般匹配符
/**nullValue()匹配符斷言被測object的值為null*/
assertThat(object,nullValue());
/**notNullValue()匹配符斷言被測object的值不為null*/
assertThat(object,notNullValue());
/**is匹配符斷言被測的object等於後面給出匹配表示式*/
assertThat(testedString, is(equalTo(expectedValue)));
/**is匹配符簡寫應用之一,is(equalTo(x))的簡寫,斷言testedValue等於expectedValue*/
assertThat(testedValue, is(expectedValue));
/**is匹配符簡寫應用之二,is(instanceOf(SomeClass.class))的簡寫,
*斷言testedObject為Cheddar的例項
*/
assertThat(testedObject, is(Cheddar.class));
/**not匹配符和is匹配符正好相反,斷言被測的object不等於後面給出的object*/
assertThat(testedString, not(expectedString));
/**allOf匹配符斷言符合所有條件,相當於“與”(&&)*/
assertThat(testedNumber, allOf( greaterThan(8), lessThan(16) ) );
/**anyOf匹配符斷言符合條件之一,相當於“或”(||)*/
assertThat(testedNumber, anyOf( greaterThan(16), lessThan(8) ) );
數值相關匹配符
/**closeTo匹配符斷言被測的浮點型數testedDouble在20.0¡À0.5範圍之內*/
assertThat(testedDouble, closeTo( 20.0, 0.5 ));
/**greaterThan匹配符斷言被測的數值testedNumber大於16.0*/
assertThat(testedNumber, greaterThan(16.0));
/** lessThan匹配符斷言被測的數值testedNumber小於16.0*/
assertThat(testedNumber, lessThan (16.0));
/** greaterThanOrEqualTo匹配符斷言被測的數值testedNumber大於等於16.0*/
assertThat(testedNumber, greaterThanOrEqualTo (16.0));
/** lessThanOrEqualTo匹配符斷言被測的testedNumber小於等於16.0*/
assertThat(testedNumber, lessThanOrEqualTo (16.0));
集合相關匹配符
/**hasEntry匹配符斷言被測的Map物件mapObject含有一個鍵值為"key"對應元素值為"value"的Entry項*/
assertThat(mapObject, hasEntry("key", "value" ) );
/**hasItem匹配符表明被測的迭代物件iterableObject含有元素element項則測試通過*/
assertThat(iterableObject, hasItem (element));
/** hasKey匹配符斷言被測的Map物件mapObject含有鍵值“key”*/
assertThat(mapObject, hasKey ("key"));
/** hasValue匹配符斷言被測的Map物件mapObject含有元素值value*/
assertThat(mapObject, hasValue(value));
複製程式碼

單元測試回滾

單元個測試的時候如果不想造成垃圾資料,可以開啟事物功能,記在方法或者類頭部新增@Transactional註解即可,如下:

@Test
@Transactional
public void add(){
    LearnResource bean = new LearnResource();
    bean.setAuthor("測試回滾");
    bean.setTitle("回滾用例");
    bean.setUrl("http://tengj.top");
    learnService.save(bean);
}
複製程式碼

這樣測試完資料就會回滾了,不會造成垃圾資料。如果你想關閉回滾,只要加上@Rollback(false)註解即可。@Rollback表示事務執行完回滾,支援傳入一個引數value,預設true即回滾,false不回滾。

如果你使用的資料庫是Mysql,有時候會發現加了註解@Transactional 也不會回滾,那麼你就要檢視一下你的預設引擎是不是InnoDB,如果不是就要改成InnoDB。

MyISAM與InnoDB是mysql目前比較常用的兩個資料庫儲存引擎,MyISAM與InnoDB的主要的不同點在於效能和事務控制上。這裡簡單的介紹一下兩者間的區別和轉換方法:

  • MyISAM:MyISAM是MySQL5.5之前版本預設的資料庫儲存引擎。MYISAM提供高速儲存和檢索,以及全文搜尋能力,適合資料倉儲等查詢頻繁的應用。但不支援事務、也不支援外來鍵。MyISAM格式的一個重要缺陷就是不能在表損壞後恢復資料。

  • InnoDB:InnoDB是MySQL5.5版本的預設資料庫儲存引擎,不過InnoDB已被Oracle收購,MySQL自行開發的新儲存引擎Falcon將在MySQL6.0版本引進。InnoDB具有提交、回滾和崩潰恢復能力的事務安全。但是比起MyISAM儲存引擎,InnoDB寫的處理效率差一些並且會佔用更多的磁碟空間以保留資料和索引。儘管如此,但是InnoDB包括了對事務處理和外來鍵的支援,這兩點都是MyISAM引擎所沒有的。

  • MyISAM適合:(1)做很多count 的計算;(2)插入不頻繁,查詢非常頻繁;(3)沒有事務。

  • InnoDB適合:(1)可靠性要求比較高,或者要求事務;(2)表更新和查詢都相當的頻繁,並且表鎖定的機會比較大的情況。(4)效能較好的伺服器,比如單獨的資料庫伺服器,像阿里雲的關係型資料庫RDS就推薦使用InnoDB引擎。

修改預設引擎的步驟

檢視MySQL當前預設的儲存引擎:

mysql> show variables like '%storage_engine%';
複製程式碼

你要看user表用了什麼引擎(在顯示結果裡引數engine後面的就表示該表當前用的儲存引擎):

mysql> show create table user;
複製程式碼

將user表修為InnoDB儲存引擎(也可以此命令將InnoDB換為MyISAM):

mysql> ALTER TABLE user ENGINE=INNODB;
複製程式碼

如果要更改整個資料庫表的儲存引擎,一般要一個表一個表的修改,比較繁瑣,可以採用先把資料庫匯出,得到SQL,把MyISAM全部替換為INNODB,再匯入資料庫的方式。 轉換完畢後重啟mysql

service mysqld restart
複製程式碼

總結

到此為止,Spring Boot整合單元測試就基本完結,關於MockMvc以及assertThat的用法大家可以繼續深入研究。後續會整合Swagger UI這個API文件工具,即提供API文件又提供測試介面介面,相當好用。

想要檢視更多Spring Boot乾貨教程,可前往:Spring Boot乾貨系列總綱

參考

Junit學習筆記之五:MockMVC 探索 JUnit 4.4 新特性

# 原始碼下載  ( ̄︶ ̄)↗[相關示例完整程式碼]  - chapter12==》Spring Boot乾貨系列:(十二)Spring Boot使用單元測試

一直覺得自己寫的不是技術,而是情懷,一篇篇文章是自己這一路走來的痕跡。靠專業技能的成功是最具可複製性的,希望我的這條路能讓你少走彎路,希望我能幫你抹去知識的蒙塵,希望我能幫你理清知識的脈絡,希望未來技術之巔上有你也有我。

相關文章