Skip to content

Latest commit

 

History

History
1271 lines (1024 loc) · 43.1 KB

单元测试 - 最佳实践.md

File metadata and controls

1271 lines (1024 loc) · 43.1 KB
created_at updated_at slug tags
2022-03-06 05:13:35 -0800
2022-03-06 05:13:35 -0800
java-unit-test-best-practice
单元测试

是不是最佳不知道,反正它是个实践!

预备

测试目标

每记后端是没有单元测试的,此次对其进行添加单元测试的重构。

技术选型及项目配置

主要技术如下

  • 测试平台:JUnit5
  • mock库:MockK
  • 断言库:AssertK
  • 测试覆盖率:Jacoco
  • 数据层测试:embedded-database-spring-test、flyway-spring-test、mybatis-plus-spring-boot-test

gradle配置如下

plugins {
  id 'jacoco'
}

dependencies {
  // 测试:mockk替代mockito,assertk替代assertj;embedded-database
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude module: 'mockito-core'
    exclude module: 'assertj-core'
  }
  testImplementation("com.ninja-squad:springmockk:3.1.0")
  testImplementation('com.willowtreeapps.assertk:assertk-jvm:0.25')
  testImplementation('io.zonky.test:embedded-database-spring-test:2.1.1')
  testImplementation('com.baomidou:mybatis-plus-boot-starter-test:3.5.1')
  testImplementation('org.flywaydb.flyway-test-extensions:flyway-spring-test:7.0.0')
}

// https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.Test.html
// 测试报告位置: build/reports/test
test {
  // 使用JUnit5进行测试
  useJUnitPlatform()
  // 测试线程数:2
  maxParallelForks(2)
  // 日志配置
  testLogging {
    // level=LIFECYCLE的配置项
    events "passed", "skipped", "failed"
    exceptionFormat "full"
  }
}

// 生成覆盖率测试报告: ./gradlew test jacocoTestReport
// 测试报告位置: build/jacocoReport
jacocoTestReport {
  reports {
    xml.enabled = false
    csv.enabled = false
    html.enabled = true
    html.destination = file("${buildDir}/jacocoReport")
  }
}

日志:src/test/resources下添加logback-test.xml文件,这里我们仅在控制台打印WARN级别的应用日志

<?xml version="1.0" encoding="UTF-8"?>

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg %X{THREAD_ID} %n</pattern>
        </encoder>
    </appender>

    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

IDEA:安装JUnit插件(如果是IDEA专业版,默认已安装)。

测试思路 - MVC如何测试

被测项目是采用Spring Web MVC构建的Restful服务,那么MVC应该如何测试呢?

如果我们将每一层的职责区分得比较好,则每层负责的内容和测试思路如下

  • Controller:路由转发、参数验证
    • 重点在参数验证的测试,对Service的调用应该被Mock掉
    • 使用MockMvc
    • 使用@WebMvcTest加载单个Controller类,提升启动速度
  • Service:业务逻辑的处理
    • 常规测试,单元测试的主要工作集中在这里
    • 不使用Spring Test的任何注解,可以提升启动速度
  • Repository:数据库访问
    • 使用嵌入式数据库提供真实等价的数据库环境
    • 使用@MybatisPlusTest只加载数据库相关的Bean,提升启动速度

你写的是单元测试还是集成测试?

使用Spring Boot Test,我们写出来的往往是集成测试。即一个测试中其实包含了Controller、Service、Repository。但真的单元测试,一个测试应该只包含一个方法的一种case。

集成测试的好处是写起来方便快捷,缺点是覆盖不全面,且一旦一个调用链路的任何环节修改,相关的case都必须修改。

接下来看具体的实施情况。

Controller测试

测什么

  • 测试接口路由是否正确
  • 测试参数验证是否符合预期

一个需求

一个典型待测试的接口如下

@RestController
class UserController(
    private val userService: UserService,
    private val subscriptionService: SubscriptionService
) {

  @Authenticate
  @PatchMapping("/users/{id}")
  fun updateUserInfo(
    @PathVariable id: Int,
    @RequestBody request: UserUpdateReq,
  ): R<User> {
    val initiator = RequestContext.getUserId()!!
    return R.succeed(userService.updateUserInfo(id, initiator, request))
  }
  
}
  • @Authenticate表示该接口需要鉴权,具体鉴权逻辑如下

    @Component
    class AuthInterceptor(
      @Autowired val objectMapper: ObjectMapper
    ) : HandlerInterceptor {
    
      override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        // 从头部取X-5E-USER字段值,解析成User对象
        val user = request.getHeader("X-5E-USER")?.let { objectMapper.readValue(it, User::class.java) }
    
        // 判断方法是否被@Authenticate注解,如果被注解了且user为空,则报401错误
        val authorize = (handler as HandlerMethod).beanType.getDeclaredAnnotation(Authenticate::class.java)
        ?: handler.getMethodAnnotation(Authenticate::class.java)
        if (authorize != null && user == null) {
          throw ResponseException(ResErrCode.NEED_AUTHORIZE)
        }
        return true
      }
    
    }
  • UserUpdateReq需要被验证

    @ApiModel("更新用户信息")
    class UserUpdateReq {
    
      @ApiModelProperty("用户名")
      @NotBlank
      var name: String? = null
    
      @ApiModelProperty("头像")
      @NotBlank
      var avatar: String? = null
    
      @ApiModelProperty("用户描述")
      @NotBlank
      var description: String? = null
    }

