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专业版,默认已安装)。
被测项目是采用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都必须修改。
接下来看具体的实施情况。
- 测试接口路由是否正确
- 测试参数验证是否符合预期
一个典型待测试的接口如下
@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() }
}
}
}
}
当前项目一个比较特殊的需求:资源保存接口,资源的结构如下,每次保存时需要验证资源的结构是否符合预期。
{
"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": "" } } ]
目前为止所有数据有这么多:
-
至于测试方法,我们这样写
@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()
}
}
如果运行测试,能够得到如下输出
- controller层测试还可以再抽象:不需要写代码,通过excel或db录入访问路径、预期输入、预期输出,直接生成测试代码生成,更为方便
- controller层测试或许可以和集成测试合并。但也不尽然,集成测试不一定要在controller这一次层做,以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风格即所谓的 given - when - then 结构(mock数据 - 执行被测方法 - 断言判断),AssertJ提供BDD风格的断言。但given阶段由Mock库决定,then阶段由断言库决定。这两个库通常还不是一个,所以API很可能组合不出预想的效果。还不如老老实实用every{}进行mock,assertThat()进行断言,约定俗成,言简意赅,大家都懂。
对该层的测试难点及解决方法如下
-
数据库环境难以搭建
对于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的使用,自行查阅手册,在这里的作用是:embedded-postres在启动本地数据库后,将使用flyway对数据库初始化。所以,src/db/migration下的sql必须组成一个完整的数据表环境。
包括如下内容
- 不可空字段检查
- 可空或具有默认值的字段检查
- 唯一索引检查
由于每个表都需要进行完整性测试,因此我们写成一个抽象类。子类需要提供预期不允许为空的字段列表、具有默认值字段和默认值映射、唯一索引和用于测试它们的数据。
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
}
}
}
通常的做法是:构建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提供了类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太多了。如果克服这些问题,让大家认真写测试,是个课题。