最近在網(wǎng)易云課堂上看一些視頻,給大家推薦一個(gè)講Spring Boot的視頻https://study.163.com/course/courseMain.htm?courseId=1005213034,老師講的很不錯(cuò)。在學(xué)習(xí)的時(shí)候我也會(huì)做一些筆記,方便日后鞏固。
對(duì)這個(gè)系列感興趣的可以看我之前寫的博客:
開始一個(gè)最簡(jiǎn)單的RESTful API項(xiàng)目
使用spring-test和junit進(jìn)行單元測(cè)試
Assert — JUnit的斷言
- 判斷某條件是否為真 Assert.assertTrue(條件表達(dá)式);
- 判斷某條件是否為假 Assert.assertFalse(條件表達(dá)式);
- 判斷兩個(gè)變量值是否相同 Assert.assertEquals(var1, var2);
- 判斷兩個(gè)變量值是否不相同 Assert.assertNotEquals(var1, var2);
- 判斷兩個(gè)數(shù)組是否相同 Assert.assertArrayEquals(數(shù)組1, 數(shù)組2);
- 直接測(cè)試失敗Assert.fail() Assert.fail(message)
如果判斷兩個(gè)變量是否相同,建議使用Assert.assertEquals,因?yàn)?br> Assert.assertTrue不支持非基礎(chǔ)類型
Assert vs. assert
- Assert是JUnit的斷言類, 全名是org.junit.Assert
- Assert提供了很多靜態(tài)方法,例如assertTrue, assertFalse, assertNotNull, assertNull, assertEquals, assertNotEquals等
- assert是java關(guān)鍵字,使用方法有兩種,表達(dá)式為false時(shí),jvm會(huì)退出;
- assert 表達(dá)式; assert 表達(dá)式 : “表達(dá)式不成立后的提示信息”;
- assert關(guān)鍵字內(nèi)表達(dá)式是否被檢查成立依賴jvm的參數(shù),默認(rèn)是關(guān)閉的
概念
要進(jìn)行測(cè)試,首先要理解三個(gè)概念
- 被測(cè)模塊:需要被測(cè)試的模塊
- 驅(qū)動(dòng)模塊:調(diào)用被測(cè)模塊的模塊
- 樁模塊:驅(qū)動(dòng)模塊需要對(duì)傳入的數(shù)據(jù)做一些處理再傳給下級(jí)模塊,若需要對(duì)這些被處理的數(shù)據(jù)進(jìn)行處理,就得使用樁模塊。
樁模塊的使用場(chǎng)景:
- 替代尚未開發(fā)完畢的子模塊
- 替代對(duì)環(huán)境依賴較大的子模塊(例如數(shù)據(jù)訪問層)
有一個(gè)框架可以幫助我們運(yùn)用樁模塊,它就是Mockito,如果要使用它,需要在.pom文件里加入:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
最后還有一個(gè)概念需要了解一下,這就是TDD
TDD
- 先寫測(cè)試用例,后寫實(shí)現(xiàn)代碼
- 重構(gòu)現(xiàn)有代碼時(shí)特別好用
用mockito做樁模塊來測(cè)試業(yè)務(wù)邏輯層
這一節(jié)會(huì)用實(shí)際的代碼來看一下測(cè)試用例,在test的包下,我們可以看到項(xiàng)目自帶了一個(gè)最簡(jiǎn)單的測(cè)試用例:
package cn.luxiaofen.test;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class TestApplicationTests {
@Test
public void contextLoads() {
}
}
在上面這個(gè)測(cè)試用例中,雖然contextLoads()方法體中什么也沒有,但是如果spring boot的配置不正確的話,這個(gè)方法是不能運(yùn)行成功的。接下來我們新建一個(gè)測(cè)試類,來看一些更復(fù)雜的測(cè)試用例:
@RunWith(SpringRunner.class)
@SpringBootTest
public class TvSeriesServiceTest {
@Autowired
TvSeriesDao tvSeriesDao;
@Autowired
TvSeriesService tvSeriesService;
@Test
public void getAllWithoutMockit() {
List<TvSeries> res = tvSeriesService.getAllTvSeries();
//這里的測(cè)試結(jié)果依賴連接數(shù)據(jù)庫內(nèi)的記錄,很難寫一個(gè)判斷是否成功的條件,甚至無法執(zhí)行
//下面的testGetAll()方法,使用了mock出來的dao作為樁模塊,避免了這一情形
Assert.assertTrue(res.size()>0);
}
}
上面的這個(gè)測(cè)試方法依賴于數(shù)據(jù)庫的情況,數(shù)據(jù)庫里的數(shù)據(jù)不是一直不變的,所以很難去制定一個(gè)測(cè)試通過的標(biāo)準(zhǔn),斷言就會(huì)很難寫。在一個(gè)團(tuán)隊(duì)中第一個(gè)人寫的這個(gè)測(cè)試用例通過后,也許第二個(gè)人去測(cè)試就不能通過了,也不能知道是什么原因,這時(shí)候就起不到測(cè)試用例原來的效果了。
要解決這種情況,就要用到之前提到的樁模塊,把DAO層作為一個(gè)Mock Bean。
//測(cè)試類的成員變量
@MockBean
TvSeriesDao tvSeriesDao;
我們可以自行設(shè)置這個(gè)mock bean的內(nèi)容,從而使的測(cè)試前提不受到真實(shí)情況的影響。
@Test
public void testGetAll() {
//新建一個(gè)list來充當(dāng)數(shù)據(jù)庫里的記錄
List<TvSeries> list = new ArrayList<>();
TvSeries tvSeries = new TvSeries();
String name = "LoveManchester";
tvSeries.setName(name);
list.add(tvSeries);
//下面這句話表示當(dāng)調(diào)用getAllTvSeries()方法時(shí),返回上述的list,這時(shí)測(cè)試結(jié)果就與數(shù)據(jù)庫內(nèi)的情況無關(guān)了
Mockito.when(tvSeriesDao.getAll()).thenReturn(list);
List<TvSeries> res = tvSeriesService.getAllTvSeries();
//獲取到的結(jié)果應(yīng)和最初的list相同
Assert.assertEquals(res.size(), list.size());
Assert.assertEquals(name, res.get(0).getName());
}
我們可以在service層中再增加一些方法用以測(cè)試傳進(jìn)方法中的參數(shù):
public TvSeries updateTvSeries(TvSeries tvSeries) {
if (log.isTraceEnabled()) {
log.trace("update tvSeries service start");
}
tvSeriesDao.update(tvSeries);
return tvSeries;
}
對(duì)應(yīng)的DAO層中的方法為:
public int update(TvSeries tvSeries);
下面的測(cè)試用例用來檢測(cè)傳進(jìn)方法的參數(shù):
@Test
public void testUpdateTvSeries() {
String newName = "Person Of Interest";
//BitSet用來測(cè)試樁模塊是否被執(zhí)行
BitSet mockExecute = new BitSet();
//doAnswer用來判斷執(zhí)行的方法和方法的參數(shù),doAnswer一般和when配合使用,當(dāng)條件滿足時(shí),執(zhí)行對(duì)應(yīng)的Answer的answer方法,
//如果answer方法拋出異常,那么測(cè)試不通過。這個(gè)方法意味著當(dāng)執(zhí)行dao層的update方法時(shí)會(huì)去檢驗(yàn)該方法的參數(shù),這個(gè)參數(shù)應(yīng)該和newName相同
Mockito.doAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
//獲取update()方法的參數(shù)
Object[] args = invocationOnMock.getArguments();
TvSeries arg = (TvSeries) args[0];
Assert.assertEquals(newName,arg.getName());
mockExecute.set(0);//方法正確執(zhí)行時(shí)
return null;
}
}).when(tvSeriesDao).update(any(TvSeries.class));
TvSeries tvSeries = new TvSeries();
tvSeries.setName(newName);
tvSeriesService.updateTvSeries(tvSeries);
//方法正確執(zhí)行時(shí)0位get的值為true
Assert.assertTrue(mockExecute.get(0));
}
用mockMvc來測(cè)試web控制層和業(yè)務(wù)邏輯層
之前的內(nèi)容都是在測(cè)試service層,那么有沒有辦法來測(cè)試web控制層呢,也是有辦法的,我們可以用MockMVC來實(shí)現(xiàn)。
- 和TvSeriesServiceTests相比,這個(gè)測(cè)試類上多了@AutoConfigureMockMvc注解,這是初始化一個(gè)mvc環(huán)境用于測(cè)試
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc public class AppTests { //TODO: 一些測(cè)試方法 - 寫一個(gè)測(cè)試獲取全部節(jié)目列表的方法
@Test public void testGetAll() throws Exception{ List<TvSeries> list = new ArrayList<>(); TvSeries tvSeries = new TvSeries(); tvSeries.setName("POI"); list.add(tvSeries); //這些樁模塊的加載可參考TvSeriesServiceTest中的例子 Mockito.when(tvSeriesDao.getAll()).thenReturn(list); //下面這個(gè)是相當(dāng)于在啟動(dòng)項(xiàng)目后,執(zhí)行 GET /tvseries,被測(cè)模塊是web控制層,因?yàn)閣eb控制層會(huì)調(diào)用業(yè)務(wù)邏輯層, // 所以業(yè)務(wù)邏輯層也會(huì)被測(cè)試 //業(yè)務(wù)邏輯層調(diào)用了被mock出來的數(shù)據(jù)訪問層樁模塊。 //如果想僅僅測(cè)試web控制層,(例如業(yè)務(wù)邏輯層尚未編碼完畢),可以mock一個(gè)業(yè)務(wù)邏輯層的樁模塊 mockMvc.perform(MockMvcRequestBuilders.get("/tvseries")).andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("POI"))); //上面這幾句和字面意思一致,期望狀態(tài)是200,返回值包含POI三個(gè)字面,樁模塊返回的一個(gè)電視劇名字是POI,如果測(cè)試正確是包含這三個(gè)字母的。 }
下面再看一個(gè)調(diào)用POST方法的例子,這個(gè)測(cè)試用例用來測(cè)試添加一個(gè)tvseries的方法:
@Test
public void testAddSeries() throws Exception {
BitSet bitSet = new BitSet(1);
bitSet.set(0,false);
//下面的兩個(gè)doAnswer方法用來驗(yàn)證插入到數(shù)據(jù)中的參數(shù)是否和我們傳入進(jìn)去的相等
//bitSet驗(yàn)證樁模塊是否被執(zhí)行過
Mockito.doAnswer((Answer<Object>) invocation -> {
Object[] args = invocation.getArguments();
TvSeries tvSeries = (TvSeries) args[0];
Assert.assertEquals(tvSeries.getName(),"可愛的湖南人");
tvSeries.setId(118);
bitSet.set(0,true);
return null;
}).when(tvSeriesDao).insert(Mockito.any(TvSeries.class));
Mockito.doAnswer((Answer<Object>) invocation -> {
Object[] args = invocation.getArguments();
TvCharacter tvCharacter = (TvCharacter) args[0];
//應(yīng)該是json中傳遞過來的劇中角色名字
Assert.assertEquals(tvCharacter.getName(),"CaiYishu");
Assert.assertEquals(118, tvCharacter.getTvSeriesId());
bitSet.set(0,true);
return null;
}).when(tvCharacterDao).insert(Mockito.any(TvCharacter.class));
String jsonData = "{\"name\":\"可愛的湖南人\",\"seasonCount\":1,\"originalRelease\":\"1996-01-18\"," +
"\"tvCharacters\":[{\"id\":1,\"name\":\"CaiYishu\"}]}";
//模擬一個(gè)MVC環(huán)境,用POST方法傳入一個(gè)JSON消息,將結(jié)果打印出來并驗(yàn)證狀態(tài)是否為200
this.mockMvc.perform(MockMvcRequestBuilders.post("/tvseries").contentType(MediaType.APPLICATION_JSON).
content(jsonData)).andDo
(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk());
Assert.assertTrue(bitSet.get(0));
}
寫這個(gè)單測(cè)之前需要將Controller、service、和dao中的方法同步更新。下面再來看一個(gè)測(cè)試上傳文件的方法:
- 首先需要在controller對(duì)之前的上傳文件方法做一些修改,這里我們把文件上傳的路徑設(shè)置成了類的field:
//通過@Value將外部的值動(dòng)態(tài)注入到Bean中 @Value("${SpringBootTest.uploadFolder:target/files}") String uploadFolder;@PostMapping(value = "/{id}/photos",consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Map<String, String> addPhoto(@PathVariable int id, @RequestParam("photo")MultipartFile imgFile) throws Exception { if (log.isTraceEnabled()) { log.trace("接受到文件"+id+"收到文件:"+imgFile.getOriginalFilename()); } //保存文件 File folder = new File(uploadFolder); if(!folder.exists()) { folder.mkdirs(); } String fileName = imgFile.getOriginalFilename(); assert fileName != null; if (fileName.endsWith(".jpg")) { FileOutputStream fileOutputStream = new FileOutputStream(new File(folder,fileName)); IOUtils.copy(imgFile.getInputStream(),fileOutputStream); fileOutputStream.close(); Map<String, String> result = new HashMap<>(); result.put("photo", fileName); return result; }else { throw new RuntimeException("不支持的格式,僅支持jpg格式"); } } - 需要在test/resource文件夾下放一張測(cè)試上傳的圖片,并命名為testfileupload.jpg
@Test public void testFileUpload() throws Exception{ String fileFolder = "/target/files"; File folder = new File(fileFolder); if (!folder.exists()) { folder.mkdirs(); } // 下面這句可以設(shè)置bean里面通過@Value獲得的數(shù)據(jù) ReflectionTestUtils.setField(tvSeriesController,"uploadFolder",folder. getAbsolutePath()); //用來獲取資源 InputStream inputStream = getClass().getResourceAsStream("/testfileupload.jpg"); if(inputStream == null) { throw new RuntimeException("需要先在src/test/resources目錄下放置一張jpg文件,名為testfileupload.jpg然后運(yùn)行測(cè)試"); } //模擬一個(gè)文件上傳的請(qǐng)求 MockMultipartFile imgFile = new MockMultipartFile("photo","/testfileupload.jpg","image/jpeg",IOUtils.toByteArray(inputStream) ); ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.multipart("/tvseries/1/photos") .file(imgFile)).andExpect(MockMvcResultMatchers.status().isOk()); //解析返回的JSON ObjectMapper objectMapper = new ObjectMapper();//Jackson框架 Map<String,Object> map = objectMapper.readValue(resultActions.andReturn().getResponse().getContentAsString(),new TypeReference<Map<String,Object>>(){}); String fileName = (String) map.get("photo"); File f2 = new File(folder,fileName); //返回的文件名,應(yīng)該已經(jīng)保存在fileFolder文件夾下 Assert.assertTrue(f2.exists()); }
Case Study
在這次寫單測(cè)的時(shí)候發(fā)現(xiàn)了一個(gè)問題,一直報(bào)一個(gè)錯(cuò)誤java:找不到符號(hào)。反復(fù)檢查并未發(fā)現(xiàn)錯(cuò)誤,找不到的符號(hào)是一個(gè)普通的insert()方法。在網(wǎng)上查閱資料后,發(fā)現(xiàn)是因?yàn)樵诟膭?dòng)tvseriesDao文件后沒有編譯,使用右側(cè)maven工具對(duì)文件單獨(dú)編譯即可解決。