测试类的构建

测试类的构建,主要考虑以下几个因素

  • 只加载Web配置和待测Controller,其它Bean都不要,我们基于@WebMvcTest构建自己的注解

    @Inherited
    @Documented
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @WebMvcTest
    // WebMvcConfig是本业务中自定义的Web配置
    @Import(WebMvcConfig.class)
    public @interface ControllerTest {
    
        @AliasFor(annotation = WebMvcTest.class)
        String[] properties() default {};
    
        @AliasFor(annotation = WebMvcTest.class)
        Class<?>[] value() default {};
    
        @AliasFor(annotation = WebMvcTest.class)
        Class<?>[] controllers() default {};
    
        @AliasFor(annotation = WebMvcTest.class)
        boolean useDefaultFilters() default true;
    
        @AliasFor(annotation = WebMvcTest.class)
        Filter[] includeFilters() default {};
    
        @AliasFor(annotation = WebMvcTest.class)
        Filter[] excludeFilters() default {};
    
        @AliasFor(annotation = WebMvcTest.class)
        Class<?>[] excludeAutoConfiguration() default {};
    
    }
  • 一个Controller一个测试类,但每个接口都有好几个case,因此需要分组。用内部类+@Nested实现,不过这样的话,一个内部类对应一个接口,可以定义一个接口,管理公共属性

    interface IRequestMapping {
    
      /**
       * 指定本次测试的路径
       */
      val path: String
    
      /**
       * 针对该路径的mock。这样,子类只需要调用mockRequest即可请求,而不需要每个case重新构建填写path、method等重复参数
       */
      fun mockRequest(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl
    
      fun mockRequestWithToken(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
        return mockRequest {
          header(mockAuthHeader.first, mockAuthHeader.second)
          block(this)
        }
      }
    
      fun mockRequestWithEmptyContent(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
        return mockRequest {
          content = "{}"
          block(this)
        }
      }
    
    }
  • 实际上,所有的接口都有两个共同的需求,我们将其写成父类

    • 都需要测试鉴权

      interface AuthenticateTest : IRequestMapping {
        @Test
        fun `401 when missing token`() {
          mockRequestWithEmptyContent().andExpect {
            status {
              isUnauthorized()
            }
          }
        }
      }
    • 都需要验证响应结构

      interface ResponseFormatTest : IRequestMapping {
        @Test
        fun `response format validate`() {
          val mockResult = mockRequestWithEmptyContent().andReturn()
          assertThat(mockResult.response.contentType).isEqualTo(MediaType.APPLICATION_JSON.toString())
          val resNode = publicObjectMapper.readTree(mockResult.response.contentAsString)
          assertThat(publicObjectMapper.convertValue(resNode, R::class.java)).isInstanceOf(R::class)
        }
      }

这样,就构建得到如下测试类

@DisplayName("用户参数验证")
@ControllerTest(UserController::class)
class UserControllerTest {

  @Autowired
  private lateinit var mockMvc: MockMvc

  @MockkBean
  private lateinit var subscriptionService: SubscriptionService

  @MockkBean(relaxed = true)
  private lateinit var userService: UserService

  // 同时需要验证鉴权和响应格式
  @Nested
  @DisplayName("更新用户信息")
  inner class UpdateUserInfo : AuthenticateTest, ResponseFormatTest {

    // 指定接口路径
    override val path: String = "/users/1"

    // 指定接口的method和contentType等公共属性
    override fun mockRequest(block: MockHttpServletRequestDsl.() -> Unit): ResultActionsDsl {
      return mockMvc.patch(path) {
        contentType = MediaType.APPLICATION_JSON
        block(this)
      }
    }

    // 异常case的测试
    @ParameterizedTest(name = "{displayName} {argumentsWithNames}")
    @CsvSource(
      value = [
        "'', '', null",
        "' ', null, 非空"
      ],
      nullValues = ["null"]
    )
    fun `400 when illegal input`(name: String?, avatar: String?, description: String?) {
      mockRequestWithToken {
        val mockBody = UserUpdateReq().apply {
          this.name = name
          this.avatar = avatar
          this.description = description
        }
        content = mockBody.toJsonString()
      }.andExpect {
        status { isBadRequest() }
      }
    }

    // 正常case的测试
    @ParameterizedTest(name = "{displayName} {argumentsWithNames}")
    @CsvSource(
      value = [
        "null, null, null",
        "null, null, 非空"
      ],
      nullValues = ["null"]
    )
    fun `200 when legal input`(name: String?, avatar: String?, description: String?) {
      mockRequestWithToken {
        val mockBody = UserUpdateReq().apply {
          this.name = name
          this.avatar = avatar
          this.description = description
        }
        content = mockBody.toJsonString()
      }.andExpect {
        status { isOk() }
      }
    }

  }
}

关于Json格式的测试

当前项目一个比较特殊的需求:资源保存接口,资源的结构如下,每次保存时需要验证资源的结构是否符合预期。

{
  "resourceId": "1233",
  "resourceType": "record",
  "data": {
    "...": "...",
    "...": "...",
    "...": "...",
  }
}

其中的data结构不固定,多达五六种。服务端将data的验证逻辑写成了json schema,以自定义validator的形式加入Spring中。现在要对这些验证逻辑进行单元测试。

这其中的测试难点及解决方法如下

  • 测试数据量大

    将测试数据写在文件中,通过文件读取各条测试数据,再以参数化形式传入测试case。需要用到JUnit的@ParameterizedTest + @MethodSource达成

  • 测试case多

    我们提取了最常见的case,对它们进行数据构建

    • 预期成功的case

      • 拥有最少合法元素
      • 拥有最多合法元素
      • 可空元素都为null

      以tag为例,可以看到除了数据之外我们还添加了针对该数据的说明:_comment_字段,这在测试结果中会打印出来

      [
        {
          "_comment_": "拥有最少合法元素",
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": "12345678901234567890123456789012",
            "title": "5qCH562+5qCH6aKY",
            "created_time": 1,
            "updated_time": 1,
            "version": 3
          }
        },
        {
          "_comment_": "拥有最多合法元素",
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": "12345678901234567890123456789012",
            "title": "5qCH562+5qCH6aKY",
            "description": "5o+P6L+w",
            "created_time": 1,
            "updated_time": 1,
            "deleted_time": 1,
            "version": 3,
            "usn": 1
          }
        },
        {
          "_comment_": "可空元素都为null",
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": null,
          "data": {
            "id": "12345678901234567890123456789012",
            "title": "5qCH562+5qCH6aKY",
            "description": null,
            "created_time": 1,
            "updated_time": 1,
            "deleted_time": null,
            "version": 3,
            "usn": null
          }
        }
      ]
    • 预期失败的case

      • 必须字段未出现
      • 不可空元素设置为null
      • 数据类型问题:故意将每个元素的数据类型写错
      • 数据格式问题:故意将每个元素的数据格式写错
      • 出现未定义的字段

      同样以tag为例,不同的是这次还多了_errorKeywords_字段,指出了该case的错误信息必须包含的关键字

      [
        {
          "_comment_": "必须字段未出现",
          "_errorKeywords_": [
            "id",
            "title",
            "created_time",
            "updated_time",
            "version"
          ],
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {}
        },
        {
          "_comment_": "字段可空问题: id, title, created_time, updated_time, version",
          "_errorKeywords_": [
            "id",
            "title",
            "created_time",
            "updated_time",
            "version"
          ],
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": null,
            "title": null,
            "description": "5o+P6L+w",
            "created_time": null,
            "updated_time": null,
            "deleted_time": 1,
            "version": null,
            "usn": 1
          }
        },
        {
          "_comment_": "数据类型问题: id、title、description为string;其它为数字",
          "_errorKeywords_": [
            "id",
            "title",
            "description",
            "created_time",
            "updated_time",
            "deleted_time",
            "version",
            "usn"
          ],
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": 1,
            "title": 1,
            "description": 1,
            "created_time": "1",
            "updated_time": "1",
            "deleted_time": "1",
            "version": "3",
            "usn": "1"
          }
        },
        {
          "_comment_": "数据格式问题: id为32位;title、description为base64;version固定为3",
          "_errorKeywords_": [
            "id",
            "title",
            "description",
            "version"
          ],
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": "123456789012345678901234567890",
            "title": "明文标题",
            "description": "明文描述",
            "created_time": 1,
            "updated_time": 1,
            "deleted_time": 1,
            "version": 2,
            "usn": 1
          }
        },
        {
          "_comment_": "出现未定义字段: undefinedField",
          "_errorKeywords_": [
            "undefinedField"
          ],
          "resourceId": "12345678901234567890123456789012",
          "resourceType": "tag",
          "usn": 1,
          "data": {
            "id": "12345678901234567890123456789012",
            "title": "5qCH562+5qCH6aKY",
            "description": "5o+P6L+w",
            "created_time": 1,
            "updated_time": 1,
            "deleted_time": 1,
            "version": 3,
            "usn": 1,
            "undefinedField": ""
          }
        }
      ]

    目前为止所有数据有这么多:

    image-20220305174323051

