单元测试教程


一、SpringBootTest和Spock基础知识

1.1 SpringBootTest注解说明

@SpringBootTest

用于构建测试环境上下文,默认情况下,SpringBootTest不会构建一个web环境,可以使用webEnvironment参数指定想要构建的测试环境。可选值如下:

  • MOCK(默认):构建一个虚拟的web环境

  • RANDOM_PORT:使用随机的端口号构建一个真实的web环境

  • DEFINED_PORT:根据配置文件指定的端口号构建一个web环境

  • NONE:构建一个非web环境

我们测试的时候希望模拟一个真实的web环境,选择了RANDOM_PORT

@Import

用于导入配置类

@TestConfiguration

标注是用于单元测试的配置类 > spring test会缓存上下文,如果不同的测试类使用的是相同的上下文,那么上下文只会加载一次。所以,如果使用了@Import注解导入配置类,那么每个测试类都应该导入了相同的配置类,保证使用同一个上下文。

更多内容,请学习spring boot test

1.2 spock基础知识

Spock框架是基于Groovy语言的测试框架,Groovy与Java具备良好的互操作性,因此可以在Spring Boot项目中使用该框架写优雅、高效以及DSL化的测试用例。因为基于Groovy, 使得Spock 可以更容易地写出表达能力更强的测试用例。又因为它内置了Junit Runner, 所以Spock兼容大部分的IDE,测试工具,和持续集成服务器。

Specification

Specification类包含了很多编写用于单元测试的方法。编写测试代码时,我们先创建一个测试类,继承自Specification。

  1. class CiControllerSpec extends Specification {
  2. // fields
  3. // fixture methods
  4. // feature methods
  5. // helper methods
  6. }

定义变量

  1. def obj = new ClassUnderSpecification()

有时需要在测试方法之间共享一个对象。例如,创建对象的成本可能非常高,或者您可能希望测试方法相互交互。此时,可以使用@Shared标注变量,并且建议在创建的同时赋值。

  1. @Shared organizationId = 1

测试方法

  1. def "pushing an element on the stack"() {
  2. // blocks go here
  3. }

模板方法:

  • def setup() {} :每个测试方法运行前的启动方法
  • def cleanup() {} : 每个测试方法运行后的清理方法
  • def setupSpec() {} : 第一个测试方法运行前的启动方法,设置每个测试类的环境
  • def cleanupSpec() {} : 最后一个测试方法运行后的清理方法,清理每个测试类的环境

代码块

  • given: 构造测试需要的数据
  • when: 触发行为,比如调用指定方法或函数
  • then: 做出断言表达式
  • expect: 期望的行为,when-then的精简版
  • cleanup: 测试结束时清除构造的数据
  • where: 以表格的形式提供测试数据集合

    相关注解

  • @Shared:多个测试方法中共享数据

  • @Stepwise: 让测试方法按照声明的顺序执行,当前面的测试方法失败后,将不会执行后面的测试方法

  • @Ignore: 忽略测试方法

  • @IgnoreRest:只测试这个方法,而忽略所有其他方法

  • @Timeout: 设置测试方法的超时时间,默认单位为秒

  • @Requires:根据条件执行

  • @Retry:测试方法执行失败后重试

  • @Unroll:展开数据管道的测试用例,分别显示每个测试用例的测试情况。即将输入的一组测试数据拆分为单独的测试用例来执行。

  • @Subject:标记当前测试的目标类

更多内容,请学习spock-docs

二、H2DataBase

H2 是一款使用Java语言编写的嵌入式数据库,具有速度块、占用内存小、支持标准的SQL语法, JDBC API等特点。非常适合用在单元测试等数据不需要保存的场景。

2.1. 运行方式

内存模式

数据库只在内存中运行,关闭连接后数据库将被清空,适合测试环境。

连接语法:

  1. jdbc:h2:mem:DBName;DB_CLOSE_DELAY=-1

如果不指定DBName,则以私有方式启动,只允许一个连接

内嵌式

数据库持久化存储为单个文件

