一 JUnit介绍
- JUnit是一个由Java语言编写的开源的回归测试框架,由Erich Gamma和Kent Beck创建,用于编写和运行可重复的测试,它是用于单元测试框架体系xUnit的一个实例.所谓单元测试也就是白盒测试.JUnit是Java开发使用最为广泛的框架.该框架也得到绝大多数Java IDE和其他工具(如:Maven)的集成支持.同时,JUnit还有很多的第三方扩展和增强包可供使用.
回归测试:指重复以前全部或部分的相同测试;
- JUnit的相关概念
- JUnit测试
JUnit 3.x 版本通过对测试方法的命名(test+方法名)来确定是否是测试,且所有的测试类必须继承TestCase.JUnit 4.x版本全面引入了注解来执行我们编写的测试,JUnit 中有两个重要的类(Assume和Assert),以及其他一些重要的注解(BeforeClass AfterClass After Before Test 和 Ignore).其中,BeforeClass和AfterClass在每个类加载的开始和结束时运行,需要设置static方法,而Before和After则在每个测试方法开始之前和结束之后运行.
- 代码片段
import org.apache.commons.lang3.time.DateFormatUtils;
import org.junit.*;
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
public class Testing {
@BeforeClass
public static void beforeClassTest(){
System.out.println("单元测试开始之前执行初始化......");
System.out.println("-----------------------------");
}
@Before
public void beforeTest(){
System.out.println("单元测试方法开始之前执行......");
}
@Test
public void test1(){
Date sd = DateFormatUtils.formatStr2Date("2018-05-16");
Date ed = DateFormatUtils.formatStr2Date("2018-05-25");
System.out.println("相差天数: " + DateFormatUtils.getBetweenDays(sd,ed));
assertEquals("相差天数: " , 9, DateFormatUtils.getBetweenDays(sd,ed));
}
@Test
public void test2(){
Date sd = DateFormatUtils.formatStr2Date("2018-05-16");
Date ed = DateFormatUtils.formatStr2Date("2018-09-30");
System.out.println("相差天数: " + DateFormatUtils.getBetweenDays(sd,ed));
assertEquals("相差天数: " , 9, DateFormatUtils.getBetweenDays(sd,ed));
}
@After
public void afterTest(){
System.out.println("单元测试方法结束后执行......");
}
@AfterClass
public static void afterClassTest(){
System.out.println("-----------------------------");
System.out.println("单元测试开始之后执行......");
}
}
JUnit在执行每个@Test方法之前,会为测试类创建一个新的实例.这有助于提供测试方法之间的独立性,并且避免在测试代码中产生意外的副作用.因为每个测试方法都运行与一个新的测试类的实例上,所以不能再测试方法之间重用各个实例的变量值.
以上代码Test2未通过测试,因为我们输入的预期值为9天,实际上为127天,这一点可以从错误结果看出.
- JUnit没有main()方法作为入口是怎么运行的?
其实在org.junit.runner包下有个JUnitCore.class,其中就有一个是标准的main方法,这就是JUnit入口函数.如此看来,它其实和我们直接在自己的main方法中跑我们要测试的方法在本质上是一样的.
Assert
为了进行测试验证,我们使用了JUnit的Assert类提供的assert方法.正如之前在实例中所看到的那样,我们在测试类中静态地导入了这些方法.另外,根据我们队静态导入的喜好,还可以导入JUnit的Assert类本身,下面是一些常用的assert方法:
- Suite
JUnit设计Suite的目的是一次性运行一个或多个测试用例,Suite是一个容器,用来把几个测试类归在一起,并把他们作为一个集合来运行,测试运行器会启动Suite,而运行哪些测试类由Suite决定.
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
@RunWith(Suite.class)
@Suite.SuiteClasses({TestSuite1.class, TestSuite2.class})
public class TestSuiteMain {
// 虽然这个类是空的,但依然可以运行JUnit测试,运行时,它会将Testsuite1.class和TestSuite2.class中
// 所有的测试用例都执行一遍.
}
二 Spring Boot单元测试
Spring Boot提供了一些实用程序和注解,用来帮助我们测试应用程序.测试由两个模块支持spring-boot-test和spring-boot-test-autoconfigure.
spring-boot-test:包含了核心项目;
spring-boot-test-autoconfigure:支持自动配置测试.
一般我们会使用spring-boot-starter-test导入Spring Boot测试模块,以及JUnit assertj hamcrest和其他一些有用的库.集成只需要在pom中添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artigactId>
</dependency>
测试依赖范围
如果使用spring-boot-starter-test来进行测试,就会发现提供的一下测试库:
- Spring Boot测试脚手架
Spring Boot使用一系列注解来增强单元测试以支持Spring Boot测试.通常Spring Boot单元测试有类似如下的样子:
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {
@Autowired
UserService userService; // 要测试的service
@Test
public void testService(){}
}
@RunWith是JUnit标准的一个注解,用来告诉JUnit单元测试框架不要使用内置的方式进行单元测试,而应使用RunWith指明的类来提供单元测试,所有的Spring单元测试总是使用SpringRunner.class.
@SpringBootTest用于Spring Boot应用测试,它默认会根据包名逐级往上找,一直找到Spring Boot主程序,也就是通过类注解是否包含@SpringBootApplication来判断是否是主程序,并在单元测试的时候启动该类来创建Spring上下文环境.
注意:Spring单元测试并不会再每个单元测试方法前都启动一个全新的Spring上下文,因为这样太耗时.Spring单元测试会缓存上下文环境,以提供给每个单元测试方法.如果你的单元测试方法改变了上下文,比如更改了Bean定义,你需要在此单元测试方法上加上@DirtiesContext以提示Spring重新加载Spring上下文.
- 测试Service
单元测试Service代码跟我们平时通过Controller调用Service代码来进行测试相比,有三个需要特别考虑的地方:
1️⃣ 单元测试需要保证可重复的测试,因此希望Service测试完毕后,数据能自动回滚;
2️⃣ 单元测试是开发过程中的一种测试手段,Service依赖的其他Service还未开发完毕的情况下如何模拟?
3️⃣ 大多数Spring Boot应用都是面向数据库的应用,如何在单元测试前模拟好要测试的场景?
对于第一个问题:Spring Boot单元测试默认会在单元测试方法运行结束后进行事物回滚;
对于第二个问题:Spring Boot会集成Mockit来模拟未完成的Service类(或者是在单元测试中不能随便调用的第三方接口Service)
对于第三个问题:Spring引入了@Sql,在测试前执行一系列的SQL脚本来初始化数据.
以下是Service单元测试的脚手架:
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class ServiceTest {
@Autowired
UserService userService;
@MockBean
private CreditSystemService creditSystemService;
@Test
public void testService(){
int userId = 10;
int expectedCredit = 100;
given(this.creditSystemService.getUserCredit(anyInt())).willReturn(expectedCredit);
int credit = userService.getCredit(10);
assertEquals(expectedCredit, credit);
}
}
在这个例子中,我们要测试调用UserService的getCredit以获取用户积分.UserService依赖CreditSystemService的getUserCredit,通过REST接口从积分系统中获取用户的积分.UserService定义如下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
@Service
@Transactional
public class UserServiceImpl implements UserService{
@Autowired
CreditSystemService creditSystemService;
@Autowired
UserDao userDao;
@Override
public int getCredit(int userId){
User user = userDao.single(userId);
if (user != null){
return creditSystemService.getUserCredit(userId);
}else {
return -1;
}
}
}
因为单元测试不能实际调用creditSystemService(假设会调用一个第三方系统),因此,我们在单元测试类中使用了@MockBean
@MockBean
private CreditSystemService creditSystemService;
注解@MockBean可以自动注入Spring管理的Service,用来提供模式实现,因此creditSystemService变量在这里实际上并不是CreditSystemServiceImpl实例,而是一个通过Mockito创建的CreditSystemServiceMokitoMockxxxxxx实例(这里的xxxxxx是一组随记数字).因此,在Spring上下文中,creditSystemService实现已经被模拟实现代替了.
以下代码模拟了Bean的getUserCredit方法,无论传入什么参数,总是返回100积分:
given(this.creditSystemService.getUserCredit(anyInt())).willReturn(expectedCredit);
given是Mockito的一个静态方法,用来模拟一个Service方法调用返回,anyInt()指示了可以传入任何参数,willReturn方法说明这个调用将返回100.
默认情况下,单元测试完毕,事务总是回滚,有时需要通过数据库查看数据测试结果而不希望事务回滚,可以在方法上使用@Rollback(false).
- 测试MVC
Spring Boot可以单独测试Controller代码,用来验证与Controller相关的URL路径映射 文件上传 参数绑定 参数校验等特性.可以通过@WebMvcTest来完成MVC单元测试,脚手架如下所示.
/**
* Created by wb-yxk397023 on 2018/7/29.
*/
@RunWith(SpringRunner.class)
// 需要测试的Controller
@WebMvcTest(UserControllerTest.class)
public class UserControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
UserService userService;
@Test
public void testMvc() throws Exception{
int userId = 10;
int expectedCredit = 100;
// 模拟userService
given(this.userService.getCredit(userId)).willReturn(100);
// MVC调用
mvc.perform(get("/user/{id}", userId)).andExpect(content().string(String.valueOf(expectedCredit)));
}
}
在Spring MVC Test中,带有@Service @Component的类不会自动被扫描注册为Spring容器管理的Bean.
MockMvc用来在Service容器内对Controller进行单元测试,并非发起了HTTP请求调用Controller.
- 完成MVC请求模拟
MockMvc的核心方法如下:
public ResultActions perform(RequestBuilder requestbuilder)
RequestBuilder类可以通过使用MockMvcRequestBuilders的get post multipart等方法来实现,一下是一些常用的例子.
模拟一个Get请求:
mockMvc.perform(get("/hotels?foo={foo}", "bar"));
模拟一个post请求:
mockMvc.perform(post("/hotels/{id}", 42);
模拟文件上传:
mockMvc.perform(multipart("/doc").file("file", "文件内容".getBytes("UTF-8")));
模拟请求参数:
// 模拟提交message参数
mvc.perform(get("/user/{id}/{name}", userId, name).param("message", "hello"));
// 模拟一个checkbox提交
mvc.perform(get("/user/{id}/{name}", userId, name).param("job", "IT", "gov").param(...));
// 直接使用MultiValueMap构造参数
LinkedMultiValueMap params = new LinkedMultiValueMap();
params.put("message", "hello");
params.put("job", "IT");
params.put("job", "gov");
mvc.perform(get("/user/{id}/{name}", userId, name).param(params));
模拟Session和Cookie:
mvc.perform(get("/user.html").sessionAttr(name, value));
mvc.perform(get("/user.html").cookie(new Coolie(name, value)));
设置HTTP Body内容,比如提交的JSON:
String json = ....;
mvc.perform(get("user.html").content(json));
设置HTTP Header:
mvc.perform(get("/user/{id}/{name}", userId, name)
// HTTP提交内容
.contentType("application/x-www-form-urlencoded")
// 期望返回内容
.accept("application/json")
// 设置HTTP头
.header(header1, value1))
- 比较MVC的返回结果
perform方法返回ResultActions实例,这个类代表了MVC调用的结果.它提供一系列的andExpect方法来对MVC调用结果进行比较,如:
mockMvc.perform(get("/user/1"))
// 期望成功调用,即HTTP Status为200
.andExpect(status().isOk())
// 期望返回内容是application/json
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
// 检查返回内容
.andExpect(jsonPath("$.name").value("Jason"));
也可以对Controller返回的ModeAndView进行校验,如比较返回的视图:
mockMvc.perform(post("/form"))
.andExpect(view().name("/success.btl"));
比较Model:
mockMvc.perform(post("/form"))
.andExpect(status().isOk()
.andExpect(model().size(1))
.andExpect(model().attributeExists("person")
.andExpect(model().attribute("person", "mufeng"));
比较forward或者redirect:
mockMvc.perform(post("/login"))
.andExpect(forwardedUrl("/index.html"));
mockMvc.perform(post("/login"))
.andExpect(redirectedUrl("/index.html"));
比较返回内容,使用content():
andExpect(content().string("hello world"));
// 返回内容是XML,并且与xmlCotent一样
andExpect(content().xml(xmlContent));
// 返回内容是JSON,并且与jsonContent一样
andExpect(content().json(jsonContent));
andExpect(content().bytes(bytes));
XML和JSON方法用来比较返回值和期望值的相似程度,比如返回值是{"success": true},期望值是{"success": true},两者依然匹配.
- JSON比较
Spring Boot内置了JsonPath来比较返回的JSON内容,通常类似如下代码:
String path = "$.success";
mvc.perform(get("/user/{id}/{name}", userId, name))
.andExpect(jsonPath(path).value(true));
这段代码期望返回的JSON的success属性是true,$代表了JSON的根节点.
以下是一个JSON文档,我们可以用JsonPath表达式来抽取JSON节点的内容:
{
"store":{
"book":[
{
"category": "reference",
"author" : "Nigel Rees",
"title" : "Sayings of the Century",
"price" : 8.95
},
{
"category": "fiction",
"author" : "Evelyn Waugh",
"title" : "Sword of Honour",
"price" : 12.99
}
]
}
}
下表列举了一些Spring Boot常用的JsonPath场景,更多的JsonPath使用方法请参考官网
三 Mockito
- 在测试过程中,对那些不容易构建的对象用一个虚拟对象来代替测试的方法称为Mock测试.目前在Java阵营中主要的Mock测试工具有Mockito JMock EasyMock等,Spring Boot内置了Mockito.
2.Mockito可以模拟任何类和接口,模拟方法调用的返回值,模拟抛出异常等.Mockito实际上同时也记录调用这些模拟方法的输入/输出和顺序,从而可以校验这些模拟对象是否被正确的顺序调用,以及按照期望的属性被调用.
先来一段未完成的积分系统,模拟CreditSystemService:
public interface CreditSystemService {
public int getUserCredit(int userId);
public boolean addCedit(int userId, int score);
}
1️⃣ 学习Mockito,我们将暂时脱离Spring Boot容器测试,这样节省启动时间.单元测试使用MockitoJUnitRunner来运行单元测试,以下代码是一个学习Mockito的脚手架代码:
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.class)
public class CreditServiceMockTest{
@Test
public void test(){
int userId = 10;
// 创建Mock对象
CreditSystemService creditService = mock(CreditSystemService.class);
// 模拟Mock对象调用,传入任何int值都将返回100积分
when(creditService.getUserCredit(anyInt())).thenReturn(1000);
// 实际调用
int ret = creditService.getUserCredit(10);
// 比较期望值和返回值
assertEquals(1000, ret);
}
}
org.mockito.Mockito包含了一系列我们会用到的模拟测试方法,如mock when thenReturn等.
通过mock方法可以模拟任何一个类或者接口,比如模拟一个java.util.List接口实现,或者模拟一个java.util.LinkedList类实现:
LinkedList mockedList = mock(LinkedList.class);
List list = mock(List.class);
2️⃣模拟方法参数
Mockito提供any方法模拟方法的任何参数,比如:
when(creditService.getUserCredit(anyInt())).thenReturn(1000);
anyInt指的是不论传入任何参数,总是返回100,也可以使用any方法:
when(creditService.getUserCredit(any(int.class))).thenReturn(1000);
单元测试中,大多数时候不推荐使用any方法,因为为模拟的对象提供更明确的输入/输出才能更好地完成单元测试,比如在Spring Boot单元测试中,通过UserService调用CreditSystemService来获取用户积分,传入的参数userId是明确的,因为我们最好用具体的方法参数来代替any.
int userId = 10;
// 模拟某个场景需要调用creditService的getUserCredit
when(creditService.getUserCredit(eq(userId))).thenReturn(1000);
以上这一段代码模拟了当传入参数是10的时候,返回100积分.因此,如果在单元测试中,被测试代码并未按照预期的参数传入的时候,Mockito会报错.
int ret = creditService.getUserCredit(11);
这段代码并未按照预期传入10,而是传入了11,运行单元测试会报错;
Mockito不仅仅能模拟参数的调用和返回值,而且也记录了模拟对象是如何调用的,因此,如果我们模拟的调用并未被实际调用,Mockito也会报错,指示某些模拟并未使用.我们可以通过verity方法来更为精准的校验模拟的对象是否被调用.
比如某些场景,我们假设肯定会调用两次getUserCredit方法.如果没有调用两次,则判断单元测试失败.
// 模拟getUserCredit
when(creditService.getUserCredit(eq(userId))).thenReturn(1000);
// 实际调用,模拟一个业务场景会调用两次getUserCredit接口
int ret = creditService.getUserCredit(userId);
ret = creditService.getUserCredit(userId);
// 比较期望值和返回值
assertEquals(1000,ret);
verify(creditSerbice, times(2)).getUserCredit(eq(userId));
verify方法包含了模拟的对象和期望的调用次数,使用times来构造期望调用的次数,如果在业务调用中只发生了一次getUserCredit调用,那么Mockito在单元测试中就会报错.
因为Mockito能记录模拟对象的调用,因此除了模拟调用对象方法的次数,还能验证调用的顺序.使用inOrder方法:
// 创建Mock对象
CreditSystemService creditService = mock(CreditSystemService.class);
// 模拟Mock对象调用
when(creditService.getUserCredit(eq(userId))).thenReturn(1000);
when(creditService.addCedit(eq(userId),anyInt())).thenReturn(true);
// 实际调用,先获取用户积分,然后增加10分
int ret = creditService.getUserCredit(userId);
creditService.addCedit(userId, ret + 10);
// 验证调用顺序,确保模拟对象先被调用getUserCredit,然后在调用addCedit方法
InOrder inOrder = inOrder(creditService);
inOrder.verify(creditService).getUserCredit(userId);
inOrder.verify(creditService).addCedit(userId, ret + 10);
3️⃣ 模拟方法返回值
当使用when来模拟方法调用的时候,可以使用thenReturn来模拟返回的结果:
when(creditService.getUserCredit(eq(userId))).thenReturn(1000);
when(creditService.addCedit(eq(userId),anyInt())).thenReturn(true);
也可以使用thenThrow来模拟抛出一个异常,比如:
CreditSystemService creditService = mock(CreditSystemService.class);
// 模拟Mock对象调用
when(creditService.getUserCredit(eq(userId))).thenReturn(1000);
when(creditService.addCedit(lt(0))).thenThrow(new IllegalArgumentException("userId不能小于0"));
// 实际调用
int ret = creditService.getUserCredit(-1);
eq:表示参数相等的情况下
lt:表示参数小于0的情况下,将抛出异常;
有些情况下,模拟的方法并没有返回值,可以使用doThrow方法来抛出异常:
// 模拟List对象
List list = mock(List.class);
doThrow(new UnsupportedOperationException("不支持clear方法调用")).when(list).clear();
//实际调用将抛出异常
list.clear();
这一段代码模拟了一个List对象的clear方法调用将抛出异常.
四 面向数据库应用的单元测试
对于绝大部分的Spring Boot应用来说,都包含了数据库CRUD操作,在单元测试中,我们可以通过模拟Dao类来返回预期的CRUD结果.但对于复杂的面向数据库应用,我们有时候不但需要比较业务调用结果是否与期望的一致,我们更期望业务调用完毕,数据库各个表的行和列与期望的数据库行和列的值是一样的.
比如更新用户手机号码的业务调用,我们期望调用完毕后,数据库的User表中这个用户的mobile字段与我们期望的mobile一致.
1️⃣@Sql
Spring Boot提供了@Sql来初始化数据库.需要为单元测试准备一个新的数据库,这个心得数据库通常不包含任何数据,或者只包含一些必要的字典类型的数据和初始化数据.
注解@Sql可以引入一系列SQL脚本来进一步模拟测试前的数据库数据,以下是单元测试脚手架:
@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
@Transactional
public class UserDbTest {
@Autowired
UserService userService;
@Test
@Sql({"user.sql"}) // 初始化一条主键为1的用户数据
public void upateNameTest() {
User user = new User();
user.setId(1);
user.setName("hello123");
// 修改用户名称
boolean success = userService.updateUser(user);
assertTrue(success);
}
}
这段代码与我们之前的Spring Boot单元测试脚手架差不多,有两个区别:
1 @ActiveProfiles("test"),因为我们需要连接一个专门用于单元测试的数据库,因此我们激活了test作为profile.我们可以创建一个新的名为application-test.property的Spring Boot配置文件,进行单元测试的时候将读取此配置文件.
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/orm-test?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
orm-test数据库与ORM类似,不同的是orm-test不包含任何数据,仅仅用于单元测试.
@Sql注解可以包含多个SQL脚本,用来在单元测试方法前初始化数据.如果SQL脚本没有以"/"开头,则默认在测试类所在包下.否则,则从根目录搜索.也可以使用classpath: file: http:作为前缀的资源文件.上述例子中的@Sql({"user.sql"})相当于@Sql({"classpath:com/bee/sample/ch9/test/db/user.sql"}), user.sql的内容如下:
INSERT INTO 'user' (id, 'name', 'department_id') VALUES(1,'lijz','1');
使用@Sql尽管能在单元测试前初始化所需要的数据,但要比较调用业务方法后的数据库中的数据是否与我们期望的数据库数据一致,Spring现在还没有提供更多直接的办法,我们只能通过Dao从数据库加载数据来进行比较.以BeetlSQL为例,可以做如下改进:
@Autowired
UserDao userDao;
@Test
@Sql({"user.sql"})
public void upateNameTest() {
User user = new User();
user.setId(1);
user.setName("hello123");
boolean success = userService.updateUser(user);
User dbUser = userDao.unique(1);
assertEquals(dbUser.getName(), "hello123");
}
在单元测试中注入BeetlSQL的UserDao,并在单元测试中调用unique方法以获得User实例来进行比较.
五 其他的数据库单元测试工具(推荐)
1️⃣ Spring@Sql的局限性
- 通过脚本来初始化单元测试数据库使得初始化数据不够直观,特别是需要初始化的数据库涉及多张表 多条数据的时候;
- 初始化SQL脚本中的数据不得不与单元测试中的代码数据写死,比如user.sql模拟了一条主键为1的user数据,单元测试中也必须用1来作为User对象的id属性,
- 单元测试到底能覆盖多少业务场景,对于项目经理或者需求人员并不明显,需要用一种直观的表达方式
- 在业务方法调用完毕,需要比较数据库的数据与期望的数据是否一致的时候,还需要调用Dao加载数据后进行一一比较,非常不直观.
鉴于以上几种局限性,在这里向大家推荐另外一个面向数据库的单元测试工具XLSUnit;
它通过Excel的工作表模拟数据库初始化数据,用其他工作表来模拟单元测试后的期望结果,只需要写少量单元测试代码,维护直观的Excel文件,就可以完成单元测试,这里就不在展开介绍XLSUnit,如果大家感兴趣可以访问官网学习以及下载对应的实例.