至于测试方法,我们这样写

@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@MethodSource("com.project5e.app.mylog.controller.mock.ResourceProvider#provideLegalResource")
fun `200 when legal resource`(type: String, comment: String, content: String) {
  every { resourceService.saveV2(any()) } returns listOf()
  mockRequestWithToken {
    this.content = content
  }.andExpect {
    status { isOk() }
  }
}

@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@MethodSource("com.project5e.app.mylog.controller.mock.ResourceProvider#provideIllegalResource")
fun `400 when illegal resource`(type: String, comment: String, errorKeyWords: List<String>, content: String) {
  val mvcResult = mockRequestWithToken {
    this.content = content
  }.andExpect {
    status { isBadRequest() }
  }.andReturn()

  errorKeyWords.forEach { errorKeyWord ->
                         assertThat(mvcResult.response.contentAsString).contains(errorKeyWord)
                        }
}

测试数据读取方法如下

object ResourceProvider {

    private const val RESOURCE_FILE_SUFFIX = "json"
    private const val LEGAL_RESOURCE_PATTERN = "classpath:mock-resource/legal/*.$RESOURCE_FILE_SUFFIX"
    private const val ILLEGAL_RESOURCE_PATTERN = "classpath:mock-resource/illegal/*.$RESOURCE_FILE_SUFFIX"