连接语法:

  1. jdbc:h2:[file:][<path>]<databaseName>

服务式

H2支持三种服务模式 - web server:此种运行方式支持使用浏览器访问H2 Console - TCP server:支持客户端/服务器端的连接方式 - PG server:支持PostgreSQL客户端

TCP/IP模式连接语法:

  1. jdbc:h2:tcp://<server>[:<port>]/[<path>]<databaseName>

更多内容,请参考database_url

2.2 连接参数说明

  • DB_CLOSE_DELAY:要求最后一个正在连接的连接断开后,不要关闭数据库
  • MODE=MySQL:兼容模式,H2兼容多种数据库,该值可以为:DB2、Derby、HSQLDB、MSSQLServer、MySQL、Oracle、PostgreSQL
  • AUTO_RECONNECT=TRUE:连接丢失后自动重新连接
  • AUTO_SERVER=TRUE:启动自动混合模式,允许开启多个连接,该参数不支持在内存中运行模式
  • TRACE_LEVEL_SYSTEM_OUT、TRACE_LEVEL_FILE:输出跟踪日志到控制台或文件, 取值0为OFF,1为ERROR(默认值),2为INFO,3为DEBUG
  • SET TRACE_MAX_FILE_SIZE mb:设置跟踪日志文件的大小,默认为16M

2.3 语法参考

2.4 应用程序配置

spring-boot项目中可以使用如下配置,启动h2内存数据库。

  1. spring:
  2. datasource:
  3. password: sa
  4. url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=Mysql;TRACE_LEVEL_SYSTEM_OUT=1;
  5. username: sa

url相关参数说明:

  • jdbc:h2:mem:testdb: 创建一个名为testdb的内存数据库
  • DB_CLOSE_DELAY=-1: 程序结束后销毁数据库
  • MODE=Mysql: 使用mysql兼容模式
  • TRACE_LEVEL_SYSTEM_OUT=1: 过多的日志信息会降低运行速度,所以这里设置日志级别为error

    2.5 数据初始化

因为项目中使用liquibase + groovy来初始化数据。 在测试中也可以使用同样的方式初始化数据,只需要在测试代码中进行如下配置即可:

application.yaml文件中添加初始化数据路径

  1. # liquibase初始化配置
  2. data:
  3. # 选择初始化资源目录
  4. dir: src/main/resources

初始化脚本目录结构:

单元测试教程 - 图1

  • groovy: groovy目录下存放表结构定义
  • init-data:init-data目录下存放表数据。这里主要初始化一些全局需要的基础数据,对于特定测试任务的初始化数据应该在测试代码中初始化,并且在测试任务执行完成后清理。

在测试程序中添加以下代码即可使用liquibase工具初始化数据库。

  1. @Import(LiquibaseConfig)
  2. class IntegrationTestConfiguration {
  3. @Autowired
  4. LiquibaseExecutor liquibaseExecutor
  5. @PostConstruct
  6. void init() {
  7. // 初始化数据库
  8. liquibaseExecutor.execute()
  9. }
  10. }

因为测试使用的是内存数据库h2和mysql、oracle等数据库语法不是完全相同,所以数据初始化可能会失败。如果初始化失败,需要把groovy文件拷贝到test资源目录中,并且修改对应的groovy文件,用以匹配h2语法。

修改后的groovy目录:

单元测试教程 - 图2

对应的application.yaml需要同步调整

  1. # liquibase初始化配置
  2. data:
  3. # 选择初始化资源目录(使用test资源目录)
  4. dir: src/test/resources

比如, 以下情况会导致初始化失败: 1. mysql中使用VARCHAR BINARY来表示区分大小写字符串,但是在h2中VARCHAR区分大小写,不区分大小使用VARCHAR_IGNORECASE 2. h2中索引名数据库唯一,如果在mysql中A表和B表都使用了’index-1’这个索引,对应groovy在初始化h2数据库时会失败。

三、单元测试

3.1 什么是单元测试

单元测试概念定义如下:

  1. 单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

