spingboot单元测试

单元测试种类

  • 单元测试: [责任人: 开发人员]针对 class 级别的测试, 针对一个类, 写一个测试类. 在项目中, class 之间依赖调用是很常见的事情, 如果要测试的 classA, 又调用了classB, 这时候就没有遵守 "在一个class范围内测试", 自然不算单元测试, 应归为集成测试了. 不过这时候, 我们可以使用 mock 技术模拟被依赖的 classB, 这样整个测试又聚焦在classA 自身的功能, 所以又变回为单元测试.
  • 集成测试: [责任人: 开发人员]是单元测试在粒度上更进一步, 测试不同class之间的组合功能.
  • 冒烟测试: 可以理解为预测试, 一旦预测试失败(发现重大bug), 就直接停止测试, 打回给开发人员.
  • 回归测试: 重跑原有测试用例.

spring-boot-starter-test 依赖包括

  • JUnit, 测试框架
  • Spring Test & Spring Boot Test,
  • Mockito, Mock框架, 可以模拟任何 Spring 管理的 bean. 在单元测试过程中, 使用 @MockBean 注解可以将一个业务Service 的模拟器注入到我们要测试的SpringBoot程序中, 而不是真正的业务Service实现类.
  • JSONassert,可以对json内容进行断言.
  • JsonPath, 提供类类似于 XPath 的 json 路径访问形式.

JUnit 方法级别的注解

  • @BeforeClass -- 用来修饰static方法, 在测试类加载时执行.
  • @AfterClass -- 用来修饰static方法, 在测试类结束时执行.
  • @Before -- 用来修饰实例方法, 在每个测试方法之前执行, 比如用来初始化一个 MockMvc 对象.
  • @After -- 用来修饰实例方法, 在每个测试方法之后执行.
  • @Test -- 用来修饰实例方法, 是测试用例方法.
  • @Transactional --和@Test一起搭配使用, 作用是自动回滚事务, 防止单元测试污染测试数据库.
  • @Rollback(false) --和@Transactional/@Test一起搭配使用, 用来提交事务.

JUnit 类级别注解

  • @RunWith -- 对于SpringBoot程序, 使用 @RunWith(SpringRunner.class)
  • @SuiteClasses -- 在IDE中一次只能执行一个测试类, 对于一个大型项目, 肯定不止一个测试类, 如何一次执行多个测试类呢? 答案就是使用@SuiteClasses. 该注解能将多个测试class归并到一个新的测试class, 新的测试类往往是一个空类, 需要加上 @RunWith(Suite.class), 代码如下:
@RunWith(Suite.class)
@Suite.SuiteClasses({//需要整合的测试类,逗号分隔
        QrCodeControllerTests.class, AccountControllerTests.class
})
public class SuiteTests {
    
}

JUnit 断言

  • assertEquals() --是否相等
  • assertSame() --判断是否是同一个对象
  • assertTrue()
  • assertFalse()
  • assertNotNull()
  • assertArrayEquals()
  • assertThat() -- JUnit4.4 新增一个全能的断言

Spring test 提供的类级别注解

  • @SpringBootTest, 该注解负责扫描配置来构建测试用的Spring上下文环境.将在启动单元测试之前, 首先按照包名逐级向上找 SpringBoot业务系统的入口,并启动该SpringBoot 程序. 所以启动速度较慢, 用于集成测试.如果我们需要要注入 WebApplicationContext 和 MockMvc bean, 需要在类上再加一个 @AutoConfigureMockMvc 注解. 完整示例见下面的MyControllerTests 代码.

  • @WebMvcTest, 该注解仅仅扫描并初始化与 Controller 相关的bean, 但一般的@Component并不会被扫描, 另外也不启动一个 http server, 所以能加快测试进程. @WebMvcTest 还可以传入 Controller 类清单参数, 进一步缩小容器中bean的数量. @WebMvcTest 还会自动实例化一个 WebApplicationContext 和 MockMvc bean, 供我们在测试用例中使用.

  • @RestClientTest 和 @WebMvcTest 类似, 用来测试基于Rest API的Service 类, 该注解会自动实例化一个MockRestServiceServer bean用来模拟远程的 Restful 服务.

@RunWith(SpringRunner.class)
@RestClientTest(RemoteVehicleDetailsService.class)
public class ExampleRestClientTest {

    @Autowired
    private RemoteVehicleDetailsService service;

    @Autowired
    private MockRestServiceServer server;

    @Test
    public void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails()
            throws Exception {
        this.server.expect(requestTo("/greet/details"))
                .andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
        String greeting = this.service.callRestService();
        assertThat(greeting).isEqualTo("hello");
    }
}
  • 除了上面几个类型, 还有 @MybatisTest 、 @JsonTest 、 @DataRedisTest 等等.

MockMvc 基本使用