    private const val COMMENT_KEY = "_comment_"
    private const val ERROR_KEYWORDS_KEY = "_errorKeywords_"

    @JvmStatic
    fun provideLegalResource(): List<Arguments> {
        return PathMatchingResourcePatternResolver().getResources(LEGAL_RESOURCE_PATTERN).map { resource ->
            resource.file.name to publicObjectMapper.readTree(resource.file)
        }.map { (fileName, mockResources) ->
            mockResources.map { mockResource ->
                val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
                val comment = (mockResource as ObjectNode).remove(COMMENT_KEY).asText()
                val content = """{"resources": [${mockResource.toJsonString()}]}"""
                Arguments.of(resourceType, comment, content)
            }
        }.flatten()
    }

    @JvmStatic
    fun provideIllegalResource(): List<Arguments> {
        return PathMatchingResourcePatternResolver().getResources(ILLEGAL_RESOURCE_PATTERN).map { resource ->
            resource.file.name to publicObjectMapper.readTree(resource.file)
        }.map { (fileName, mockResources) ->
            mockResources.map { it as ObjectNode }.map { mockResource ->
                val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
                val comment = mockResource.remove(COMMENT_KEY).asText()
                val errorKeywords = mockResource.remove(ERROR_KEYWORDS_KEY).map { it.asText() }
                val content = """{"resources": [${mockResource.toJsonString()}]}"""
                Arguments.of(resourceType, comment, errorKeywords, content)
            }
        }.flatten()
    }
}

如果运行测试,能够得到如下输出

image-20220305174813410

可以改进的地方

  • controller层测试还可以再抽象:不需要写代码,通过excel或db录入访问路径、预期输入、预期输出,直接生成测试代码生成,更为方便
  • controller层测试或许可以和集成测试合并。但也不尽然,集成测试不一定要在controller这一次层做,以service为入口或许更加方便

Service测试

测什么

  • 测试业务逻辑

一个需求

@Service
class UserService(@Autowired private val userDao: UserDao) {

  @Value("\${user.appid}")
  lateinit var appId: String

  @Value("\${user.fresh.avatar}")
  lateinit var freshUserAvatar: String

  @GrpcClient("user-service")
  lateinit var userStub: UserServiceGrpc.UserServiceBlockingStub

  fun updateUserInfo(id: Int, initiator: Int, request: UserUpdateReq): User {
    if (id != initiator) {
      throw ResponseException(ResErrCode.ILLEGAL_REQUEST, "不可修改他人信息")
    }
    if (!request.isAllFieldNull()) {
      userDao.updateUserInfo(id, request)
    }
    return getUserInfo(id)
  }
}

构建测试类

在Controller测试时,我们采用了@Nested+内部类的方式对接口进行了分组。但是在Service中,往往有很多个方法,每个方法的case也会非常多,如果还采用和Controller一样的方式,测试类会爆炸。因此我们采用类继承的方式进行分组。

父类如下:定义了mock对象和测试对象,以及测试对象中的共享mock属性

@ExtendWith(MockKExtension::class)
open class UserServiceTest {

    @RelaxedMockK
    protected lateinit var userDao: UserDao

    @InjectMockKs
    protected lateinit var userService: UserService

    @BeforeEach
    @Order(Int.MIN_VALUE)
    fun setUp() {
        userService.appId = MOCK_APP_ID
        userService.freshUserAvatar = MOCK_FRESH_USER_AVATAR
        userService.userStub = mockk(relaxed = true)
    }

}

针对需求中的方法的测试类如下,只需要写测试方法即可

@DisplayName("UserService测试 > 更新用户信息")
class UpdateUserInfoTest : UserServiceTest() {

    @Test
    fun `reject when update others info`() {
        val mockUserId = MOCK_USER_ID
        val mockInitiatorId = MOCK_USER_ID + 1
        val mockRequest = UserUpdateReq()

        assertThat { userService.updateUserInfo(mockUserId, mockInitiatorId, mockRequest) }.isFailure()
            .isInstanceOf(ResponseException::class)
            .matchesPredicate { it.errorCode == ResErrCode.ILLEGAL_REQUEST }
    }

    @Test
    fun `success when empty request`() {
        val spyUserService = spyk(userService)
        every { spyUserService.getUserInfo(any()) } returns User()

        spyUserService.updateUserInfo(MOCK_USER_ID, MOCK_USER_ID, UserUpdateReq())

        verify { userDao wasNot called }
    }

