在SpringBoot中如何编写高效运行的单元测试
在我五年的开发生涯中,接触了大量的Spring项目,这么多的项目里,我发现单元测试都极度匮乏,或许大家都未意识到单元测试的作用,亦或是懒得编写或是不会写。但是单元测试是必要的,可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试,最终受益的也是程序员自己。
本篇文章通过深入研究Spring的测试框架,来探讨如何编写高效运行的单元测试。先来看功能代码,这是一个创建用户的接口(省略无关代码):
@Service
public class UsersServiceImpl implements UsersService {
// ......
@Override
@Transactional(rollbackFor = Exception.class)
@AopLock(spEL = "#reqVo.createOrEditUsersForm.username")
public Users createUsers(CreateUsersReqVo reqVo, UserDetails userDetails) {
if (findUsersByUsername(reqVo.getCreateOrEditUsersForm().getUsername()) != null) {
throw new BizException(BizExceptionEnum.USER_EXISTS);
UsersExample usersExample = new UsersExample();
usersExample.createCriteria().andEmailEqualTo(reqVo.getCreateOrEditUsersForm().getEmail());
if (!usersMapper.selectByExample(usersExample).isEmpty()) {
throw new BizException(BizExceptionEnum.EMAIL_EXISTS);
Users user = new Users();
BeanUtils.copyProperties(reqVo.getCreateOrEditUsersForm(), user);
user.setPassword(passwordEncoder.encode(user.getPassword()));
if (userDetails != null) {
user.setCreateUsername(userDetails.getUsername());
user.setLastOpUsername(userDetails.getUsername());
} else {
user.setCreateUsername("");
user.setLastOpUsername("");
usersMapper.insertSelective(user);
Authorities authorities = new Authorities();
authorities.setUsername(reqVo.getCreateOrEditUsersForm().getUsername());
authorities.setAuthority(AppConsts.ROLE_ORDINARY);
authoritiesMapper.insert(authorities);
user = usersMapper.selectByPrimaryKey(reqVo.getCreateOrEditUsersForm().getUsername());
return user;
}
通过研究Spring的测试框架,第一版针对Service的单元测试是这样写的:
// 添加事务注解,则默认情况下测试方法执行完后事务会被回滚,结合@commit注解使用可让事务被提交
@Transactional
@RunWith(SpringRunner.class)
@SpringBootTest(
classes = CoreApplication.class
public class UsersServiceNormalTests {
@Autowired
private UsersService usersService;
@Test
// 添加此注解则事务不会回滚
// @Commit
// 指定运行测试方法前要先执行sql脚本
@Sql("classpath:sql-script/users.sql")
public void createUsersTest() {
CreateUsersReqVo reqVo = new CreateUsersReqVo();
CreateUsersForm form = new CreateUsersForm();
form.setUsername("jufeng98");
form.setPassword("admin");
form.setEmail("jufeng98@qq.com");
form.setGender("M");
reqVo.setCreateOrEditUsersForm(form);
Users users = usersService.createUsers(reqVo, mockUserDetails());
Assert.assertEquals(users.getUsername(), "jufeng98");
}
注意 @Transactional 和 @Sql 注解的使用,这样就能让测试方法可以重复执行而不污染数据库。
单元测试这样写目前没什么问题,但是随着业务的发展,我们的应用会变得越来越庞大,会引入各种各样的框架,如Apollo、dubbo、mongodb和redis等等,导致了我们应用启动越来越慢。而单元测试这里使用@SpringBootTest注解时,该注解相当于完整启动了整个应用,这会让执行测试类的耗时随着应用的变大而不断变长,在我接触的实际项目中有些项目启动就要耗时一分多钟,这意味着执行测试类也要耗时一分多钟,这就变得无法接受,所以此种写法不推荐。
通过继续研究Spring的测试框架,我的想法是,我仅仅需要测试UsersService类,所以不应该让Spring组装其他无关的bean,我只需要将UsersService类所依赖的bean组装起来就行了,所以,先编写一个mybatis的测试配置类:
@TestConfiguration
@MapperScan(basePackages = "org.javamaster.b2c.core.mapper")
@Profile(PROFILE_UNIT_TEST)
public class MybatisTestConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
// ......
return sqlSessionFactoryBean.getObject();
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
接着还有其他的相关bean依赖以此类推来编写,最终得到的写法:
@Transactional
@ContextConfiguration(classes = {
DatasourceTestConfig.class,
MybatisTestConfig.class,
RedissonTestConfig.class,
WebTestConfig.class,
UsersServiceImpl.class
@ActiveProfiles(PROFILE_UNIT_TEST)
public class UsersServiceBestTests extends CommonTestCode {
@Autowired
private UsersService usersService;
@Test
// @Commit
@Sql("classpath:sql-script/users.sql")
public void createUsersTest() {
CreateUsersReqVo reqVo = new CreateUsersReqVo();
CreateUsersForm form = new CreateUsersForm();
form.setUsername("jufeng98");
form.setPassword("admin");
form.setEmail("jufeng98@qq.com");
form.setGender("M");
reqVo.setCreateOrEditUsersForm(form);
Users users = usersService.createUsers(reqVo, mockUserDetails());
Assert.assertEquals(users.getUsername(), "jufeng98");
}
此时执行测试类就非常快了,因为我们只组装了必要的bean,只要Service依赖的东西不变,那么执行时间就基本不会有太多变化,避免了第一种写法的问题。
这种方式唯一的缺点就是需要清楚知道依赖了哪些bean,并将他们组装起来,虽然麻烦了点,但这是值得的,避免了组装无关的bean,让测试类能快速启动执行。
最后这里是针对Service层(接口)的测试,对于Controller层的测试是缺失的,所以为了能快速测试Controller,我又研究了针对Controller的测试类,先来看看Controller的功能代码:
@Validated
@RestController
@RequestMapping("/admin/users")
public class UsersController {
@Autowired
private UsersService usersService;
* 创建用户
@PostMapping("/createUsers")
public Result<Users> createUsers(@Validated @RequestBody CreateUsersReqVo reqVo,
@AuthenticationPrincipal UserDetails userDetails) {
return new Result<>(usersService.createUsers(reqVo, userDetails));
}
第一种针对Controller的测试类写法,这种写法也是用了@SpringBootTest注解,所以也有执行耗时随着应用的变大而不断变长的问题(此种写法不推荐):
// 添加事务注解,则默认情况下测试方法执行完后事务会被回滚,结合@commit注解使用可让事务被提交
@Transactional
@RunWith(SpringRunner.class)
@SpringBootTest(
classes = CoreApplication.class
public class UsersControllerNormalTests {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Before
public void setup() {
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
@Test
@SneakyThrows
// SpringSecurity的测试注解,用于mock当前登录的用户信息
@WithMockUser(
username = "admin",
password = "admin",
authorities = "ROLE_ADMIN"
// 添加此注解则事务不会回滚
// @Commit
// 指定运行测试方法前要先执行sql脚本
@Sql("classpath:sql-script/users.sql")
public void createUsersTest() {
ObjectNode reqVo = objectMapper.createObjectNode();
ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
createOrEditUsersForm.put("username", "jufeng98");
createOrEditUsersForm.put("password", "admin");
createOrEditUsersForm.put("email", "jufeng98@qq.com");
createOrEditUsersForm.put("gender", "M");
// 发起请求并对结果进行断言
mockMvc
.perform(
post("/admin/users/createUsers")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(reqVo))
.accept(MediaType.APPLICATION_JSON_UTF8)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.data.username").value("jufeng98"));
}
第二种针对Controller的测试类写法,也是通过自己组装Spring应用的上下文:
@Transactional
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
DatasourceTestConfig.class,
MybatisTestConfig.class,
RedissonTestConfig.class,
WebTestConfig.class,
UsersServiceImpl.class,
SecurityTestConfig.class,
UsersController.class
// 下面这三个注解用于配置SpringMVC的测试上下文
@AutoConfigureMockMvc
@AutoConfigureWebMvc
@WebAppConfiguration
@ActiveProfiles(PROFILE_UNIT_TEST)
public class UsersControllerBestTests extends CommonTestCode {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@SneakyThrows
@WithMockUser(
username = "admin",
password = "admin",
authorities = "ROLE_ADMIN"
// @Commit
@Sql("classpath:sql-script/users.sql")
public void createUsersTest() {
ObjectNode reqVo = objectMapper.createObjectNode();
ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
createOrEditUsersForm.put("username", "jufeng98");
createOrEditUsersForm.put("password", "admin");
createOrEditUsersForm.put("email", "jufeng98@qq.com");
createOrEditUsersForm.put("gender", "M");
mockMvc
.perform(
post("/admin/users/createUsers")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(reqVo))
.accept(MediaType.APPLICATION_JSON_UTF8)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.data.username").value("jufeng98"));
}
所以,掌握此类写法就能让单元测试高效运行,于此同时,我们也可利用单元测试来快速调试代码,这也大大提高了我们的开发效率,可谓一举两得。
另外,Spring也提供了两个非常有用的测试注解:@MockBean、@SpyBean,还有一个辅助类:MockRestServiceServer。下面依次介绍其用法,首先是@MockBean,此注解会代理bean的所有方法,对于未mock的方法调用均是返回null:
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
WebTestConfig.class,
DatasourceTestConfig.class,
SecurityTestConfig.class,
UsersController.class
@AutoConfigureMockMvc
@AutoConfigureWebMvc
@WebAppConfiguration
@ActiveProfiles(PROFILE_UNIT_TEST)
public class MockBeanTests extends CommonTestCode {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UsersService usersService;
@Test
@SneakyThrows
@WithMockUser(
username = "admin",
password = "admin",
authorities = "ROLE_ADMIN"
public void createUsersTest() {
Users users = new Users();
users.setUsername("jufeng98");
// @MockBean注解会代理bean的所有方法,对于未mock的方法调用均是返回null,这里的意思是针对调用createUsers方法
// 的任意入参,均返回指定的结果
given(usersService.createUsers(any(), any())).willReturn(users);
ObjectNode reqVo = objectMapper.createObjectNode();
ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
createOrEditUsersForm.put("username", "jufeng98");
createOrEditUsersForm.put("password", "admin");
createOrEditUsersForm.put("email", "jufeng98@qq.com");
createOrEditUsersForm.put("gender", "M");
mockMvc
.perform(
post("/admin/users/createUsers")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(reqVo))
.accept(MediaType.APPLICATION_JSON_UTF8)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.data.username").value("jufeng98"));
}
@SpyBean 可达到部分mock的效果,未被mock的方法会被真实调用:
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
MybatisTestConfig.class,
WebTestConfig.class,
DatasourceTestConfig.class,
SecurityTestConfig.class,
UsersController.class,
UsersServiceImpl.class
@AutoConfigureMockMvc
@AutoConfigureWebMvc
@WebAppConfiguration
@ActiveProfiles(PROFILE_UNIT_TEST)
public class SpyBeanTests extends CommonTestCode {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@SpyBean
private UsersService usersService;
@Test
@SneakyThrows
@WithMockUser(
username = "admin",
password = "admin",
authorities = "ROLE_ADMIN"
public void createUsersTest() {
Users users = new Users();
users.setUsername("jufeng98");
// @SpyBean可达到部分mock的效果,仅当 doReturn("").when(service).doSomething() 时,doSomething方法才被mock,
// 其他的方法仍被真实调用。
// 未发生实际调用
doReturn(users).when(usersService).createUsers(any(), any());
ObjectNode reqVo = objectMapper.createObjectNode();
ObjectNode createOrEditUsersForm = reqVo.putObject("createOrEditUsersForm");
createOrEditUsersForm.put("username", "jufeng98");
createOrEditUsersForm.put("password", "admin");
createOrEditUsersForm.put("email", "jufeng98@qq.com");
createOrEditUsersForm.put("gender", "M");
mockMvc
.perform(
post("/admin/users/createUsers")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(reqVo))
.accept(MediaType.APPLICATION_JSON_UTF8)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.data.username").value("jufeng98"));
}
最后是MockRestServiceServer类,用于mock使用RestTemplate调用http接口的返回,假设我们有个接口是这样的,使用了RestTemplate调用http接口获取信息:
@Validated
@RestController
@RequestMapping("/admin/test")
public class TestController {
@Autowired
private TestService testService;
@PostMapping("/getOrderPayType")
public Result<String> getOrderPayType(@RequestBody JsonNode jsonNode) {
return new Result<>(testService.getOrderPayType(jsonNode.get("orderCode").asText()));
@Service
public class TestServiceImpl implements TestService {
@Autowired
private RestTemplate restTemplate;
@Override
public String getOrderPayType(String orderCode) {
JsonNode jsonNode = restTemplate.getForObject("http://b2c-cloud-order-service/getOrderPayType?orderCode={1}", JsonNode.class, orderCode);
return Objects.requireNonNull(jsonNode).get("payType").asText();
}
那么单元测试就可以这样写:
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
DatasourceTestConfig.class,
SecurityTestConfig.class,
WebTestConfig.class,
TestController.class,
TestServiceImpl.class
@AutoConfigureMockMvc
@AutoConfigureWebMvc
@WebAppConfiguration
@ActiveProfiles(PROFILE_UNIT_TEST)
public class MockRestServiceServerTests extends CommonTestCode {
@Autowired
protected MockMvc mockMvc;
@Autowired
private RestTemplate restTemplate;
@Test
@WithMockUser(
username = "admin",
password = "admin",
authorities = "ROLE_ADMIN"
@SneakyThrows
public void test() {
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
// mock http接口的返回
server
.expect(requestTo("http://b2c-cloud-order-service/getOrderPayType?orderCode=C93847639357"))
.andRespond(withSuccess("{\"orderCode\":\"C93847639357\",\"payType\":\"alipay\"}", MediaType.APPLICATION_JSON_UTF8));
mockMvc
.perform(
post("/admin/test/getOrderPayType")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"orderCode\":\"C93847639357\"}")
.accept(MediaType.APPLICATION_JSON_UTF8)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.data").value("alipay"));
}
最后SpringBoot还提供了大量的各类辅助测试的注解例如@JdbcTest、@DataRedisTest、@DataJpaTest等等,大家有兴趣可以去研究。
前面说到,使用@ContextConfiguration来自己组装应用上下文时,如果待测试的Controller依赖的对象很多的话,那么这里就要把依赖的类一层层的找出并逐一列到@ContextConfiguration的classes属性里,显得非常麻烦,为了解决这个问题, 可使用@BootstrapWith注解指定自定义的WebTestContextBootstrapper接口的实现类ScanDependenciesContextBootstrapper(注意加粗的两行),在ScanDependenciesContextBootstrapper里通过扫描类路径将所有依赖自动找出 ,这样,就解决了需要自己找出所有依赖并一一列出的问题,大大简化测试类的编写:
@Transactional
@ScanTestedDependencies(UsersController.class)
@BootstrapWith(ScanDependenciesContextBootstrapper.class)
@ContextConfiguration(classes = {
MybatisTestConfig.class,
RedisTestConfig.class,
SecurityTestConfig.class,
public class UsersControllerSuperBestTests extends CommonSuperTestCode {
// ......
* 扫描指定类的所有依赖和指定接口的所有子类的依赖.通过查看类字段是否带有Autowired,
* 将其class加入到ContextConfiguration的classes里.
* 对于不带有Autowired的特殊依赖字段,使用additionalInterfaces指定.
* <br/>
* 注意:只会扫描当前模块(即target目录下的class),不会去扫描jar包(为了节省时间),
* 若依赖位于其他jar包,则仍需在ContextConfiguration注解中显式指明
* @author yudong
* @date 2021/5/15
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ScanTestedDependencies {
Class<?> value();
Class<?>[] additionalInterfaces() default {};
public class ScanDependenciesContextBootstrapper extends WebTestContextBootstrapper {
private final Set<Class<?>> alreadyHandle = new HashSet<>();
private Vector<?> allTargetClasses;
// 找出待测试Controller类的所有依赖class,并加入到@ContextConfiguration的calsses里
@Override
@SuppressWarnings("all")
protected MergedContextConfiguration processMergedContextConfiguration(MergedContextConfiguration mergedConfig) {
MergedContextConfiguration mergedContextConfiguration = super.processMergedContextConfiguration(mergedConfig);
Class<?> testClass = mergedConfig.getTestClass();
ScanTestedDependencies scanTestedDependencies = testClass.getAnnotation(ScanTestedDependencies.class);
if (scanTestedDependencies == null) {
return mergedContextConfiguration;
initTargetAllClasses(testClass.getClassLoader());
Class<?> targetTestedClass = scanTestedDependencies.value();
if (targetTestedClass.isInterface()) {
throw new IllegalArgumentException("待测试的类不能是接口:" + targetTestedClass.getSimpleName());
List<Class<?>> list = getDependencyClasses(targetTestedClass);
Class<?>[] interfaces = scanTestedDependencies.additionalInterfaces();
for (Class<?> additionalInterface : interfaces) {
List<Class<?>> implClasses = getInterfaceImplClasses(additionalInterface);
list.addAll(implClasses);
for (Class<?> clazz : implClasses) {
list.addAll(getDependencyClasses(clazz));
list.add(targetTestedClass);
list = list.stream()
.filter(clz -> !clz.getName().startsWith("org.springframework") && !Modifier.isAbstract(clz.getModifiers()))
.collect(Collectors.toList());
Class<?>[] classes = mergedConfig.getClasses();
Class<?>[] targetClasses = list.toArray(new Class<?>[]{});
Class<?>[] allClasses = new Class[classes.length + targetClasses.length];
System.arraycopy(classes, 0, allClasses, 0, classes.length);
System.arraycopy(targetClasses, 0, allClasses, classes.length, targetClasses.length);