MockMvc bean, 使用该对象的 perform() 可以发出模拟web请求, 并完成期望检查, 当然期望检查还可以使用JUnit 的 Assert. 虽然 MockMvc 是一个模拟的Http调用, 但它确实真实调用了视图函数, 并以 http 协议封装了结果, 所以可以用来做单元测试.

@WebMvcTest(AccountController.class)
@RunWith(SpringRunner.class)
public class AccountControllerTests {

    @Autowired
    private MockMvc mockMvc;

    //模拟bean
    @MockBean
    private IaccountService accountService;

    @Test
    public void testGetSpeed() throws Exception {
        String phone = "135*****772";

        BDDMockito.given(accountService.findSpeedBySunline(phone)) //模拟方法
                .willReturn("用户未开户");//模拟返回值
        mockMvc.perform(
                    get("/openAccount/getSpeed") //get方法
                        .param("phoneNo", phone)// 参数
                        .contentType(MediaType.APPLICATION_JSON)//参数类型
                        .accept(MediaType.APPLICATION_JSON)
                )
                .andExpect(MockMvcResultMatchers.status().isOk())// 判断接收到的状态是否是200(静态导入)
                .andDo(print());// 打印请求和响应的详情
    }
}

Mockito基本使用

顶层class经常会依赖一些底层class, 要对顶层class做单元测试, 就需要使用 mock 技术来代替底层class.

  1. @MockBean 注解, 该 Mockito 注解可注入顶层对象, 它和 @Autowired 用法含义差不多, 但并不会注入真实的底层实现类.
  2. 使用 Mockito.when() 来模拟底层类的行为:
    Mockito.when(methodCall).thenReturn(expected) 使用穷举法来模拟, 这个方式简单但最常用, 我们写测试用例基本上也是按照case by case 设计有限的几个测试用例.
    Mockito.when(methodCall).thenAnswer(匿名对象) 通过when()传入的参数动态模拟, 该方式很强大, 但并不常用, 因为我们在测试用例中, 没有必要再和被模拟对象一样实现一套业务逻辑.
@RunWith(SpringRunner.class)
@SpringBootTest
public class MyServiceConsumerTests {
    @MockBean
    MyService myService;

    @Autowired
    MyServiceConsumer myServiceConsumer;

    /*
     * 使用 MockBean 来模拟 MyService 的 appendA() 行为
     */
    @Before
    public void Init() {
        // 方式1: 使用穷举法模拟, 具体是通过 thenReturn()方法
        String source = "MyService1";
        Mockito.when(myService.appendA(source))
                .thenReturn(source + "A");

        // 方式2:使用动态参数形式模拟, 具体是通过 thenAnswer()方法
        Mockito.when(myService.appendA(Mockito.anyString()))
                .thenAnswer(new Answer<String>() {

                    @Override
                    public String answer(InvocationOnMock invocation) throws Throwable {
                        String arg = (String) invocation.getArgument(0);
                        return arg + "A";
                    }
                });
    }

    @Test
    public void getServiceName1() {
        String source = "MyService1";
        String expected = "MyService1A";
        String actual = myServiceConsumer.getServiceName(source);
        Assert.assertEquals("错误: getServiceName() 不符合预期", expected, actual);
    }

    @Test
    public void getServiceName2() {
        String source = "Other";
        String expected = "OtherA";
        String actual = myServiceConsumer.getServiceName(source);
        Assert.assertEquals("错误: getServiceName() 不符合预期", expected, actual);
    }
}

综合使用示例

待测试controller类

@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;
    }
}

测试类

@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简单的方法

/**
 * 获取教程测试用例
 * @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执行一个请求
  • MockMvcRequestBuilders.get(“/user/1”)构造一个请求,Post请求就用.post方法
  • contentType(MediaType.APPLICATION_JSON_UTF8)代表发送端发送的数据格式是application/json;charset=UTF-8
  • accept(MediaType.APPLICATION_JSON_UTF8)代表客户端希望接受的数据类型为application/json;charset=UTF-8
  • session(session)注入一个session,这样拦截器才可以通过
  • ResultActions.andExpect添加执行完成后的断言
  • ResultActions.andExpect(MockMvcResultMatchers.status().isOk())方法看请求的状态响应码是否为200如果不是则抛异常,测试不通过
  • andExpect(MockMvcResultMatchers.jsonPath(“$.author”).value(“嘟嘟MD独立博客”))这里jsonPath用来获取author字段比对是否为嘟嘟MD独立博客,不是就测试不通过
  • ResultActions.andDo添加一个结果处理器,表示要对结果做点什么事情,比如此处使用MockMvcResultHandlers.print()输出整个响应结果信息

如何使用 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));

文章参考

https://www.cnblogs.com/harrychinese/p/springboot_unittesting2.html
https://www.cnblogs.com/harrychinese/p/springboot_unittesting.html

推荐阅读更多精彩内容