测试范围:

  • 单元测试:针对单个函数的测试,关注其内部逻辑,mock所有需要的服务。单元测试带来优秀的代码质量、良好的异常处理、优雅的错误报告

  • 服务测试:验证两个或多个模块之间的交互

  • 端到端测试:端到端测试会覆盖整个系统,这类测试通常需要打开一个浏览器来操作图形用户界面,这种类型的测试会覆盖大范围的产品代码

3.2 为什么要编写单元测试

编写单元测试会占用我们编码中很大一部分时间,导致延长软件的开发周期。那我们为什么要编写单元测试呢,单元测试的优点是什么?

单元测试的优点:

  • 单元测试对我们的产品质量是非常重要的。
  • 单元测试是所有测试中最底层的一类测试,是第一个环节,也是最重要的一个环节,是唯一一次有保证能够代码覆盖率达到100%的测试,是整个软件测试过程的基础和前提,单元测试防止了开发的后期因bug过多而失控,单元测试的性价比是最好的。
  • 据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点
  • 代码规范、优化,可测试性的代码
  • 作为重构的基础,验证重构是否可靠
  • 应用于自动化构建或持续集成流程,对每次代码修改做回归测试

四、编写单元测试

测试目标

编写单元测试时,我们遵循分层测试的原则,为controller、service、infra层单独编写测试用例,即我们只关注被测方法的业务逻辑,mock使用到的外部服务。这样优点是可以降低测试代码的复杂度、提高代码质量。

  • controller: controller层主要测试参数合法性校验逻辑是否正确
  • servrice: service层则主要测试方法的内部逻辑,对于使用到的其他服务以及Mapper可以使用mock对象。
  • infra: infra层主要测试核心的sql语句以及工具类

4.1 添加pom依赖和插件

  • 引入maven依赖:
依赖作用
org.springframework.boot:spring-boot-starter-testspring-boot-test启动包
org.spockframework:spock-corespcok核心依赖包
org.spockframework:spock-springspring整合spock依赖包
org.codehaus.groovy:groovyspock需要的groovy依赖包
com.h2database:h2测试使用的内存数据库
io.choerodon:choerodon-liquibase数据初始化工具
  • 添加插件:
  • gmavenplus-plugin:groovy编译插件
  • maven-surefire-plugins:maven测试插件
  1. <dependencies>
  2. [...]
  3. <!--单元测试-->
  4. <dependency>
  5. <groupId>org.springframework.boot</groupId>
  6. <artifactId>spring-boot-starter-test</artifactId>
  7. <scope>test</scope>
  8. </dependency>
  9. <dependency>
  10. <groupId>org.spockframework</groupId>
  11. <artifactId>spock-core</artifactId>
  12. <version>1.1-groovy-2.4-rc-2</version>
  13. <scope>test</scope>
  14. </dependency>
  15. <dependency>
  16. <groupId>org.spockframework</groupId>
  17. <artifactId>spock-spring</artifactId>
  18. <version>1.1-groovy-2.4-rc-3</version>
  19. <scope>test</scope>
  20. </dependency>
  21. <dependency>
  22. <groupId>org.codehaus.groovy</groupId>
  23. <artifactId>groovy</artifactId>
  24. </dependency>
  25. <!--测试数据库-->
  26. <dependency>
  27. <groupId>com.h2database</groupId>
  28. <artifactId>h2</artifactId>
  29. <version>1.4.197</version>
  30. <scope>test</scope>
  31. </dependency>
  32. <!--初始化数据库表-->
  33. <dependency>
  34. <groupId>io.choerodon</groupId>
  35. <artifactId>choerodon-liquibase</artifactId>
  36. <version>0.16.1-SNAPSHOT</version>
  37. <scope>test</scope>
  38. </dependency>
  39. [...]
  40. </dependencies>
  41. ...
  42. <build>
  43. <plugins>
  44. [...]
  45. <!--使用spock测试框架需要添加groovy编译插件-->
  46. <plugin>
  47. <groupId>org.codehaus.gmavenplus</groupId>
  48. <artifactId>gmavenplus-plugin</artifactId>
  49. <version>1.9.0</version>
  50. <executions>
  51. <execution>
  52. <goals>
  53. <goal>compileTests</goal>
  54. </goals>
  55. </execution>
  56. </executions>
  57. </plugin>
  58. <!--maven测试插件-->
  59. <plugin>
  60. <groupId>org.apache.maven.plugins</groupId>
  61. <artifactId>maven-surefire-plugin</artifactId>
  62. <configuration>
  63. <!-- 自定义测试匹配规则,测试有Test和Spec后缀的类-->
  64. <includes>
  65. <include>**/*Test.java</include>
  66. <include>**/*Spec.java</include>
  67. </includes>
  68. </configuration>
  69. </plugin>
  70. [...]
  71. </plugins>
  72. </build>
  73. ...

