
前言
這次來介紹下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.

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


自動生成測試類如下:

然后再編寫創(chuàng)建好的測試類,具體代碼如下:
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即可,想要執(zhí)行的時候,鼠標放在對應(yīng)的方法,右鍵選擇run該方法即可。
測試用例中我使用了assertThat斷言,下文中會介紹,也推薦大家使用該斷言。
Controller單元測試
上面只是針對Service層做測試,但是有時候需要對Controller層(API)做測試,這時候就得用到MockMvc了,你可以不必啟動工程就能測試這些接口。
MockMvc實現(xiàn)了對Http請求的模擬,能夠直接使用網(wǎng)絡(luò)的形式,轉(zhuǎn)換到Controller的調(diào)用,這樣可以使得測試速度快、不依賴網(wǎng)絡(luò)環(huán)境,而且提供了一套驗證的工具,這樣可以使得請求的驗證統(tǒng)一而且很方便。
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;
}
}
這里我們也自動創(chuàng)建一個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參數(shù)
.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干貨系列\(zhòng)",\"url\":\"http://tengj.top/\"}";
mvc.perform(MockMvcRequestBuilders.post("/learn/update")
.accept(MediaType.APPLICATION_JSON_UTF8)
.content(json.getBytes())//傳json參數(shù)
.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參數(shù)
.session(session)
)
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
}
上面實現(xiàn)了基本的增刪改查的測試用例,使用MockMvc的時候需要先用MockMvcBuilders使用構(gòu)建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());
}
- mockMvc.perform執(zhí)行一個請求
- MockMvcRequestBuilders.get("/user/1")構(gòu)造一個請求,Post請求就用.post方法
- contentType(MediaType.APPLICATION_JSON_UTF8)代表發(fā)送端發(fā)送的數(shù)據(jù)格式是
application/json;charset=UTF-8 - accept(MediaType.APPLICATION_JSON_UTF8)代表客戶端希望接受的數(shù)據(jù)類型為
application/json;charset=UTF-8 - session(session)注入一個session,這樣攔截器才可以通過
- ResultActions.andExpect添加執(zhí)行完成后的斷言
- ResultActions.andExpect(MockMvcResultMatchers.status().isOk())方法看請求的狀態(tài)響應(yīng)碼是否為200如果不是則拋異常,測試不通過
- andExpect(MockMvcResultMatchers.jsonPath("$.author").value("嘟嘟MD獨立博客"))這里jsonPath用來獲取author字段比對是否為
嘟嘟MD獨立博客,不是就測試不通過 - ResultActions.andDo添加一個結(jié)果處理器,表示要對結(jié)果做點什么事情,比如此處使用MockMvcResultHandlers.print()輸出整個響應(yīng)結(jié)果信息
本例子測試如下:

mockMvc 更多例子可以本篇下方參考查看
新斷言assertThat使用
JUnit 4.4 結(jié)合 Hamcrest 提供了一個全新的斷言語法——assertThat。程序員可以只使用 assertThat 一個斷言語句,結(jié)合 Hamcrest 提供的匹配符,就可以表達全部的測試思想,我們引入的版本是Junit4.12所以支持assertThat。
assertThat 的基本語法如下:
清單 1 assertThat 基本語法
assertThat( [value], [matcher statement] );
- value 是接下來想要測試的變量值;
- matcher statement 是使用 Hamcrest 匹配符來表達的對前面變量所期望的值的聲明,如果 value 值與 matcher statement 所表達的期望值相符,則測試成功,否則測試失敗。
assertThat 的優(yōu)點
- 優(yōu)點 1:以前 JUnit 提供了很多的 assertion 語句,如:assertEquals,assertNotSame,assertFalse,assertTrue,assertNotNull,assertNull 等,現(xiàn)在有了 JUnit 4.4,一條 assertThat 即可以替代所有的 assertion 語句,這樣可以在所有的單元測試中只使用一個斷言方法,使得編寫測試用例變得簡單,代碼風(fēng)格變得統(tǒng)一,測試代碼也更容易維護。
- 優(yōu)點 2:assertThat 使用了 Hamcrest 的 Matcher 匹配符,用戶可以使用匹配符規(guī)定的匹配準則精確的指定一些想設(shè)定滿足的條件,具有很強的易讀性,而且使用起來更加靈活。如清單 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 表示是否含有參數(shù)子
// 字符串,文章接下來會對匹配符進行具體介紹
優(yōu)點 3:assertThat 不再像 assertEquals 那樣,使用比較難懂的“謂賓主”語法模式(如:assertEquals(3, x);),相反,assertThat 使用了類似于“主謂賓”的易讀語法模式(如:assertThat(x,is(3));),使得代碼更加直觀、易讀。
優(yōu)點 4:可以將這些 Matcher 匹配符聯(lián)合起來靈活使用,達到更多目的。如清單 3 所示:
清單 3 Matcher 匹配符聯(lián)合使用
// 聯(lián)合匹配符not和equalTo表示“不等于”
assertThat( something, not( equalTo( "developer" ) ) );
// 聯(lián)合匹配符not和containsString表示“不包含子字符串”
assertThat( something, not( containsString( "Works" ) ) );
// 聯(lián)合匹配符anyOf和containsString表示“包含任何一個子字符串”
assertThat(something, anyOf(containsString("developer"), containsString("Works")));
- 優(yōu)點 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") ) );
// 如果出錯后,系統(tǒng)會自動拋出以下提示信息:
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 中定義,要想使用他們,必須導(dǎo)入包 org.hamcrest.CoreMatchers.*。
清單 5 列舉了大部分 assertThat 的使用例子:
字符相關(guān)匹配符
/**equalTo匹配符斷言被測的testedValue等于expectedValue,
* equalTo可以斷言數(shù)值之間,字符串之間和對象之間是否相等,相當于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結(jié)尾*/
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匹配符簡寫應(yīng)用之一,is(equalTo(x))的簡寫,斷言testedValue等于expectedValue*/
assertThat(testedValue, is(expectedValue));
/**is匹配符簡寫應(yīng)用之二,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) ) );
數(shù)值相關(guān)匹配符
/**closeTo匹配符斷言被測的浮點型數(shù)testedDouble在20.0?à0.5范圍之內(nèi)*/
assertThat(testedDouble, closeTo( 20.0, 0.5 ));
/**greaterThan匹配符斷言被測的數(shù)值testedNumber大于16.0*/
assertThat(testedNumber, greaterThan(16.0));
/** lessThan匹配符斷言被測的數(shù)值testedNumber小于16.0*/
assertThat(testedNumber, lessThan (16.0));
/** greaterThanOrEqualTo匹配符斷言被測的數(shù)值testedNumber大于等于16.0*/
assertThat(testedNumber, greaterThanOrEqualTo (16.0));
/** lessThanOrEqualTo匹配符斷言被測的testedNumber小于等于16.0*/
assertThat(testedNumber, lessThanOrEqualTo (16.0));
集合相關(guān)匹配符
/**hasEntry匹配符斷言被測的Map對象mapObject含有一個鍵值為"key"對應(yīng)元素值為"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));
單元測試回滾
單元個測試的時候如果不想造成垃圾數(shù)據(jù),可以開啟事物功能,記在方法或者類頭部添加@Transactional注解即可,如下:
@Test
@Transactional
public void add(){
LearnResource bean = new LearnResource();
bean.setAuthor("測試回滾");
bean.setTitle("回滾用例");
bean.setUrl("http://tengj.top");
learnService.save(bean);
}
這樣測試完數(shù)據(jù)就會回滾了,不會造成垃圾數(shù)據(jù)。如果你想關(guān)閉回滾,只要加上@Rollback(false)注解即可。@Rollback表示事務(wù)執(zhí)行完回滾,支持傳入一個參數(shù)value,默認true即回滾,false不回滾。
如果你使用的數(shù)據(jù)庫是Mysql,有時候會發(fā)現(xiàn)加了注解@Transactional 也不會回滾,那么你就要查看一下你的默認引擎是不是InnoDB,如果不是就要改成InnoDB。
MyISAM與InnoDB是mysql目前比較常用的兩個數(shù)據(jù)庫存儲引擎,MyISAM與InnoDB的主要的不同點在于性能和事務(wù)控制上。這里簡單的介紹一下兩者間的區(qū)別和轉(zhuǎn)換方法:
MyISAM:MyISAM是MySQL5.5之前版本默認的數(shù)據(jù)庫存儲引擎。MYISAM提供高速存儲和檢索,以及全文搜索能力,適合數(shù)據(jù)倉庫等查詢頻繁的應(yīng)用。但不支持事務(wù)、也不支持外鍵。MyISAM格式的一個重要缺陷就是不能在表損壞后恢復(fù)數(shù)據(jù)。
InnoDB:InnoDB是MySQL5.5版本的默認數(shù)據(jù)庫存儲引擎,不過InnoDB已被Oracle收購,MySQL自行開發(fā)的新存儲引擎Falcon將在MySQL6.0版本引進。InnoDB具有提交、回滾和崩潰恢復(fù)能力的事務(wù)安全。但是比起MyISAM存儲引擎,InnoDB寫的處理效率差一些并且會占用更多的磁盤空間以保留數(shù)據(jù)和索引。盡管如此,但是InnoDB包括了對事務(wù)處理和外來鍵的支持,這兩點都是MyISAM引擎所沒有的。
MyISAM適合:(1)做很多count 的計算;(2)插入不頻繁,查詢非常頻繁;(3)沒有事務(wù)。
InnoDB適合:(1)可靠性要求比較高,或者要求事務(wù);(2)表更新和查詢都相當?shù)念l繁,并且表鎖定的機會比較大的情況。(4)性能較好的服務(wù)器,比如單獨的數(shù)據(jù)庫服務(wù)器,像阿里云的關(guān)系型數(shù)據(jù)庫RDS就推薦使用InnoDB引擎。
修改默認引擎的步驟
查看MySQL當前默認的存儲引擎:
mysql> show variables like '%storage_engine%';
你要看user表用了什么引擎(在顯示結(jié)果里參數(shù)engine后面的就表示該表當前用的存儲引擎):
mysql> show create table user;
將user表修為InnoDB存儲引擎(也可以此命令將InnoDB換為MyISAM):
mysql> ALTER TABLE user ENGINE=INNODB;
如果要更改整個數(shù)據(jù)庫表的存儲引擎,一般要一個表一個表的修改,比較繁瑣,可以采用先把數(shù)據(jù)庫導(dǎo)出,得到SQL,把MyISAM全部替換為INNODB,再導(dǎo)入數(shù)據(jù)庫的方式。
轉(zhuǎn)換完畢后重啟mysql
service mysqld restart
總結(jié)
到此為止,Spring Boot整合單元測試就基本完結(jié),關(guān)于MockMvc以及assertThat的用法大家可以繼續(xù)深入研究。后續(xù)會整合Swagger UI這個API文檔工具,即提供API文檔又提供測試接口界面,相當好用。
想要查看更多Spring Boot干貨教程,可前往:Spring Boot干貨系列總綱
參考
Junit學(xué)習(xí)筆記之五:MockMVC
探索 JUnit 4.4 新特性
源碼下載
( ̄︶ ̄)↗[相關(guān)示例完整代碼]
- chapter12==》Spring Boot干貨系列:(十二)Spring Boot使用單元測試
一直覺得自己寫的不是技術(shù),而是情懷,一篇篇文章是自己這一路走來的痕跡??繉I(yè)技能的成功是最具可復(fù)制性的,希望我的這條路能讓你少走彎路,希望我能幫你抹去知識的蒙塵,希望我能幫你理清知識的脈絡(luò),希望未來技術(shù)之巔上有你也有我。