    @Test
    fun `success when non-empty request`() {
        val mockUserId = MOCK_USER_ID
        val mockInitiatorId = MOCK_USER_ID
        val mockRequest = UserUpdateReq().apply {
            this.name = "假名,只让它非空"
        }
        val spyUserService = spyk(userService)
        val mockUpdateResult = User()

        every { spyUserService.getUserInfo(any()) } returns mockUpdateResult

        val updateResult = spyUserService.updateUserInfo(mockUserId, mockInitiatorId, mockRequest)

        val idSlot = slot<Int>()
        val requestSlot = slot<UserUpdateReq>()
        verify { userDao.updateUserInfo(capture(idSlot), capture(requestSlot)) }
        assertThat(idSlot.captured).isEqualTo(mockUserId)
        assertThat(requestSlot.captured).isEqualTo(mockRequest)
        assertThat(updateResult).isEqualTo(mockUpdateResult)
    }

}

如何脱离Spring

当Service依赖Bean的注入使用构造器注入时,对Service层的单元测试就能脱离Spring:因为MockK在构建测试对象时,是通过构造器注入的。否则就得一个个手动mock

让代码更易于测试

最易于测试的代码当然是纯函数式,一个确定的输入就能对应一个确定的输出。但理想与现实是有差别的,日常业务中过分追求函数式可能会使得代码晦涩难懂。尽量函数式+mock才是正解。但是副作用和函数内部的级联调用过多,又会造成mock灾难。所以还是一个权衡的过程。

按照个人经验总结一下就是

  • 函数式+适当mock
  • 功能拆解:复杂方法拆分成若干小的方法,分别测试,这样弹性更高

一个例子:如下是重构之前的一个方法(获取用户邀请信息),如此之大,很显然是无法测试的

		fun getInvitationInfo(): InvitationInfoResponseDto {
        val currentUser = RequestContext.currentUser
        // 从库中获取邀请码,没有就生成一个插入库中
        var userModel = userDao.getById(currentUser)
        if (userModel.invitationCode.isNullOrEmpty()) {
            userModel.invitationCode = userDao.generateInvitationCode().toUpperCase()
            userModel.invitedCount = 0
            // 尝试更新,如果提示冲突,说明发生了并发问题,以库中已有为准。不过这能发生的前提是数据表针对invitation_code做了unique限制
            runCatching { userModel.updateById() }.exceptionOrNull()
                ?.takeIf { it.message?.contains("duplicate") == true }
                ?.run { userModel = userDao.getById(currentUser) }
        }

        // 查出一串记录,作为后面的计算依据
        val invitedHistory = invitationHistoryDao.getByInvitee(currentUser)
        val redeemHistory = invitationRedeemHistoryDao.listByRedeemer(currentUser)
        val subscriptionModel = subscriptionService.ktQuery().eq(SubscriptionModel::userId, currentUser).one()

        // 奖品构建:基本信息固定,只需填充兑换状态、是否可用(置灰状态)
        val awards = awardModels.map { InvitationInfoResponseDto.Award().fillWith(it) }.onEach { award ->
            award.redeemed = redeemHistory.any { it.awardId == award.id }
            // 常规case:邀请数够且未被兑换,则高亮。否则置灰
            award.enable = (userModel.invitedCount!! >= award.triggerCount!!) && !award.redeemed!!
        }
        val standardAward = awards.single { it.membershipLevel == MembershipLevel.STANDARD }
        val premiumAward = awards.single { it.membershipLevel == MembershipLevel.PREMIUM }
        // 特殊case1:只要高级有效(要么购买要么兑换),普通奖励就置灰
        if (subscriptionModel?.membershipLevel == MembershipLevel.PREMIUM) {
            standardAward.enable = false
        }

        // 构建完整的邀请信息
        return InvitationInfoResponseDto().apply {
            this.invitationCode = userModel.invitationCode
            this.invitedCount = userModel.invitedCount
            this.hasBeenInvited = invitedHistory != null
            this.awards = awards
        }
    }

