概述

本文主要介绍如何对基于spring-boot的web应用编写单元测试、集成测试的代码。

此类应用的架构图一般如下所示:

我们项目的程序,对应到上图中的web应用部分。这部分一般分为Controller层、service层、持久层。除此之外,应用程序中还有一些数据封装类,我们称之为domain。上述各组件的职责如下:

  • Controller层/Rest接口层: 负责对外提供Rest服务,接收Rest请求,返回处理结果。
  • service层: 业务逻辑层,根据Controller层的需要,实现具体的逻辑。
  • 持久层: 访问数据库,进行数据的读写。向上支撑service层的数据库访问需求。

在Spring环境中,我们通常会把这三层注册到Spring容器,上图中使用浅蓝色背景就是为了表示这一点。

在本文的后续内容,我们将介绍如何对应用进行集成测试,包括启动web容器的请求测试、不启动web容器而使用模拟环境的测试;介绍如何对应用进行单元测试,包括单独测试Controller层、service层、持久层。

集成测试和单元测试的区别是,集成测试通常只需要测试最上面一层,因为上层会自动调用下层,所以会测试完整的流程链,流程链中每一个环节都是真实、具体的。单元测试是单独测试流程链中的某一环,这一个环所直接依赖的下游环节使用模拟的方式来提供支撑,这一技术称为Mock。在介绍单元测试的时候,我们会介绍如何mock依赖对象,并简单对mock的原理进行介绍。

本文所关注的另一个主题,是在持久层测试时,如何消除修改数据库的副作用。

集成测试

集成测试是在所有组件都已经开发完成之后,进行组装测试。有两种测试方式:启动web容器进行测试,使用模拟环境测试。这两种测试的效果没有什么差别,只是使用模拟环境测试的话,可以不用启动web容器,从而会少一些开销。另外,两者的测试API会有所不同。

启动web容器进行测试

我们通过测试最上层的Controller来实施集成测试,我们的测试目标如下:

  1. @RestController
  2. public class CityController {
  3. @Autowired
  4. private CityService cityService;
  5. @GetMapping("/cities")
  6. public ResponseEntity<?> getAllCities() {
  7. List<City> cities = cityService.getAllCities();
  8. return ResponseEntity.ok(cities);
  9. }
  10. }

这是一个Controller,它对外提供一个服务/cities,返回一个包含所有城市的列表。这个Controller通过调用下一层的CityService来完成自己的职责。

针对这个Controller的集成测试方案如下:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
  3. public class CityControllerWithRunningServer {
  4. @Autowired
  5. private TestRestTemplate restTemplate;
  6. @Test
  7. public void getAllCitiesTest() {
  8. String response = restTemplate.getForObject("/cities", String.class);
  9. Assertions.assertThat(response).contains("San Francisco");
  10. }
  11. }

首先我们使用@RunWith(SpringRunner.class)声明在Spring的环境中进行单元测试,这样Spring的相关注解才会被识别并起效。然后我们使用@SpringBootTest,它会扫描应用程序的spring配置,并构建完整的Spring Context。我们为其参数webEnvironment赋值为SpringBootTest.WebEnvironment.RANDOM_PORT,这样就会启动web容器,并监听一个随机的端口,同时,为我们自动装配一个TestRestTemplate类型的bean来辅助我们发送请求。

使用模拟环境测试

测试的目标不变,测试的方案如下:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. @AutoConfigureMockMvc
  4. public class CityControllerWithMockEnvironment {
  5. @Autowired
  6. private MockMvc mockMvc;
  7. @Test
  8. public void getAllCities() throws Exception {
  9. mockMvc.perform(MockMvcRequestBuilders.get("/cities"))
  10. .andDo(MockMvcResultHandlers.print())
  11. .andExpect(MockMvcResultMatchers.status().isOk())
  12. .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("San Francisco")));
  13. }
  14. }

我们依然使用@SpringBootTest,但是没有设置其webEnvironment属性,这样依然会构建完整的Spring Context,但是不会再启动web容器。为了进行测试,我们需要使用MockMvc实例发送请求,而我们使用@AutoConfigureMockMvc则是因为这样可以获得自动配置的MockMvc实例。

具体测试的代码中出现很多新的API,对于API细节的研究不在本文计划范围内。

单元测试

上文中描述的两种集成测试的方案,相同的一点是都会构建整个Spring Context。这表示所有声明的bean,而不管声明的方式为何,都会被构建实例,并且都能被依赖。这里隐含的意思是从上到下整条依赖链上的代码都已实现。

Mock技术