4.2 项目准备

  • 单元测试目录结构

单元测试教程 - 图3

  • application.yaml
  1. spring:
  2. datasource:
  3. password: sa
  4. url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=Mysql;TRACE_LEVEL_SYSTEM_OUT=1;
  5. username: sa
  6. autoconfigure:
  7. exclude:
  8. - org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration # 关闭LiquibaseAutoConfiguration
  9. - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration # 关闭KafkaAutoConfiguration的自动化配置
  10. h2:
  11. console:
  12. enabled: true
  13. cloud:
  14. service-registry:
  15. auto-registration:
  16. enabled: false # 关闭自动注册
  17. eureka:
  18. client:
  19. enabled: false # 关闭eureka
  20. mybatis:
  21. mapperLocations: classpath*:/mapper/*.xml
  22. configuration:
  23. mapUnderscoreToCamelCase: true
  24. # liquibase初始化配置
  25. data:
  26. # 选择初始化资源目录
  27. dir: src/main/resources
  28. # 日志级别设置
  29. logging:
  30. level:
  31. root: error

上面的是基础的应用配置,针对不同的项目环境,可以进行相应调整。

4.3 编写测试代码

测试controller时,我们需要对服务器进行REST调用测试。 添加@SpringBootTest(webEnvironment = RANDOM_PORT)注解后,Spring上下文中会配置一个可用的TestRestTemplate实例,它会解析到正在运行的服务器的相对链接,所以我们只需要使用@Autowired注入一个TestRestTemplate即可,在使用对应api的时候,url地址填Endpoint的相对地址即可。

下面我们以例子的形式来讲解如何编写单元测试。

示例1:第一个单元测试demo

源码: UserController

  1. @RestController
  2. @RequestMapping(value = "/v1/users")
  3. public class UserController {
  4. private UserService userService;
  5. public UserController(UserService userService) {
  6. this.userService = userService;
  7. }
  8. @PostMapping
  9. @Permission(level = ResourceLevel.SITE, permissionLogin = true)
  10. @ApiOperation(value = "创建todo用户")
  11. private ResponseEntity<UserDTO> create(@RequestBody UserDTO userDTO) {
  12. return new ResponseEntity<>(userService.createOne(userDTO), HttpStatus.OK);
  13. }
  14. }

测试代码:

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserController)
  4. class UserControllerSpec extends Specification {
  5. def BASE_URL = "/v1/users"
  6. @Autowired
  7. TestRestTemplate testRestTemplate;
  8. @Autowired
  9. UserController userController
  10. UserService userService = Mock()
  11. def setup() {
  12. ReflectionTestUtils.setField(userController, "userService", userService)
  13. }
  14. def "create"() {
  15. given: "构造参数"
  16. def userDto = new UserDTO()
  17. userDto.setEmail("test@hand.com")
  18. userDto.setEmployeeName("lisi")
  19. userDto.setEmployeeNumber("001")
  20. when: "新建用户"
  21. def entity = testRestTemplate.postForEntity(BASE_URL, userDto, UserDTO.class)
  22. then: "校验参数"
  23. entity.statusCode.is2xxSuccessful()
  24. 1 * userService.createOne(_)
  25. }
  26. }

因为TestRestTemplate会解析服务器的相对地址,所以调用接口时,url我们直接填相对地址”/v1/users”即可

  • @Import(IntegrationTestConfiguration)

我们在构建上下文的时候,我们希望自定义bean,进行一些初始化操作(比如,初始化数据库,为请求头添加jwt_token)。可以通过@import注解,导入一个标注@TestConfiguration注解的测试配置类实现。

  1. @TestConfiguration
  2. @Import(LiquibaseConfig)
  3. class IntegrationTestConfiguration {
  4. // spock提供的可以在外部配置类中mock对象的工厂类
  5. private final detachedMockFactory = new DetachedMockFactory()
  6. @Value('${choerodon.oauth.jwt.key:hzero}')
  7. String key
  8. @Autowired
  9. LiquibaseExecutor liquibaseExecutor
  10. @Autowired
  11. TestRestTemplate testRestTemplate
  12. ObjectMapper objectMapper = new ObjectMapper()
  13. @PostConstruct
  14. void init() {
  15. // 初始化数据库
  16. liquibaseExecutor.execute()
  17. // 给请求头添加jwt_token
  18. setTestRestTemplateJWT()
  19. }
  20. /**
  21. * 请求头添加jwt_token
  22. */
  23. private void setTestRestTemplateJWT() {
  24. testRestTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory())
  25. testRestTemplate.getRestTemplate().setInterceptors([new ClientHttpRequestInterceptor() {
  26. @Override
  27. ClientHttpResponse intercept(HttpRequest httpRequest, byte[] bytes, ClientHttpRequestExecution clientHttpRequestExecution) throws IOException {
  28. httpRequest.getHeaders()
  29. .add('Jwt_token', createJWT(key, objectMapper))
  30. return clientHttpRequestExecution.execute(httpRequest, bytes)
  31. }
  32. }])
  33. }
  34. String createJWT(final String key, final ObjectMapper objectMapper) {
  35. Signer signer = new MacSigner(key)
  36. CustomUserDetails details = new CustomUserDetails('default', 'unknown', Collections.emptyList())
  37. details.setUserId(1L);
  38. details.setLanguage("zh_CN");
  39. details.setTimeZone("GMT+8");
  40. details.setEmail("hand@hand-china.com");
  41. details.setOrganizationId(1L);
  42. try {
  43. return 'Bearer ' + JwtHelper.encode(objectMapper.writeValueAsString(details), signer).getEncoded()
  44. } catch (IOException e) {
  45. throw new CommonException(e)
  46. }
  47. }
  48. }

