# 前言

这次来介绍下 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 目录下,你可以手动创建具体测试类,如果是 IDEA,则可以通过 IDEA 自动创建测试类,如下图,也可以通过快捷键 ⇧⌘T (MAC) 或者 Ctrl+Shift+T (Window) 来创建,如下:


自动生成测试类如下:

然后再编写创建好的测试类,具体代码如下:

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 即可,想要执行的时候,鼠标放在对应的方法,右键选择 run 该方法即可。

测试用例中我使用了 assertThat 断言,下文中会介绍,也推荐大家使用该断言。

# Controller 单元测试

上面只是针对 Service 层做测试,但是有时候需要对 Controller 层(API)做测试,这时候就得用到 MockMvc 了,你可以不必启动工程就能测试这些接口。

MockMvc 实现了对 Http 请求的模拟,能够直接使用网络的形式,转换到 Controller 的调用,这样可以使得测试速度快、不依赖网络环境,而且提供了一套验证的工具,这样可以使得请求的验证统一而且很方便。

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

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

本例子测试如下:

mockMvc 更多例子可以本篇下方参考查看

# 新断言 assertThat 使用

JUnit 4.4 结合 Hamcrest 提供了一个全新的断言语法 ——assertThat。程序员可以只使用 assertThat 一个断言语句,结合 Hamcrest 提供的匹配符,就可以表达全部的测试思想,我们引入的版本是 Junit4.12 所以支持 assertThat。

# assertThat 的基本语法如下:

清单 1 assertThat 基本语法

assertThat( [value], [matcher statement] );
  • value 是接下来想要测试的变量值;
  • matcher statement 是使用 Hamcrest 匹配符来表达的对前面变量所期望的值的声明,如果 value 值与 matcher statement 所表达的期望值相符,则测试成功,否则测试失败。

# assertThat 的优点

  • 优点 1:以前 JUnit 提供了很多的 assertion 语句,如:assertEquals,assertNotSame,assertFalse,assertTrue,assertNotNull,assertNull 等,现在有了 JUnit 4.4,一条 assertThat 即可以替代所有的 assertion 语句,这样可以在所有的单元测试中只使用一个断言方法,使得编写测试用例变得简单,代码风格变得统一,测试代码也更容易维护。
  • 优点 2:assertThat 使用了 Hamcrest 的 Matcher 匹配符,用户可以使用匹配符规定的匹配准则精确的指定一些想设定满足的条件,具有很强的易读性,而且使用起来更加灵活。如清单 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 表示是否含有参数子 
// 字符串,文章接下来会对匹配符进行具体介绍
  • 优点 3:assertThat 不再像 assertEquals 那样,使用比较难懂的 “谓宾主” 语法模式(如:assertEquals (3, x);),相反,assertThat 使用了类似于 “主谓宾” 的易读语法模式(如:assertThat (x,is (3));),使得代码更加直观、易读。
  • 优点 4:可以将这些 Matcher 匹配符联合起来灵活使用,达到更多目的。如清单 3 所示:
    清单 3 Matcher 匹配符联合使用
// 联合匹配符 not 和 equalTo 表示 “不等于”
assertThat( something, not( equalTo( "developer" ) ) ); 
// 联合匹配符 not 和 containsString 表示 “不包含子字符串”
assertThat( something, not( containsString( "Works" ) ) ); 
// 联合匹配符 anyOf 和 containsString 表示 “包含任何一个子字符串”
assertThat(something, anyOf(containsString("developer"), containsString("Works")));
  • 优点 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") ) ); 
// 如果出错后,系统会自动抛出以下提示信息:
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 中定义,要想使用他们,必须导入包 org.hamcrest.CoreMatchers.*。

清单 5 列举了大部分 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));

# 单元测试回滚

单元个测试的时候如果不想造成垃圾数据,可以开启事物功能,记在方法或者类头部添加 @Transactional 注解即可,如下:

@Test
@Transactional
public void add(){
    LearnResource bean = new LearnResource();
    bean.setAuthor("测试回滚");
    bean.setTitle("回滚用例");
    bean.setUrl("http://tengj.top");
    learnService.save(bean);
}

这样测试完数据就会回滚了,不会造成垃圾数据。如果你想关闭回滚,只要加上 @Rollback(false) 注解即可。 @Rollback 表示事务执行完回滚,支持传入一个参数 value,默认 true 即回滚,false 不回滚。

如果你使用的数据库是 Mysql,有时候会发现加了注解 @Transactional 也不会回滚,那么你就要查看一下你的默认引擎是不是 InnoDB,如果不是就要改成 InnoDB。

MyISAM 与 InnoDB 是 mysql 目前比较常用的两个数据库存储引擎,MyISAM 与 InnoDB 的主要的不同点在于性能和事务控制上。这里简单的介绍一下两者间的区别和转换方法:

  • MyISAM:MyISAM 是 MySQL5.5 之前版本默认的数据库存储引擎。MYISAM 提供高速存储和检索,以及全文搜索能力,适合数据仓库等查询频繁的应用。但不支持事务、也不支持外键。MyISAM 格式的一个重要缺陷就是不能在表损坏后恢复数据。
  • InnoDB:InnoDB 是 MySQL5.5 版本的默认数据库存储引擎,不过 InnoDB 已被 Oracle 收购,MySQL 自行开发的新存储引擎 Falcon 将在 MySQL6.0 版本引进。InnoDB 具有提交、回滚和崩溃恢复能力的事务安全。但是比起 MyISAM 存储引擎,InnoDB 写的处理效率差一些并且会占用更多的磁盘空间以保留数据和索引。尽管如此,但是 InnoDB 包括了对事务处理和外来键的支持,这两点都是 MyISAM 引擎所没有的。
  • MyISAM 适合:(1) 做很多 count 的计算;(2) 插入不频繁,查询非常频繁;(3) 没有事务。
  • InnoDB 适合:(1) 可靠性要求比较高,或者要求事务;(2) 表更新和查询都相当的频繁,并且表锁定的机会比较大的情况。(4) 性能较好的服务器,比如单独的数据库服务器,像阿里云的关系型数据库 RDS 就推荐使用 InnoDB 引擎。

# 修改默认引擎的步骤

查看 MySQL 当前默认的存储引擎:

mysql> show variables like '%storage_engine%';

你要看 user 表用了什么引擎 (在显示结果里参数 engine 后面的就表示该表当前用的存储引擎):

mysql> show create table user;

将 user 表修为 InnoDB 存储引擎 (也可以此命令将 InnoDB 换为 MyISAM):

mysql> ALTER TABLE user ENGINE=INNODB;

如果要更改整个数据库表的存储引擎,一般要一个表一个表的修改,比较繁琐,可以采用先把数据库导出,得到 SQL,把 MyISAM 全部替换为 INNODB,再导入数据库的方式。
转换完毕后重启 mysql

service mysqld restart

# 总结

到此为止,Spring Boot 整合单元测试就基本完结,关于 MockMvc 以及 assertThat 的用法大家可以继续深入研究。后续会整合 Swagger UI 这个 API 文档工具,即提供 API 文档又提供测试接口界面,相当好用。

想要查看更多 Spring Boot 干货教程,可前往:Post not found: Spring Boot干货系列总纲

# 源码下载

( ̄︶ ̄)↗[相关示例完整代码