在开发的过程中进行测试,无法满足上述的条件,Mock技术可以让我们屏蔽掉下层的依赖,从而专注于当前的测试目标。Mock技术的思想是,当测试目标的下层依赖的行为是可预期的,那么测试目标本身的行为也是可预期的,测试就是把实际的结果和测试目标的预期结果做比较,而Mock就是预先设定下层依赖的行为表现。

Mock的流程

  1. 将测试目标的依赖对象进行mock,设定其预期的行为表现。
  2. 对测试目标进行测试。
  3. 检测测试结果,检查在依赖对象的预期行为下,测试目标的结果是否符合预期。

Mock的使用场景

  1. 多人协作时,可以通过mock进行无等待的测试先行。
  2. 当测试目标的依赖对象需要访问外部的服务,而外部服务不易获得时,可以通过mock来模拟服务可用。
  3. 当在排查不容易复现的问题场景时,通过mock来模拟问题。

测试web层

测试的目标不变,测试的方案如下:

  1. /**
  2. * 不构建整个Spring Context,只构建指定的Controller进行测试。需要对相关的依赖进行mock.<br>
  3. * Created by lijinlong9 on 2018/8/22.
  4. */
  5. @RunWith(SpringRunner.class)
  6. @WebMvcTest(CityController.class)
  7. public class CityControllerWebLayer {
  8. @Autowired
  9. private MockMvc mvc;
  10. @MockBean
  11. private CityService service;
  12. @Test
  13. public void getAllCities() throws Exception {
  14. City city = new City();
  15. city.setId(1L);
  16. city.setName("杭州");
  17. city.setState("浙江");
  18. city.setCountry("中国");
  19. Mockito.when(service.getAllCities()).thenReturn(Collections.singletonList(city));
  20. mvc.perform(MockMvcRequestBuilders.get("/cities"))
  21. .andDo(MockMvcResultHandlers.print())
  22. .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("杭州")));
  23. }
  24. }

这里不再使用@SpringBootTest,而代之以@WebMvcTest,这样只会构建web层或者指定的一到多个Controller的bean。@WebMvcTest同样可以为我们自动配置MockMvc类型的bean,我们可以使用它来模拟发送请求。

@MockBean是一个新接触的注解,它表示对应的bean是一个模拟的bean。因为我们要测试CityController,对其依赖的CityService,我们需要mock其预期的行为表现。在具体的测试方法中,使用Mockito的API对sercive的行为进行mock,它表示当调用service的getAllCities时,会返回预先设定的一个City对象的列表。

之后就是发起请求,并预测结果。

Mockito是Java语言的mock测试框架,spring以自己的方式集成了它。

测试持久层

持久层的测试方案跟具体的持久层技术相关。这里我们介绍基于Mybatis的持久层的测试。

测试目标是:

  1. @Mapper
  2. public interface CityMapper {
  3. City selectCityById(int id);
  4. List<City> selectAllCities();
  5. int insert(City city);
  6. }

测试方案是:

  1. @RunWith(SpringRunner.class)
  2. @MybatisTest
  3. @FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
  4. // @Transactional(propagation = Propagation.NOT_SUPPORTED)
  5. public class CityMapperTest {
  6. @Autowired
  7. private CityMapper cityMapper;
  8. @Test
  9. public void /*selectCityById*/ test1() throws Exception {
  10. City city = cityMapper.selectCityById(1);
  11. Assertions.assertThat(city.getId()).isEqualTo(Long.valueOf(1));
  12. Assertions.assertThat(city.getName()).isEqualTo("San Francisco");
  13. Assertions.assertThat(city.getState()).isEqualTo("CA");
  14. Assertions.assertThat(city.getCountry()).isEqualTo("US");
  15. }
  16. @Test
  17. public void /*insertCity*/ test2() throws Exception {
  18. City city = new City();
  19. city.setId(2L);
  20. city.setName("HangZhou");
  21. city.setState("ZheJiang");
  22. city.setCountry("CN");
  23. int result = cityMapper.insert(city);
  24. Assertions.assertThat(result).isEqualTo(1);
  25. }
  26. @Test
  27. public void /*selectNewInsertedCity*/ test3() throws Exception {
  28. City city = cityMapper.selectCityById(2);
  29. Assertions.assertThat(city).isNull();
  30. }
  31. }

这里使用了@MybatisTest,它负责构建mybatis-mapper层的bean,就像上文中使用的@WebMvcTest负责构建web层的bean一样。值得一提的是@MybatisTest来自于mybatis-spring-boot-starter-test项目,它是mybatis团队根据spring的习惯来实现的。Spring原生支持的两种持久层的测试方案是@DataJpaTest@JdbcTest,分别对应JPA持久化方案和JDBC持久化方案。