4.4 Mock

在测试过程中,对于一些不容易构造、测试的对象,通过创建一个mock对象来模拟对象的行为。比如,A服务需要调用B服务,但是在单元测试中,我们并没有部署B服务。那么就可以构造一个mock对象,并且返回期望的结果。

关于Stub和Mock:

  • Stub: Stub可以模拟一个方法,阻断对原有方法的调用。

  • Mock: Mock包含了Stub的功能,不仅可以模拟方法,阻断对原有方法的调用。它还期望你必须调用这个方法多少次。可以把Stub理解为Mock的子集。

示例1:Stub - 测试方法返回值

在spock中使用Stub可以构造一个虚拟对象,并且模拟对象的返回值

  1. subscriber.receive(_) >> "ok"
  2. | | | |
  3. | | | 返回值
  4. | | 参数
  5. | 目标方法
  6. 目标对象

源码:

UserServiceImpl

  1. @Service
  2. public class UserServiceImpl implements UserService {
  3. private UserMapper userMapper;
  4. private DevopsFeignClient devopsFeignClient;
  5. public UserServiceImpl(UserMapper userMapper, DevopsFeignClient devopsFeignClient) {
  6. this.userMapper = userMapper;
  7. }
  8. @Override
  9. public Boolean checkEmailExist(String email) {
  10. // 校验email在gitlab中是否已经使用
  11. ResponseEntity<Boolean> responseEntity = devopsFeignClient.checkGitlabEmail(email);
  12. return responseEntity.getBody();
  13. }
  14. }

