使用TestNG进行单元测试

单元测试指的是对软件系统中的最小可测单元进行测试和验证。在面向对象的程序设计中,一个类中最小可执行单元通常是作为一个函数或者方法,单元测试要尽可能的对所有的方法覆盖,对预期结果和实际结果进行验证。在这里首先要感谢我现在所在公司给与的培训,感谢同事的分享让我更加明白单元测试的必要性。

非单元测试

在开始单元测试前,我们先来看看不属于单元测试的内容

新建一个SpringBoot模板工程,可以看到在工程结构中src源代码目录下有两个模块,一个是main模块用来编写和存放业务代码,另一个是test模块,用来对业务功能进行测试,在test模块的文件层级下可以看到SpringBoot已经默认为我们初始化了一个测试模板:

1
2
3
4
5
6
7
8
9
@RunWith(SpringRunner::class)
@SpringBootTest
class BootApplicationTests {

@Test
fun contextLoads() {
}

}

@RunWith(SpringRunner::class)@SpringBootTest注解将会在测试运行时加载和启动Spring的上下文测试环境。

网上很多文章写的单元测试都是用这个注解,在了解单元测试之前我看过很多篇文章的单元测试都是这么写的,使我对单元测试造成了一定的误解,其实这个是集成测试的部分,是对整个系统的测试,不是单元测试!

单元测试

下面开始进行单元测试的内容,我使用Kotlin+Gradle的方式创建了一个Spring Boot的项目

在Java开发中单元测试的工具有很多比如Junit,这里我使用的是TestNG,他继承了Junit的特性并且简洁对代码无污染,且功能强大。

引入TestNG相关的依赖包

1
2
3
4
// testng依赖
testCompile group: 'org.testng', name: 'testng', version: '6.14.3'
// 引入kotlin的mock相关依赖,才能在kotlin的工程代码中使用mock()方法进行对象mock()
testCompile group: 'com.nhaarman', name: 'mockito-kotlin', version: '1.6.0'

比如在UserController中有个用户登录的login方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RestController
@RequestMapping("/user")
class UserController {

val logger = LoggerFactory.getLogger(UserController::class.java)!!

@Autowired
lateinit var i18NService: I18NService

@Autowired
lateinit var userService: MerUserService

@PostMapping("login")
fun login(@RequestBody userLoginRequest: UserLoginRequest): ApiResponse<CoMerUserResponse> {

logger.info("登录信息" + JSON.toJSONString(userLoginRequest))
try {
val md5Pwd = CryptoUtil.getMD5String(userLoginRequest.password)
val user = userService.getUser(userLoginRequest.username, md5Pwd)

if (user != null) {
// 用户被删除或禁用 || 用户所属的商户被删除或禁用
if (user.userDelete == 1 || user.userAvalible == 0 || user.merDeleted == 1 || user.merAvalible == 0) {
logger.info("账号异常:" + JSON.toJSONString(user))
return ApiResponse.fail(ERROR_CODE, i18NService.getI18N("mer.coupon.accountError"))
}
return ApiResponse.succ(user)

} else {
return ApiResponse.fail(ERROR_CODE, i18NService.getI18N("mer.coupon.userError"))
}

} catch (e: Exception) {
logger.error(e.printStackTrace().toString())
return ApiResponse.fail(ERROR_CODE, i18NService.getI18N("mer.coupon.loginError"))
}
}
}

生成单元测试代码

我们要对UserController的login方法进行单元测试,我使用的开发工具是IDEA,在Mac上command+shift+t, 如果是Windows则按controller+shift+t,选择Create New Test,选择要创建单元测试的方法进行快速生成。
勾选 SetUp/@BeforetearDown/@After,生成如下单元测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.testng.annotations.Test

import org.testng.Assert.*
import org.testng.annotations.AfterMethod
import org.testng.annotations.BeforeMethod

class UserControllerTest {

@BeforeMethod
fun setUp() {
// 测试前
}

@Test
fun testLogin() {
// 测试中,测试的对应方法
}

@AfterMethod
fun tearDown() {
// 测试后
}
}

由于我们只对UserController类的login方法进行单元测试,该方法所依赖的Service等对象的方法和行为我们都需要进行Mock。这里暂且不讨论代码写的如何,也不必关心login方法中所调用的Service层的具体实现,login只需要关系自己的逻辑即可。

在login方法中调用了userService.getUser来获取一个用户对象,通过判断用户是否存在进行下一步的逻辑验证,如果出现错误需要调用i18NService.getI18N这个方法用来向用户返回对应的提示信息。这两处调用属于外部依赖的,无论实际业务代码是否实现这个功能,我们都不必关心,只需要Mock模拟这写对象的调用行为即可。login只对自身方法的逻辑进行验证。

需要Mock的数据有:

1
2
3
4
userService.getUser(userLoginRequest.username, md5Pwd)
i18NService.getI18N("mer.coupon.accountError")
i18NService.getI18N("mer.coupon.userError")
i18NService.getI18N("mer.coupon.loginError")

使用when()进行Mock数据

