Spring Boot項(xiàng)目的單元測(cè)試

最近在網(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)目

RestController詳解(上)

RestController詳解(下)

在Spring Boot中使用Mybatis

使用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)。

  1. 和TvSeriesServiceTests相比,這個(gè)測(cè)試類上多了@AutoConfigureMockMvc注解,這是初始化一個(gè)mvc環(huán)境用于測(cè)試
     @RunWith(SpringRunner.class)
     @SpringBootTest
     @AutoConfigureMockMvc
    
     public class AppTests {
     //TODO: 一些測(cè)試方法
    
    
  2. 寫一個(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è)試上傳文件的方法:

  1. 首先需要在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格式");
        }
    }
    
  2. 需要在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ú)編譯即可解決。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容