@FixMethodOrder来自junit,目的是为了让一个测试类中的多个测试方案按照设定的顺序执行。一般情况下不需要如此,我这里想确认test2方法中插入的数据,在test3中是否还存在,所以需要保证两者的执行顺序。

我们注入了CityMapper,因为其没有更底层的依赖,所以我们不需要进行mock。

@MybatisTest除了实例化mapper相关的bean之外,还会检测依赖中的内嵌数据库,然后测试的时候使用内嵌数据库。如果依赖中没有内嵌数据库,就会失败。当然,使用内嵌数据库是默认的行为,可以使用配置进行修改。

@MybatisTest还会确保每一个测试方法都是事务回滚的,所以在上述的测试用例中,test2插入了数据之后,test3中依然获取不到插入的数据。当然,这也是默认的行为,可以改变。

测试任意的bean

service层并不作为一种特殊的层,所以没有什么注解能表示“只构建service层的bean”这种概念。

这里将介绍另一种通用的测试场景,我要测试的是一个普通的bean,没有什么特殊的角色,比如不是担当特殊处理的controller,也不是负责持久化的dao组件,我们要测试的只是一个普通的bean。

上文中我们使用@SpringBootTest的默认机制,它去查找@SpringBootApplication的配置,据此构建Spring的上下文。查看@SpringBootTest的doc,其中有一句是:

Automatically searches for a @SpringBootConfiguration when nested @Configuration is not used, and no explicit classes are specified.

这表示我们可以通过classes属性来指定Configuration类,或者定义内嵌的Configuration类来改变默认的配置。

在这里我们通过内嵌的Configuration类来实现,先看下测试目标 - CityService:

  1. @Service
  2. public class CityService {
  3. @Autowired
  4. private CityMapper cityMapper;
  5. public List<City> getAllCities() {
  6. return cityMapper.selectAllCities();
  7. }
  8. }

测试方案:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class CityServiceTest {
  4. @Configuration
  5. static class CityServiceConfig {
  6. @Bean
  7. public CityService cityService() {
  8. return new CityService();
  9. }
  10. }
  11. @Autowired
  12. private CityService cityService;
  13. @MockBean
  14. private CityMapper cityMapper;
  15. @Test
  16. public void getAllCities() {
  17. City city = new City();
  18. city.setId(1L);
  19. city.setName("杭州");
  20. city.setState("浙江");
  21. city.setCountry("CN");
  22. Mockito.when(cityMapper.selectAllCities())
  23. .thenReturn(Collections.singletonList(city));
  24. List<City> result = cityService.getAllCities();
  25. Assertions.assertThat(result.size()).isEqualTo(1);
  26. Assertions.assertThat(result.get(0).getName()).isEqualTo("杭州");
  27. }
  28. }

同样的,对于测试目标的依赖,我们需要进行mock。

Mock操作

单元测试中,需要对测试目标的依赖进行mock,这里有必要对mock的细节介绍下。上文单元测试部分已对Mock的逻辑、流程和使用场景进行了介绍,此处专注于实践层面进行说明。

根据方法参数设定预期行为

一般的mock是对方法级别的mock,在方法有入参的情况下,方法的行为可能会跟方法的具体参数值有关。比如一个除法的方法,传入参数4、2得结果2,传入参数8、2得结果4,传入参数2、0得异常。

mock可以针对不同的参数值设定不同的预期,如下所示:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class MathServiceTest {
  4. @Configuration
  5. static class ConfigTest {}
  6. @MockBean
  7. private MathService mathService;
  8. @Test
  9. public void testDivide() {
  10. Mockito.when(mathService.divide(4, 2))
  11. .thenReturn(2);
  12. Mockito.when(mathService.divide(8, 2))
  13. .thenReturn(4);
  14. Mockito.when(mathService.divide(ArgumentMatchers.anyInt(), ArgumentMatchers.eq(0))) // 必须同时用matchers语法
  15. .thenThrow(new RuntimeException("error"));
  16. Assertions.assertThat(mathService.divide(4, 2))
  17. .isEqualTo(2);
  18. Assertions.assertThat(mathService.divide(8, 2))
  19. .isEqualTo(4);
  20. Assertions.assertThatExceptionOfType(RuntimeException.class)
  21. .isThrownBy(() -> {
  22. mathService.divide(3, 0);
  23. })
  24. .withMessageContaining("error");
  25. }
  26. }

上面的测试可能有些奇怪,mock的对象也同时作为测试的目标。这是因为我们的目的在于介绍mock,所以简化了测试流程。