比如userService.getUser()这个方法将会根据传入的参数去查询用户是否存在,如果存在则返回对象。这里不必关心具体的用户数据,只需要关心login()自身的逻辑,所以我们对这个方法进行Mock即可,比如:

1
`when`(userService.getUser(username, password)).thenReturn(response)

thenReturn(response)中的response是我们自己构造的对象,when().thenReturn()在实际单元测试时,将会模拟when()中的代码行为,并且通过thenReturn()返回调用该方法的返回值。这个返回值可以在单元测试中进行模拟。

单元测试一般要覆盖各种边界值,比如这里的login()业务示例代码中,需要根据用户当前的启用和删除状态,以及所属商家的启用和删除状态进行登录限制。我们要模拟这些不同的数据进行输入,并且将输出的结果和预期的结果进行对比验证。

测试数据

测试数据这里用了一个Excel文件进行存放。对用户和所属商户的启用禁用的各种情况进行模拟不同的用户数据,第一行表头部分作为后续单元测试读取的键名称,expected为这条数据登录后应该返回的期望状态,单元测试程序需要对比期望状态和实际调用login方法所返回的状态,如果相同则表明测试通过,如果不同则表明login()方法有逻辑问题。

测试数据中包含了实际的业务场景,用户和所关联的商户必须都为未删除状态,且都必须是启用状态用户才能成功登陆。

编写单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.lanshiqin.boot

import com.nhaarman.mockito_kotlin.mock
import org.mockito.Mockito.`when`
import org.springframework.core.io.ClassPathResource
import org.testng.Assert.assertEquals
import org.testng.annotations.BeforeMethod
import org.testng.annotations.DataProvider
import org.testng.annotations.Test

class UserControllerTest {


private var userController: UserController? = null
private var userService: MerUserService? = null
private var i18NService: I18NService? = null

@BeforeMethod
fun setUp() {
userController = UserController()
userService = mock()
i18NService = mock()
userController!!.userService = userService as MerUserService
userController!!.i18NService = i18NService as I18NService
}


@DataProvider(name = "loginDataInfo")
fun loginDataInfo(): Array<Array<Any>> {
// 加载用户登录测试数据,包含各种边界值
return ExcelUtil().testData(ClassPathResource("loginDataInfo.xls").file.path)
}
// 登录
@Test(dataProvider = "loginDataInfo")
fun testLogin(data: HashMap<String, String>) {
// 行为mock,当调用i18NService!!.getI18N("mer.coupon.accountError")方法时将会返回 “账号异常”
`when`(i18NService!!.getI18N("mer.coupon.accountError")).thenReturn("账号异常")
`when`(i18NService!!.getI18N("mer.coupon.userError")).thenReturn("用户名或密码错误")
`when`(i18NService!!.getI18N("mer.coupon.loginError")).thenReturn("登录失败")

// 构造测试数据,用户登录信息
val request = UserLoginRequest()
request.username = data["reqUserName"].toString()
request.password = data["reqPassWord"].toString()

// 构造测试数据
val response = CoMerUserResponse()
response.userName = data["userName"].toString()
val password = data["password"].toString()
response.userDelete = data["userDelete"]!!.toInt()
response.merDeleted = data["merDeleted"]!!.toInt()
response.userAvalible = data["userAvalible"]!!.toInt()
response.merAvalible = data["merAvalible"]!!.toInt()
// Mock userService.getUser(),返回测试对象
`when`(userService!!.getUser(request.username!!, password)).thenReturn(response)
// 调用userController.login()方法
val result = userController!!.login(request)
// 验证预期返回值和实际返回值
assertEquals(data["expected"].toString(),result.message)

}

}

@DataProvider(name="loginDataInfo")所标注的方法表示该方法是一个数据提供者,指定名称为loginDataInfo@Test(dataProvider="loginDataInfo")所标注的单元测试方法表示,该方法执行单元测试前将会先执行数据提供者loginDataInfo所标注的方法,将数据提供者方法返回的数据作为本单元测试的入参。

注:单元测试代码中的when使用引号引起来是因为单元测试的when方法和kotlin的关键字when冲突了,所以必须加上单引号

执行单元测试

根据测试数据中的边界值执行单元测试,每条数据所返回的结果与预期结果相同,测试通过!

后续如果不小心修改了UserController.login()中的方法,比如不小心将业务代码中23行的的user.userDelete == 1改成了 user.userDelete == 0,这时候再运行一遍单元测试就会发现有一部分单元测试就无法通过了,通过预期值和实际值可以很容易的发现问题。

在实际业务场景中,一个方法可能调用了多个外部方法,并且根据外部方法不同的返回值执行不同的逻辑。每个方法应该只关注自身的逻辑,所调用的外部方法以及返回值我们要通过Mock进行构造,并且应该尽可能的覆盖所有的边界情况,在特别复杂的业务代码中,单元测试显得至关重要。写完测试后,即使后续动到了核心的业务代码,只需要运行一遍单元测试就能判断逻辑是否有问题,而不是通过一遍一遍的断点调试,做重复的工作。
0%