如下是重构之后的代码,按功能拆分,不但更易测,可读性也增强了不少。之前的代码不一点点看你一定不知道它在干啥

		fun getInvitationInfo(): InvitationInfoResponseDto {
        val currentUser = RequestContext.currentUser
        val (invitationCode, invitedCount) = getInvitationCodeAndInvitedCount(currentUser)
        val hasBeenInvited = hasBeenInvited(invitationHistoryDao.getByInvitee(currentUser))
        val awards = awardModels.map { InvitationInfoResponseDto.Award().fillWith(it) }
        fillAwardRedeemed(awards, invitationRedeemHistoryDao.listByRedeemer(currentUser))
        fillAwardEnable(awards, currentUser, invitedCount, subscriptionDao.getByUserId(currentUser))

        return InvitationInfoResponseDto().apply {
            this.invitationCode = invitationCode
            this.invitedCount = invitedCount
            this.hasBeenInvited = hasBeenInvited
            this.awards = awards
        }
    }

    fun getInvitationCodeAndInvitedCount(userId: Int): Pair<String, Int> {
        var userModel = userDao.getById(userId)!!
        if (userModel.invitationCode.isNullOrEmpty()) {
            userModel.invitationCode = generateCode()
            userModel.invitedCount = 0
            // 尝试更新,如果提示冲突,说明发生了并发问题,以库中已有为准。前提:invitation_code做了unique限制
            try {
                userDao.updateById(userModel)
            } catch (_: DuplicateKeyException) {
                userModel = userDao.getById(userId)!!
            }
        }
        return userModel.invitationCode!! to userModel.invitedCount!!
    }

    fun generateCode(): String {
        val tryCount = 3
        (0..tryCount).forEach { _ ->
            val candidate = UUID.randomUUID().toString().takeLast(6).toUpperCase()
            if (userDao.getByInvitationCode(candidate) == null) {
                return candidate
            }
        }
        log.error("邀请码生成失败,请查找问题")
        throw ResponseException(ResErrCode.COMM_ERROR)
    }

    fun hasBeenInvited(invitationHistoryModel: InvitationHistoryModel?): Boolean {
        return invitationHistoryModel != null
    }

    fun fillAwardRedeemed(
        awards: List<InvitationInfoResponseDto.Award>, redeemHistory: List<InvitationRedeemHistoryModel>
    ) = awards.forEach { award ->
        award.redeemed = redeemHistory.any { it.awardId == award.id }
    }

    fun fillAwardEnable(
        awards: List<InvitationInfoResponseDto.Award>, userId: Int, invitedCount: Int, subscription: SubscriptionModel?
    ) {
        awards.forEach { award ->
            award.enable = (invitedCount >= award.triggerCount!!) && !award.redeemed!!
        }
        val standardAward = awards.single { it.membershipLevel == MembershipLevel.STANDARD }
        // 特殊case1:只要高级有效(要么购买要么兑换),普通奖励就置灰
        if (subscription?.membershipLevel == MembershipLevel.PREMIUM) {
            standardAward.enable = false
        }
    }

一个经验:在重构一个大方法时,预想拆分成多个子方法,每个方法写成完全无副作用、无级联方法调用,这样做的结果是子方法包含很多高阶方法,可读性相较于前,差了不少。

不要过分纠结BDD风格

BDD风格即所谓的 given - when - then 结构(mock数据 - 执行被测方法 - 断言判断),AssertJ提供BDD风格的断言。但given阶段由Mock库决定,then阶段由断言库决定。这两个库通常还不是一个,所以API很可能组合不出预想的效果。还不如老老实实用every{}进行mock,assertThat()进行断言,约定俗成,言简意赅,大家都懂。

Repository测试

对该层的测试难点及解决方法如下

  • 数据库环境难以搭建

    对于MySQL,还有嵌入式数据库H2可供选择。但对于PostgreSQL,有一个更好的选择:embedded-postres,测试期间启动数据库,支持PG11版本以上,支持run in docker。这是真实的环境,应该说是绝佳选择了。

  • MyBatis Plus写的如何测试

    MyBatsi Plus有提供测试支持,仅加载数据库相关内容。

如下自定义的注解,包含了所有测试必须的内容

@Inherited
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
// mybatis plus的测试支持
@MybatisPlusTest
// 关闭测试完成后的事务回滚(@MybatisPlusTest默认开启,但我们是单元测试单独的环境,测试完成后会被销毁,因此不需要)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// 导入自定义的针对MyBatis的配置,主要是Mapper扫描位置
@Import(MyBatisConfig.class)
// 扫描dao、model等内容
@ComponentScan("com.project5e.app.mylog.repo")
// PG的嵌入式数据库开启
@AutoConfigureEmbeddedDatabase(type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES, refresh = AutoConfigureEmbeddedDatabase.RefreshMode.AFTER_EACH_TEST_METHOD)
// 嵌入式数据库的初始化走Flyway
@FlywayTest
public @interface RepositoryTest {
}

测什么

  • 测试表完整性
  • 测试SQL正确性

关于Flyway

flyway的使用,自行查阅手册,在这里的作用是:embedded-postres在启动本地数据库后,将使用flyway对数据库初始化。所以,src/db/migration下的sql必须组成一个完整的数据表环境。

image-20220305185526827

表完整性测试

包括如下内容

  • 不可空字段检查
  • 可空或具有默认值的字段检查
  • 唯一索引检查

由于每个表都需要进行完整性测试,因此我们写成一个抽象类。子类需要提供预期不允许为空的字段列表、具有默认值字段和默认值映射、唯一索引和用于测试它们的数据。

abstract class RepositoryIntegrityTest<M : BaseMapper<T>, T> {

    /**
     * 非空且无默认值的字段
     */
    abstract val nonNullableWithoutDefaultValueFields: List<KMutableProperty<*>>

    /**
     * 可空或有默认值的字段
     */
    abstract val nullableOrDefaultFields: Map<KMutableProperty<*>, Any?>

    /**
     * 唯一索引列表和测试它们的数据(当存在多个唯一索引时,测试数据不好造,还是交给实现类比较好)
     */
    abstract val uniqueIndexesWithTestData: Map<List<KMutableProperty<*>>, List<T>>

    abstract fun getDao(): ServiceImpl<M, T>

    /**
     * 提供一个所有字段都不为空的基础model,用于测试
     */
    abstract fun createModelWithAllFieldNotNull(): T