测试类:

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserServiceImpl)
  4. class UserServiceImplSpec extends Specification {
  5. @Autowired
  6. UserService userService
  7. DevopsFeignClient devopsFeignClient = Stub()
  8. void setup() {
  9. ReflectionTestUtils.setField(userService, "devopsFeignClient", devopsFeignClient)
  10. }
  11. /**
  12. * 测试方法返回值
  13. * @return
  14. */
  15. def "checkEmailExistInGitlab"() {
  16. given: "构造参数"
  17. def email = "test@hand.com"
  18. ResponseEntity<Boolean> responseEntity = new ResponseEntity<>(true, HttpStatus.OK)
  19. devopsFeignClient.checkGitlabEmail(_ as String) >> responseEntity
  20. when: "校验邮箱是否被使用"
  21. def result = userService.checkEmailExistInGitlab(email)
  22. then: "校验结果 - 邮箱已使用"
  23. result == true
  24. }
  25. }

我们首先创建DevopsFeignClient的虚拟对象,然后在setup()方法中将虚拟对象通过反射的方式设置到待测试类中。

在given代码块中,我们通过devopsFeignClient.checkGitlabEmail(_ as String) >> responseEntity给虚拟对象的方法模拟返回值。

在when代码块中,我们通过testRestTemplate调用要测试的接口。

在then代码块中,我们校验接口的返回值是否符合我们的预期。

示例2: Stub - 测试连续调用时返回不同的值

我有有时希望在连续调用中,返回不同的值可以使用下面的语法

  1. subscriber.receive(_) >>> ["ok", "error", "error", "ok"]

测试类:

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserServiceImpl)
  4. class UserServiceImplSpec extends Specification {
  5. @Autowired
  6. UserService userService
  7. DevopsFeignClient devopsFeignClient = Stub()
  8. void setup() {
  9. ReflectionTestUtils.setField(userService, "devopsFeignClient", devopsFeignClient)
  10. }
  11. /**
  12. * 测试连续调用时返回不同的值
  13. * @return
  14. */
  15. def "checkEmailExistInGitlab2"() {
  16. given: "构造参数"
  17. def email = "test@hand.com"
  18. ResponseEntity<Boolean> responseEntity1 = new ResponseEntity<>(true, HttpStatus.OK)
  19. ResponseEntity<Boolean> responseEntity2 = new ResponseEntity<>(false, HttpStatus.OK)
  20. devopsFeignClient.checkGitlabEmail(_ as String) >>> [responseEntity1, responseEntity2]
  21. when: "校验邮箱是否被使用"
  22. def result1 = userService.checkEmailExistInGitlab(email)
  23. then: "校验结果 - 邮箱已使用"
  24. result1 == true
  25. when: "校验邮箱是否被使用"
  26. def result2 = userService.checkEmailExistInGitlab(email)
  27. then: "校验结果 - 邮箱未使用"
  28. result2 == false
  29. }
  30. }

上面的测试中,我们使用devopsFeignClient.checkGitlabEmail(_ as String) >>> [responseEntity1, responseEntity2]模拟返回值,第一次调用时返回responseEntity1,第二次调用时返回responseEntity2。然后我们分别在两个when-then代码块中对不同的返回结果进行校验。

示例3: Mock - 测试方法调用次数

使用Mock构建的虚拟对象,我们可以判断方法在测试中执行的次数,这可以帮助我们判断代码逻辑是否按照预期执行。

  1. 1 * userMapper.selectByPrimaryKey(_)
  2. | | | |
  3. | | | 输入参数,“_”匹配任意
  4. | | 方法
  5. | 目标对象
  6. 调用次数

源码:

UserServiceImpl

  1. @Service
  2. public class UserServiceImpl implements UserService {
  3. private UserMapper userMapper;
  4. private DevopsFeignClient devopsFeignClient;
  5. public UserServiceImpl(UserMapper userMapper, DevopsFeignClient devopsFeignClient) {
  6. this.userMapper = userMapper;
  7. }
  8. @Override
  9. public UserDTO queryById(Long id) {
  10. return userMapper.selectByPrimaryKey(id);
  11. }
  12. }

