Spring Boot项目的单元测试

最近在网易云课堂上看一些视频,给大家推荐一个讲Spring Boot的视频https://study.163.com/course/courseMain.htm?courseId=1005213034,老师讲的很不错。在学习的时候我也会做一些笔记,方便日后巩固。
对这个系列感兴趣的可以看我之前写的博客:

开始一个最简单的RESTful API项目

RestController详解(上)

RestController详解(下)

在Spring Boot中使用Mybatis

使用spring-test和junit进行单元测试

Assert — JUnit的断言

  • 判断某条件是否为真 Assert.assertTrue(条件表达式);
  • 判断某条件是否为假 Assert.assertFalse(条件表达式);
  • 判断两个变量值是否相同 Assert.assertEquals(var1, var2);
  • 判断两个变量值是否不相同 Assert.assertNotEquals(var1, var2);
  • 判断两个数组是否相同 Assert.assertArrayEquals(数组1, 数组2);
  • 直接测试失败Assert.fail() Assert.fail(message)

如果判断两个变量是否相同,建议使用Assert.assertEquals,因为
Assert.assertTrue不支持非基础类型

Assert vs. assert

  • Assert是JUnit的断言类, 全名是org.junit.Assert
  • Assert提供了很多静态方法,例如assertTrue, assertFalse, assertNotNull, assertNull, assertEquals, assertNotEquals等
  • assert是java关键字,使用方法有两种,表达式为false时,jvm会退出;
  • assert 表达式; assert 表达式 : “表达式不成立后的提示信息”;
  • assert关键字内表达式是否被检查成立依赖jvm的参数,默认是关闭的

概念

要进行测试,首先要理解三个概念

  • 被测模块:需要被测试的模块
  • 驱动模块:调用被测模块的模块
  • 桩模块:驱动模块需要对传入的数据做一些处理再传给下级模块,若需要对这些被处理的数据进行处理,就得使用桩模块。

桩模块的使用场景:

  • 替代尚未开发完毕的子模块
  • 替代对环境依赖较大的子模块(例如数据访问层)

有一个框架可以帮助我们运用桩模块,它就是Mockito,如果要使用它,需要在.pom文件里加入:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <scope>test</scope>
</dependency>

最后还有一个概念需要了解一下,这就是TDD

TDD

  • 先写测试用例,后写实现代码
  • 重构现有代码时特别好用

用mockito做桩模块来测试业务逻辑层

这一节会用实际的代码来看一下测试用例,在test的包下,我们可以看到项目自带了一个最简单的测试用例:

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() {
    }
}

在上面这个测试用例中,虽然contextLoads()方法体中什么也没有,但是如果spring boot的配置不正确的话,这个方法是不能运行成功的。接下来我们新建一个测试类,来看一些更复杂的测试用例:

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

    @Autowired
    TvSeriesDao tvSeriesDao;

    @Autowired
    TvSeriesService tvSeriesService;

    @Test
    public void getAllWithoutMockit() {
        List<TvSeries> res = tvSeriesService.getAllTvSeries();
        //这里的测试结果依赖连接数据库内的记录,很难写一个判断是否成功的条件,甚至无法执行
        //下面的testGetAll()方法,使用了mock出来的dao作为桩模块,避免了这一情形
        Assert.assertTrue(res.size()>0);
    }
}

上面的这个测试方法依赖于数据库的情况,数据库里的数据不是一直不变的,所以很难去制定一个测试通过的标准,断言就会很难写。在一个团队中第一个人写的这个测试用例通过后,也许第二个人去测试就不能通过了,也不能知道是什么原因,这时候就起不到测试用例原来的效果了。
要解决这种情况,就要用到之前提到的桩模块,把DAO层作为一个Mock Bean。

//测试类的成员变量
@MockBean
    TvSeriesDao tvSeriesDao;

我们可以自行设置这个mock bean的内容,从而使的测试前提不受到真实情况的影响。

@Test
    public void testGetAll() {
        //新建一个list来充当数据库里的记录
        List<TvSeries> list = new ArrayList<>();
        TvSeries tvSeries = new TvSeries();
        String name = "LoveManchester";
        tvSeries.setName(name);
        list.add(tvSeries);

        //下面这句话表示当调用getAllTvSeries()方法时,返回上述的list,这时测试结果就与数据库内的情况无关了
        Mockito.when(tvSeriesDao.getAll()).thenReturn(list);

        List<TvSeries> res = tvSeriesService.getAllTvSeries();

        //获取到的结果应和最初的list相同
        Assert.assertEquals(res.size(), list.size());
        Assert.assertEquals(name, res.get(0).getName());
    }