从上述测试用例可以看出,我们除了可以指定具体参数时的行为,也可以指定参数满足一定匹配规则时的行为。

有返回的方法

对于有返回的方法,mock时可以设定的行为有:

返回设定的结果,如:

  1. when(taskService.findResourcePool(any()))
  2. .thenReturn(resourcePool);

直接抛出异常,如:

  1. when(taskService.createTask(any(), any(), any()))
  2. .thenThrow(new RuntimeException("zz"));

实际调用真实的方法,如:

  1. when(taskService.createTask(any(), any(), any()))
  2. .thenCallRealMethod();

注意,调用真实的方法有违mock的本义,应该尽量避免。如果要调用的方法中调用了其他的依赖,需要自行注入其他的依赖,否则会空指针。

无返回的方法

对于无返回的方法,mock时可以设定的行为有:

直接抛出异常,如:

  1. doThrow(new RuntimeException("test"))
  2. .when(taskService).saveToDBAndSubmitToQueue(any());

实际调用(下列为Mockito类的doc中给出的示例,我并没有遇到此需求),如:

  1. doAnswer(new Answer() {
  2. public Object answer(InvocationOnMock invocation) {
  3. Object[] args = invocation.getArguments();
  4. Mock mock = invocation.getMock();
  5. return null;
  6. }})
  7. .when(mock).someMethod();

附录

相关注解的汇总

  • @RunWith:

    junit的注解,通过这个注解使用SpringRunner.class,能够将junit和spring进行集成。后续的spring相关注解才会起效。
  • @SpringBootTest:

    spring的注解,通过扫描应用程序中的配置来构建测试用的Spring上下文。
  • @AutoConfigureMockMvc:

    spring的注解,能够自动配置MockMvc对象实例,用来在模拟测试环境中发送http请求。
  • @WebMvcTest:

    spring的注解,切片测试的一种。使之替换@SpringBootTest能将构建bean的范围限定于web层,但是web层的下层依赖bean,需要通过mock来模拟。也可以通过参数指定只实例化web层的某一个到多个controller。具体可参考Auto-configured Spring MVC Tests
  • @RestClientTest:

    spring的注解,切片测试的一种。如果应用程序作为客户端访问其他Rest服务,可以通过这个注解来测试客户端的功能。具体参考Auto-configured REST Clients
  • @MybatisTest:

    mybatis按照spring的习惯开发的注解,切片测试的一种。使之替换@SpringBootTest,能够将构建bean的返回限定于mybatis-mapper层。具体可参考mybatis-spring-boot-test-autoconfigure
  • @JdbcTest:

    spring的注解,切片测试的一种。如果应用程序中使用Jdbc作为持久层(spring的JdbcTemplate),那么可以使用该注解代替@SpringBootTest,限定bean的构建范围。官方参考资料有限,可自行网上查找资料。
  • @DataJpaTest:

    spring的注解,切片测试的一种。如果使用Jpa作为持久层技术,可以使用这个注解,参考Auto-configured Data JPA Tests
  • @DataRedisTest:

    spring的注解,切片测试的一种。具体内容参考Auto-configured Data Redis Tests

设置测试数据库

给持久层测试类添加注解@AutoConfigureTestDatabase(replace = Replace.NONE)可以使用配置的数据库作为测试数据库。同时,需要在配置文件中配置数据源,如下:

  1. spring:
  2. datasource:
  3. url: jdbc:mysql://127.0.0.1/test
  4. username: root
  5. password: root
  6. driver-class-name: com.mysql.jdbc.Driver

事务不回滚

可以在测试方法上添加@Rollback(false)来设置不回滚,也可以在测试类的级别上添加该注解,表示该类所有的测试方法都不会回滚。

参考

  1. Spring Boot Testing
  2. Spring Boot Test博客
  3. Mybatis Spring Boot Test官方资料