测试类;

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserServiceImpl)
  4. class UserServiceImplSpec extends Specification {
  5. @Autowired
  6. UserService userService
  7. UserMapper userMapper = Mock()
  8. void setup() {
  9. ReflectionTestUtils.setField(userService, "userMapper", userMapper)
  10. }
  11. def "queryById"() {
  12. given: "构造参数"
  13. def userId = 1L
  14. when: "根据id查询用户"
  15. userService.queryById(userId)
  16. then: "校验结果"
  17. 1 * userMapper.selectByPrimaryKey(_)
  18. }
  19. }

根据源码,我们知道调用queryById接口的时候,userMapper.selectByPrimaryKey(id)方法一定会执行一次。 在then代码块中,我们使用1 * userMapper.selectByPrimaryKey(_)校验该方法会执行一次

示例4:Mock - 测试方法调用次数以及返回值

使用Mock构建的虚拟对象,我们不仅可以判断方法在测试中执行的次数,还可以模拟方法的返回值。

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserServiceImpl)
  4. class UserServiceImplSpec extends Specification {
  5. @Autowired
  6. UserService userService
  7. UserMapper userMapper = Mock()
  8. void setup() {
  9. ReflectionTestUtils.setField(userService, "userMapper", userMapper)
  10. }
  11. /**
  12. * 测试返回值以及调用次数
  13. * @return
  14. */
  15. def "queryById2"() {
  16. given: "构造参数"
  17. def userId = 1L
  18. def user = new UserDTO()
  19. user.setEmail("test@hand.com")
  20. user.setEmployeeName("lisi")
  21. user.setEmployeeNumber("001")
  22. user.setId(1)
  23. when: "根据id查询用户"
  24. def userDTO = userService.queryById(userId)
  25. then: "校验结果"
  26. 1 * userMapper.selectByPrimaryKey(_) >> user
  27. userDTO == user
  28. }
  29. }

当同时需要校验调用次数和模拟返回值时,需要将声明放在then块中才能生效。

上面的例子中, userMapper.selectByPrimaryKey(_)执行一次,同时返回given中构造的user对象。

4.5 其他示例

示例1:where代码块的使用

where代码块可以用来准备多组测试数据

源码:

  1. public final class HumpToUnderlineUtil {
  2. private static final String UNDERLINE = "_";
  3. /**
  4. * 驼峰转下划线格式
  5. *
  6. * @param camelCase 驼峰格式字符串
  7. * @return 下划线格式字符串
  8. */
  9. public static String toUnderLine(String camelCase) {
  10. if (StringUtils.isEmpty(camelCase)) {
  11. return camelCase;
  12. }
  13. StringBuilder builder = new StringBuilder(camelCase);
  14. int upperCaseCharNumber = 0;
  15. for (int i = 0; i < camelCase.length(); i++) {
  16. if (Character.isUpperCase(camelCase.charAt(i))) {
  17. builder.insert(i + upperCaseCharNumber, UNDERLINE);
  18. // 删除大写字母
  19. builder.deleteCharAt(i + upperCaseCharNumber + 1);
  20. // 插入小写字母
  21. builder.insert(i + upperCaseCharNumber + 1, Character.toLowerCase(camelCase.charAt(i)));
  22. upperCaseCharNumber += 1;
  23. }
  24. }
  25. return builder.toString();
  26. }
  27. }

测试类1:Data Tables

Data Tables以表格的形式方便的构造多组测试数据

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. class HumpToUnderlineUtilSpec extends Specification {
  4. def "ToUnderLine1"() {
  5. expect: "测试驼峰转下划线"
  6. HumpToUnderlineUtil.toUnderLine(camelCase) == underline
  7. where: ""
  8. camelCase | underline
  9. "" | ""
  10. "ciPipeline" | "ci_pipeline"
  11. "cdPipeline" | "cd_pipeline"
  12. }
  13. }

测试类2:Data Pipes