我们可以在service层中再增加一些方法用以测试传进方法中的参数:

    public TvSeries updateTvSeries(TvSeries tvSeries) {
        if (log.isTraceEnabled()) {
            log.trace("update tvSeries service start");
        }
        tvSeriesDao.update(tvSeries);
        return tvSeries;
    }

对应的DAO层中的方法为:

 public int update(TvSeries tvSeries);

下面的测试用例用来检测传进方法的参数:

@Test
   public void testUpdateTvSeries() {
       String newName = "Person Of Interest";
       //BitSet用来测试桩模块是否被执行
       BitSet mockExecute = new BitSet();

       //doAnswer用来判断执行的方法和方法的参数,doAnswer一般和when配合使用,当条件满足时,执行对应的Answer的answer方法,
       //如果answer方法抛出异常,那么测试不通过。这个方法意味着当执行dao层的update方法时会去检验该方法的参数,这个参数应该和newName相同
       Mockito.doAnswer(new Answer<Object>() {
           @Override
           public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
               //获取update()方法的参数
               Object[] args = invocationOnMock.getArguments();
               TvSeries arg = (TvSeries) args[0];
               Assert.assertEquals(newName,arg.getName());
               mockExecute.set(0);//方法正确执行时
               return null;
           }
       }).when(tvSeriesDao).update(any(TvSeries.class));

       TvSeries tvSeries = new TvSeries();
       tvSeries.setName(newName);

       tvSeriesService.updateTvSeries(tvSeries);
       //方法正确执行时0位get的值为true
       Assert.assertTrue(mockExecute.get(0));
   }

用mockMvc来测试web控制层和业务逻辑层

之前的内容都是在测试service层,那么有没有办法来测试web控制层呢,也是有办法的,我们可以用MockMVC来实现。

  1. 和TvSeriesServiceTests相比,这个测试类上多了@AutoConfigureMockMvc注解,这是初始化一个mvc环境用于测试
     @RunWith(SpringRunner.class)
     @SpringBootTest
     @AutoConfigureMockMvc
    
     public class AppTests {
     //TODO: 一些测试方法
    
    
  2. 写一个测试获取全部节目列表的方法
    @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);
    
        //下面这个是相当于在启动项目后,执行 GET /tvseries,被测模块是web控制层,因为web控制层会调用业务逻辑层,
        // 所以业务逻辑层也会被测试
        //业务逻辑层调用了被mock出来的数据访问层桩模块。
        //如果想仅仅测试web控制层,(例如业务逻辑层尚未编码完毕),可以mock一个业务逻辑层的桩模块
        mockMvc.perform(MockMvcRequestBuilders.get("/tvseries")).andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("POI")));
        //上面这几句和字面意思一致,期望状态是200,返回值包含POI三个字面,桩模块返回的一个电视剧名字是POI,如果测试正确是包含这三个字母的。
    }
    

下面再看一个调用POST方法的例子,这个测试用例用来测试添加一个tvseries的方法:

@Test
    public void testAddSeries() throws Exception {
        BitSet bitSet = new BitSet(1);
        bitSet.set(0,false);

        //下面的两个doAnswer方法用来验证插入到数据中的参数是否和我们传入进去的相等
        //bitSet验证桩模块是否被执行过
        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];
            //应该是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\"}]}";

        //模拟一个MVC环境,用POST方法传入一个JSON消息,将结果打印出来并验证状态是否为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));
    }

写这个单测之前需要将Controller、service、和dao中的方法同步更新。下面再来看一个测试上传文件的方法:

  1. 首先需要在controller对之前的上传文件方法做一些修改,这里我们把文件上传的路径设置成了类的field:
    //通过@Value将外部的值动态注入到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文件夹下放一张测试上传的图片,并命名为testfileupload.jpg
    @Test
    public void testFileUpload() throws Exception{
        String fileFolder = "/target/files";
        File folder = new File(fileFolder);
        if (!folder.exists()) {
            folder.mkdirs();
        }
    
        // 下面这句可以设置bean里面通过@Value获得的数据
        ReflectionTestUtils.setField(tvSeriesController,"uploadFolder",folder.
                getAbsolutePath());
    
        //用来获取资源
        InputStream inputStream = getClass().getResourceAsStream("/testfileupload.jpg");
        if(inputStream == null) {
            throw new RuntimeException("需要先在src/test/resources目录下放置一张jpg文件,名为testfileupload.jpg然后运行测试");
        }
    
        //模拟一个文件上传的请求
        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);
    
        //返回的文件名,应该已经保存在fileFolder文件夹下
        Assert.assertTrue(f2.exists());
    }
    

Case Study

在这次写单测的时候发现了一个问题,一直报一个错误java:找不到符号。反复检查并未发现错误,找不到的符号是一个普通的insert()方法。在网上查阅资料后,发现是因为在改动tvseriesDao文件后没有编译,使用右侧maven工具对文件单独编译即可解决。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容