基于spring-boot的应用程序的单元测试方案的更多相关文章

  1. 基于Spring Boot、Spring Cloud、Docker的微服务系统架构实践

    由于最近公司业务需要,需要搭建基于Spring Cloud的微服务系统.遍访各大搜索引擎,发现国内资料少之又少,也难怪,国内Dubbo正统治着天下.但是,一个技术总有它的瓶颈,Dubbo也有它捉襟见肘 ...

  2. step6----->往工程中添加spring boot项目------->修改pom.xml使得我的project是基于spring boot的,而非直接基于spring framework

    文章内容概述: spring项目组其实有多个projects,如spring IO platform用于管理external dependencies的版本,通过定义BOM(bill of mater ...

  3. 基于Spring Boot/Spring Session/Redis的分布式Session共享解决方案

    分布式Web网站一般都会碰到集群session共享问题,之前也做过一些Spring3的项目,当时解决这个问题做过两种方案,一是利用nginx,session交给nginx控制,但是这个需要额外工作较多 ...

  4. 借助Maven入手Spring Boot第一个程序

    目前网上有不少Spring Boot的入门文章,都很有帮助,本人最近在深入学习Spring Cloud,在搭建第一个Hello World程序时,感觉对于新手而言,介绍文章怎么详细都不为过,因为其中坑 ...

  5. 实战基于Spring Boot 2的WebFlux和mLab搭建反应式Web

    Spring Framework 5带来了新的Reactive Stack非阻塞式Web框架:Spring WebFlux.作为与Spring MVC并行使用的Web框架,Spring WebFlux ...

  6. 基于Spring Boot和Spring Cloud实现微服务架构学习

    转载自:http://blog.csdn.net/enweitech/article/details/52582918 看了几周Spring相关框架的书籍和官方demo,是时候开始总结下这中间的学习感 ...

  7. 基于Spring Boot和Spring Cloud实现微服务架构学习--转

    原文地址:http://blog.csdn.net/enweitech/article/details/52582918 看了几周spring相关框架的书籍和官方demo,是时候开始总结下这中间的学习 ...

  8. 基于Spring Boot和Spring Cloud实现微服务架构

    官网的技术导读真的描述的很详细,虽然对于我们看英文很费劲,但如果英文不是很差,请选择沉下心去读,你一定能收获好多.我的学习是先从Spring boot开始的,然后接触到微服务架构,当然,这一切最大的启 ...

  9. .NET CORE与Spring Boot编写控制台程序应有的优雅姿势

    本文分别说明.NET CORE与Spring Boot 编写控制台程序应有的“正确”方法,以便.NET程序员.JAVA程序员可以相互学习与加深了解,注意本文只介绍用法,不会刻意强调哪种语言或哪种框架写 ...

  10. 基于Spring Boot的统一异常处理设计

    基于Spring Boot的统一异常处理设计 作者: Grey 原文地址:https://www.cnblogs.com/greyzeng/p/11733327.html Spring Boot中,支 ...

随机推荐

  1. 20155310 2016-2017-2 《Java程序设计》第六周学习总结

    20155310 2016-2017-2 <Java程序设计>第六周学习总结 教材学习内容总结 4.1 Y86指令集体系结构 •有8个程序寄存器:%eax.%ecx.%edx.%ebx.% ...

  2. 10 Useeful Tips for Writing Effective Bash Scripts in Linux

    1.Always Use Comments in Scripts2.Make a Scripts exit When Fails    Sometimes bash may continue to e ...

  3. Ubuntu下安装arm-linux-gnueabi-xxx编译器【转】

    转自:http://blog.csdn.net/real_myth/article/details/51481639 from: http://www.linuxdiyf.com/linux/1948 ...

  4. jmeter,测登录,要不要过滤掉JS,CSS等请求?感觉过滤掉了压出来的数据就不真实?

    首先,我们来明确下你的性能测试目的,你的目的是服务端的性能还是前端的性能.这两用目的所涉及到的测试场景和工具等方法是不一样的.1.我们先来谈谈服务端的性能.一般的web产品,像css, jpeg等这种 ...

  5. go语言项目汇总

    Horst Rutter edited this page 7 days ago · 529 revisions Indexes and search engines These sites prov ...

  6. 07 go语言

    Home   Alexey Palazhchenko edited this page on 9 Jul · 89 revisions Welcome to the Go wiki, a collec ...

  7. 使用html+css+js实现魔性的舞蹈

    使用html+css+js实现魔性的舞蹈,让我们燥起来!!! 效果图: 代码如下,复制代码即可使用: <!DOCTYPE html> <html > <head> ...

  8. 不同意义的new和delete

    补充说明: new/delete是运算符而非函数,operator new/delete并非是new/delete的重载.事实上,我们无法自定义new/delete的行为: operator new/ ...

  9. 超简单的java爬虫

    最简单的爬虫,不需要设定代理服务器,不需要设定cookie,不需要http连接池,使用httpget方法,只是为了获取html代码... 好吧,满足这个要求的爬虫应该是最基本的爬虫了.当然这也是做复杂 ...

  10. CentOS 7不能联网解决办法

    在使用 Ubuntu 一段时间之后想体验一下 CentOS,就去下载了 CentOS 7 安装到了虚拟机里面,结果发现不能联网,一直提示Cannot find a valid baseurl for ...