    @Test
    fun `validate when field absent (without default value)`() {
        assertAll {
            nonNullableWithoutDefaultValueFields.forEach { prop ->
                // 待测字段填充null
                val mockModel = createModelWithAllFieldNotNull().apply { prop.setter.call(this, null) }
                // 填充null的字段都有应该报错
                val result = Result.runCatching { getDao().save(mockModel) }
                assertThat(result, prop.name).isFailure().matchesPredicate {
                    val nameInDb = StringUtils.camelToUnderline(prop.name)
                    it.message!!.contains("""null value in column "$nameInDb" violates not-null constraint""")
                }
            }
        }
    }

    @Test
    fun `validate when field absent (with default value)`() {
        // 清空数据库
        clearTable()
        // 字段填充null
        val mockModel = createModelWithAllFieldNotNull().apply {
            nullableOrDefaultFields.forEach { (prop, _) ->
                prop.setter.call(this, null)
            }
        }
        // 保存然后查出
        getDao().save(mockModel)
        val insertedModel = getDao().list().first()
        // 所有设置为null的字段查询结果都应该与预期的一致
        assertAll {
            nullableOrDefaultFields.forEach { (prop, expectedValue) ->
                if (expectedValue != null && expectedValue::class == Any::class) {
                    assertThat(prop.getter.call(insertedModel), prop.name).isNotNull()
                } else {
                    assertThat(prop.getter.call(insertedModel), prop.name).isEqualTo(expectedValue)
                }
            }
        }
    }

    @Test
    fun `validate unique constraint`() {
        clearTable()
        // 构建两个拥有一样字段值的对象
        assertAll {
            uniqueIndexesWithTestData.forEach { (index, testModels) ->
                val result = Result.runCatching { getDao().saveBatch(testModels) }
                assertThat(result, index.toString()).isFailure().isInstanceOf(DuplicateKeyException::class)
                    .matchesPredicate {
                        it.message!!.contains("already exists")
                    }
            }
        }
    }

    fun clearTable() {
        getDao().ktUpdate().remove()
    }

}

以表senior_user为例,实现它如下

@RepositoryTest
class SeniorTest : RepositoryIntegrityTest<SeniorUserMapper, SeniorUserModel>() {

    @Autowired
    private lateinit var seniorUserDao: SeniorUserDao

    override val nonNullableWithoutDefaultValueFields: List<KMutableProperty<*>> = listOf(
        SeniorUserModel::id
    )

    override val nullableOrDefaultFields: Map<KMutableProperty<*>, Any?> = mapOf(
        SeniorUserModel::createdTime to Any(),
        SeniorUserModel::updatedTime to Any(),
        SeniorUserModel::redeemed to false
    )

    override val uniqueIndexesWithTestData: Map<List<KMutableProperty<*>>, List<SeniorUserModel>> = mapOf(
        listOf(SeniorUserModel::id) to listOf(createModelWithAllFieldNotNull(), createModelWithAllFieldNotNull())
    )

    override fun getDao(): ServiceImpl<SeniorUserMapper, SeniorUserModel> {
        return seniorUserDao
    }

    final override fun createModelWithAllFieldNotNull(): SeniorUserModel {
        return SeniorUserModel().apply {
            this.id = MOCK_USER_ID.toLong()
            this.createdTime = OffsetDateTime.now()
            this.updatedTime = OffsetDateTime.now()
            this.redeemed = false
        }
    }
}

SQL正确性测试

通常的做法是:构建mock数据,插入数据库 -> 调用被测SQL -> 判断结果

@Test
fun `test update user info partially`() {
  // 清空表
  clearTable()
  // 构建测试数据
  val oldModel = createModelWithAllFieldNotNull()
  userDao.save(oldModel)

  // 调用被测SQL方法
  val mockUserId = MOCK_USER_ID
  val mockUserName = MOCK_USER_NAME + "gt"
  val mockRequest = UserUpdateReq().apply {
    this.name = mockUserName
  }
  userDao.updateUserInfo(mockUserId, mockRequest)
  // 取出数据
  val newModel = userDao.getById(mockUserId)
	// 判断结果
  assertThat(newModel.id).isEqualTo(mockUserId)
  assertThat(newModel.name).isEqualTo(mockRequest.name)
  assertThat(newModel.avatar).isEqualTo(oldModel.avatar)
  assertThat(newModel.description).isEqualTo(oldModel.description)
}

被测SQL如下(当然它也不一定是个SQL,也可能是基于底层库构建的对数据库的操作,比如这里)

fun updateUserInfo(id: Int, request: UserUpdateReq): Boolean {
  return UserModel(
    id = id,
    name = request.name,
    avatar = request.avatar,
    description = request.description,
  ).run { updateById() }
}

MyBatis Plus的SQL写在哪里

MyBatis Plus提供了类public class ServiceImpl<M extends BaseMapper<T>, T> implements IService<T>,提供三个能力

  • 快捷方法,如save、list、getById等
  • 提供写SQL的DSL能力
  • 提供baseMapper,对mapper直接调用

如此方便,以至于很多业务将Service类继承了它,省去了定义DAO的麻烦。个人认为,这样做在能力1和能力3的使用上都没有问题,但利用DSL构建SQL的能力对Service层造成了入侵,至少它不是容易测试的代码:

  • 如果构建的SQL DSL和业务代码混在一起,则根本无法测试
  • 如果将其提成一个单独的方法,那为什么不专门定义一个DAO来负责呢?能力1和能力3调用的方便性没有变差,能力2也得到了的有效管控