与Data Tables功能一致,Data Pipes以管道的形式构造多组测试数据

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. class HumpToUnderlineUtilSpec extends Specification {
  4. @Unroll
  5. def "ToUnderLine"() {
  6. expect: "测试驼峰转下划线"
  7. HumpToUnderlineUtil.toUnderLine(camelCase) == underline
  8. where:
  9. camelCase << ["", null, "ciPipeline", "cdPipeline"]
  10. underline << ["", null, "ci_pipeline", "cd_pipeline"]
  11. }
  12. }

示例2:thrown()与notThrown()的使用

  • thrown: 在测试中,对于测试中一定会出现的异常我们可以通过thrown(Exception)来捕获,并且校验异常实例。
  • notThrown: 与thrown相反,如果我们明确知道某个方法不应该抛出某种异常,可以使用notThrown()来校验。

源码:

  1. @Service
  2. public class UserServiceImpl implements UserService {
  3. private UserMapper userMapper;
  4. private DevopsFeignClient devopsFeignClient;
  5. public UserServiceImpl(UserMapper userMapper, DevopsFeignClient devopsFeignClient) {
  6. this.userMapper = userMapper;
  7. }
  8. @Override
  9. public UserDTO createOne(UserDTO userDTO) {
  10. if (userMapper.insert(userDTO) != 1) {
  11. throw new CommonException("error.user.insert");
  12. }
  13. return userDTO;
  14. }
  15. }

测试类:

  1. @SpringBootTest(webEnvironment = RANDOM_PORT)
  2. @Import(IntegrationTestConfiguration)
  3. @Subject(UserServiceImpl)
  4. class UserServiceImplSpec extends Specification {
  5. @Autowired
  6. UserService userService
  7. UserMapper userMapper = Mock()
  8. void setup() {
  9. ReflectionTestUtils.setField(userService, "userMapper", userMapper)
  10. }
  11. def "createOne"() {
  12. given: "构造参数"
  13. def userDto = new UserDTO()
  14. userDto.setEmail("test@hand.com")
  15. userDto.setEmployeeName("lisi")
  16. userDto.setEmployeeNumber("001")
  17. when: "新建用户 - 创建成功"
  18. userService.createOne(userDto)
  19. then: "校验结果"
  20. 1 * userMapper.insert(_) >> 1
  21. // 使用notThrown校验,是否没有出现异常
  22. notThrown(CommonException)
  23. when: "新建用户 - 创建失败"
  24. userService.createOne(userDto)
  25. then: "校验结果"
  26. 1 * userMapper.insert(_) >> 0
  27. // 使用thrown校验,是否出现指定异常
  28. def e = thrown(Exception)
  29. e instanceof CommonException
  30. e.getMessage() == "error.user.insert"
  31. }
  32. }

根据源码可以看出,userMapper.insert(_)返回值为1时,程序不会抛出CommonException,如果为0,则相反。我们分别验证两种情况,如果测试用例执行时,与预期结果不同则测试用例执行失败。

五、获取代码覆盖率

代码覆盖率通过 sonar + jacoco获取。只需要在持续集成工具中添加对应配置即可。比如使用gitlab-ci,在gitlab-ci.yaml中定义sonar任务,执行下面的命令,即可扫描代码质量以及单元测试覆盖率。

  1. mvn --batch-mode clean org.jacoco:jacoco-maven-plugin:prepare-agent verify sonar:sonar -Dsonar.host.url=${SONAR_URL} -Dsonar.login=${SONAR_LOGIN} -Dsonar.gitlab.project_id=$CI_PROJECT_PATH -Dsonar.gitlab.commit_sha=$CI_COMMIT_REF_NAME -Dsonar.gitlab.ref_name=$CI_COMMIT_REF_NAME -Dsonar.analysis.serviceGroup=$GROUP_NAME -Dsonar.analysis.commitId=$CI_COMMIT_SHA -Dsonar.projectKey=${GROUP_NAME}:${PROJECT_NAME} -Dmaven.test.failure.ignore=true -DskipTests=false

此时在sonar代码质量页面能够看到覆盖率情况 单元测试教程 - 图4