所以我的看法是:应该将ServiceImpl锁定在DAO里,不要侵入到业务代码。

测试锁

如下SQL如何测试,测试重点在于FOR UPDATE

fun lockUser(userId: Int) {
  ktQuery()
  .eq(UserModel::id, userId)
  .last("FOR UPDATE")
  .list()
}

分析难点及解决方案

  • 需要在两个并发的事物中分别调用才能测试

    可以利用线程+手动管理事务+延迟

  • 如何验证是否成功

    使用SELECT XXXX FOR UPDATE NOWAIT语句,检测到锁冲突时马上报错

于是代码可以如下

// 事务管理器,直接注入即可
@Autowired
private lateinit var tm: DataSourceTransactionManager

@Test
fun `test lock user`() {
  // 注意latch的使用
  val latch = CountDownLatch(2)

  val transaction1 = Runnable {
    // 注意编程式事务的使用方式
    val transactionStatus = tm.getTransaction(TransactionDefinition.withDefaults())
    userDao.lockUser(MOCK_USER_ID)
    TimeUnit.SECONDS.sleep(2)
    tm.commit(transactionStatus)
    latch.countDown()
  }
  val transaction2 = Runnable {
    TimeUnit.SECONDS.sleep(1)
    val transactionStatus = tm.getTransaction(TransactionDefinition.withDefaults())
    try {
      assertThat {
        userDao.ktQuery().eq(UserModel::id, MOCK_USER_ID).last("FOR UPDATE NOWAIT").list()
      }.isFailure().isInstanceOf(CannotAcquireLockException::class)
    } finally {
      tm.commit(transactionStatus)
      latch.countDown()
    }
  }

  Thread(transaction1).start()
  Thread(transaction2).start()
  latch.await()
}

问题记录

  • 使用@WebMvcTest单独测试Controller时,报错:无法找到xxxMapper

    原因:默认情况下,@WebMvcTest会将SpringBootApplication注解的那个启动类当做配置主类,而之前将@MapperScan写在了上面。

    解决:将@MapperScan转移到单独的配置文件中。同时也提醒:配置不要乱放

  • logback-test.xml中指定的日志等级不生效

    原因:如果在src/main/resources/application.properties中指定了logging.root.level,在配置文件中的等级就不会生效

    解决:移除logging.root.level

    要彻底解决,可以参考这篇文章

  • 配置问题:Controller测试过程中发现一些针对Web的配置未生效

    原因:配置类未被@WebMvcTest加载

    解决:手动导入自定义的Web配置

  • Kotlin兼容性问题:@NotBlank对写在构造方法中的Kotlin属性不生效

    原因:验证库和Kotlin的兼容性问题

    解决:使用@field:NotBlank或者将属性从构造方法移到类体

  • MockK无法调用、mock私有方法

    解决:MockK提供这样的功能,但使用起来不是很方便。所以这个问题尚未有较好的解决方式

总结

我花了两周去了解单元测试常用技术,细读JUnit用户手册,浏览了Spring Boot Test手册(但是忘记看Spring Test手册,是说用的时候觉得哪里不对,😓),去了解了如何正确地写单元测试。又花了两周对每记后端项目进行TDD重构,先后尝试了Mockito、AssertJ和MockK、AssertK,发现用Kotlin时,MockK比Mockito香得不是一点点(Mockito写得像Java,mock静态方法时还得打补丁,对Kotlin DSL的补丁看起来并不特别好;MockK就不一样了,功能全面,使用优雅)。

在补单元测试时,还修复了一些之前没注意也没测出来的bug,所谓矫枉过正,现在的我觉得没有单元测试的代码,是非常不可靠的。

本周开发新功能时,同步添加单元测试。两点感受颇深

  • 写起来确实放心不少。写测试的过程中,已经细细品过很多边缘case了。也省去了本地启动服务一遍遍手动测试的麻烦。
  • 是真的挺花费时间。扪心自问,如果进度要求再急一点,这单元测试可能写不下去,还得后面补。所以,要给自己预留充足的时间。

回看刚写的这些单测,还是有很多问题的

  • case数量多,当前有将近300个case(算上条件测试)。根据80%的bug出在20%代码中的定律,可以依靠经验识别容易出问题的地方,集中测试,其它地方,相对可以放松警惕。这样能够节省写单测时间
  • 测试速度慢,CI机上跑完所有case需要2:30左右。速度有待优化
  • 测试代码质量有待加强

而就整个测试工作来说,要做的也还有很多

  • 集成测试:目前没有集成测试,所以各层的协作还是靠手动测试
  • CI+接口测试自动化:当前CI负责从构建、单元测试、发布应用的过程;还可以更加自动化:发布使用金丝雀,检测应用发布完成后对所有接口进行冒烟测试,测试通过再替换实际应用,否则就只发布金丝雀,从而最大程度降低发布风险。
  • 推广单元测试:单测值得推广吗?肯定是值得的。但说实话,让我去写table应用的单元测试,我也会心生抗拒,毕竟case太多了。如果克服这些问题,让大家认真写测试,是个课题。