0 前言
说起单元测试,每个开发人员都很熟悉,但并未得到大家的重视。很多开发人员认为单测属于可有可无,意义不大。或者有时间就写,没时间就算了的情况。甚至认为:“反正有测试同学帮忙把控代码质量,为什么还要开发浪费时间写单测呢?难道不是重复工作么?”这个问题其实很有代表性,很多开发有这个想法,就算他们写了单测,可能也只是敷衍了事或者随意发挥。
这里解释一下前言中的几个关键点:
- 职责的不同。开发人员的职责是在保证质量的前提下完成一个功能的开发,而测试人员的职责是为产品的质量把关,保证项目交付的质量。也就是说保证代码质量是开发这一环节的工作职责之一,在测试阶段发现了代码里的过多缺陷,就说明了一些场景未考虑全面。
- 成本的不同。对于同一个缺陷,在越早期发现,修复的成本就越小,这个道理想必大家都知道。如果很多的质量问题,都需要到测试阶段才能发现,才来返工修复,这对整体项目的时间和资源来说绝对是不小的浪费。
- 角度的不同。对于开发人员来说,写单测更多的是针对单个方法的逻辑的测试,而对于测试人员来说,更多的是针对功能的黑盒测试,而单测能够覆盖到某些特殊场景。
1 什么是单元测试
在面向对象语言里,一个方法到一个类,都可以是一个单元,它取决于我们的测试意图。
在Google官方文档中,将测试分为三级,最底层的属于小型测试即单元测试,本文以单测简称之,第二层属于中型测试即集成测试,第三层属于大型测试即UI测试,每一层的比例约为小型测试占 70%,中型测试占 20%,大型测试占 10%。
- 小型测试:针对单个函数的测试,关注其内部逻辑,mock所有需要的服务。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告。
- 中型测试:验证两个或多个制定的模块应用之间的交互。
- 大型测试:也被称为“系统测试”或“端到端测试”。大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。
2 为什么要做单元测试
你是不是也有这些疑问?
-
单测浪费了太多的时间
- 单测仅仅是证明这些代码做了什么
- 我是很棒的程序员,我是不是可以不进行单测?
- 后面的集成测试将会抓住所有的bug
- 单测的成本效率不高我把测试都写了,那么测试人员做什么呢?
- 公司请我来是写代码,而不是写测试
- 测试代码的正确性,并不是我的工作
- 只是 Bug 少了一点
据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单测的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点。
上面那张图,来自微软的统计数据:bug在单测阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。
下面这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。
那么单测的作用到底是什么?意义究竟如何体现?
- 单测可以很好保证代码质量,一个好的单测能够覆盖各种业务场景,在这个前提下该目标单元都能够验证通过,说明该单元是足够健壮的。
- 单测可以一定程度提高代码合理性,当我们发现给一个方法写单测非常困难,比如需要覆盖的业务场景过多,那说明此方法可以在一定程度上继续进行拆分;又比如需要mock的内容过多,那说明此方法违背了单一责任原则,处理了太多的逻辑,那么需要重新设计等等。
- 单测能够有效防止回溯问题的出现,提升回归测试的质量,随着功能的不停增加,全量回归性价比会越来越低,测试人员仅会对主流程进行测试,对于那些不在测试范围内的边界功能,单测能够进行覆盖从而提升回归测试的质量。
- 通过单测快速熟悉代码,单测不仅起到了测试的作用,还是一种很好的“文档”,通过单测代码,我们不需要深入的阅读代码,便能知道这段代码做什么工作,有哪些特殊情况需要考虑,包含哪些业务。
小结:对于单测,我们不搞虚的,希望能实实在在为项目质量保驾护航。
3 针对哪些代码做单元测试
3.1 单元测试动机
- 针对逻辑复杂、核心的业务去写单测
- 针对痛点、易错的代码去写单测
- 针对不易理解的代码去写单测
- 针对改动较多的部分去写单测
3.2 单元测试代码对象
单测是指对软件中的最小可测试单元进行检查和验证,要以类功能作为测试目标的单个或者一连串的函数测试,也就是说,单测可以是对某个类的具体函数的功能、内部逻辑进行验证。
而针对代码复杂性和依赖性,有如下图的原则描述可参考:
- 第一象限复杂依赖多:重构减少依赖,变成复杂依赖少,然后写单测
- 第二象限复杂依赖少:适合写单测
- 第三象限简单依赖少:看情况写
- 第四象限简单依赖多:不用重构,不用单测
这里对第四象限多提一嘴,对它们写单测意义并不大,不要为了提高单测覆盖率,而花费很多时间和精力去写单测,这样得不偿失。
3.3 单元测试什么时候写
比较好的节奏是:每个功能Sprint的开发周期,单测与具体实现代码同时进行。
4 具体怎么做单元测试
4.1 单元测试成长过程
首先,单测成长的过程大致可分为如下4个阶段:
- 会写,全员写,不要求写好。
- 写好,有效。关注可测性问题,试点解决
- 可测试性提升。识别可测性问题,熟练使用重构方法进行重构;识别代码架构设计问题;case与业务代码同步编写
- TDD。但这个目标是期望,不能作为必须实现的目标。
4.2 一个简单的测试例子
我们代码中经常会有日期工具类,下面是对获取有效日期的测试例子。
(1)获取有效日期源码
object DateUtils {
fun getValidDate(milliseconds: Long): Long {
var validDate = milliseconds
val timeInMillis = Calendar.getInstance(Locale.US).timeInMillis
if (milliseconds <= 0L) {
validDate = timeInMillis
}
return validDate
}
}
(2)单元测试代码
我们对边界情况(日期小于0、等于0),和正常情况(日期大于0)分别进行测试,单测代码如下:
@Test
fun testGetValidData() {
// 1、模拟前提
val time1 = System.currentTimeMillis()
// 2、执行语句
val validDate1 = DateUtils.getValidDate(-1)
// 3、断言结果
Assert.assertEquals(time1, validDate1)
Assert.assertEquals(System.currentTimeMillis(), DateUtils.getValidDate(0))
val time2 = System.currentTimeMillis() - 100000
Assert.assertEquals(time2, DateUtils.getValidDate(time2))
}
当传入日期小于0或等于0时,获取到的有效日期为当前时间戳;
当传入日期大于0时,获取到的有效日期为传入日期。
注:为简化Demo,这里我们假定,程序多次获取当前时间戳是一样的。
(3)单元测试三段式
上面是很经典的测试写法,也有很多人称这种写法为“三段式”,“三段式”包括模拟前提、执行语句、断言结果。
上面测试例子中,time1 和 time2的创建就是模拟前提,例子中最后一行把执行语句和断言合在了一起,分解开来的话就是:
val validDate2 = DateUtils.getValidDate(time2)
Assert.assertEquals(time2, validDate2)
4.3 再来看一个栗子
终端应用中,通常会存在页面跳转逻辑,通过传入不同的参数类型,跳转不同页面,下面我们来一起看看。
(1)页面跳转逻辑源码
object PushManager {
@JvmStatic
fun npcHandlePush(
context: Context,
skip: Skip
) {
try {
val skipType = skip.skipType
val skipData = skip.skipData
when (skipType) {
NotificationType.MAP_DETAIL_TYPE.type -> {
val skipMapOrPropData =
GsonUtils.fromJson(skipData, SkipMapOrPropData::class.java)
skipMapOrPropData?.let { skipMapOrPropData ->
DetailLaunchUtil.toUgcMapDetail(
context = context,
mapId = skipMapOrPropData.mapId,
topCommentId = skipMapOrPropData.commentId
)
}
}
NotificationType.PROP_DETAIL_TYPE.type -> {
val skipMapOrPropData =
GsonUtils.fromJson(skipData, SkipMapOrPropData::class.java)
skipMapOrPropData?.let { skipMapOrPropData ->
PropDetailActivity.toUgcPropDetail(
context,
mapId = skipMapOrPropData.mapId,
topCommentId = skipMapOrPropData.commentId
)
}
}
// ......
// ......
NotificationType.PROP_OR_CLOTH_COLLECTION_TYPE.type -> {
val skipDataPropOrClothCollection =
GsonUtils.fromJson(skipData, SkipDataPropOrClothCollection::class.java)
skipDataPropOrClothCollection?.collectionId?.let { collectionId ->
CollectionsDetailActivity.launch(context, collectionId)
}
}
else -> {
}
}
} catch (e: Exception) {
val intent = Intent(context, MainActivity::class.java)
context.startActivity(intent)
}
}
}
(2)代码重构
object PushManager {
const val KEY_TOP_COMMENT_ID = "topCommentId"
@JvmStatic
fun npcHandlePush(
context: Context,
skip: Skip
) {
try {
val skipType = skip.skipType
val skipData = skip.skipData
when (skipType) {
NotificationType.MAP_DETAIL_TYPE.type -> {
goToUgcMapDetail(context, skipData)
}
NotificationType.PROP_DETAIL_TYPE.type -> {
val skipMapOrPropData =
GsonUtils.fromJson(skipData, SkipMapOrPropData::class.java)
skipMapOrPropData?.let { skipMapOrPropData ->
PropDetailActivity.toUgcPropDetail(
context,
mapId = skipMapOrPropData.mapId,
topCommentId = skipMapOrPropData.commentId
)
}
}
// ......
// ......
} catch (e: Exception) {
val intent = Intent(context, MainActivity::class.java)
context.startActivity(intent)
}
}
fun goToUgcMapDetail(context: Context, skipData: String) {
val skipMapOrPropData =
GsonUtils.fromJson(skipData, SkipMapOrPropData::class.java)
skipMapOrPropData?.let { skipMapOrPropData ->
DetailLaunchUtil.toUgcMapDetail(
context = context,
mapId = skipMapOrPropData.mapId,
topCommentId = skipMapOrPropData.commentId
)
}
}
}
此处我们只关注skipType的输入,会导致什么样的输出,所以对具体实现细节我们可以封装起来,这也是软件设计中最少知道原则的使用。
(3)单元测试代码
@PrepareForTest(PushManager::class)
class PushManagerTest : BaseTestRobolectricAndPowerMockClass() {
@Test
fun shouldExecCommonDetailActivity_whenTypeIs1() {
val skip = Skip(1, GsonUtils.toJson(PushManager.SkipMapOrPropData("123", "456")))
Mockito.doNothing().`when`(PushManager.goToUgcMapDetail(mContext, skip.skipData))
PushManager.npcHandlePush(mContext, skip)
Mockito.verify(PushManager).goToUgcMapDetail(mContext, skip.skipData)
}
}
可以看到,我们依旧使用的是单元测试三段式:首先,模拟输入,构造skip对象,并且mock真实的goToUgcMapDetail方法调用,做到逻辑隔离与单一验证;然后,调用npcHandlePush方法,根据模拟的输入,应该会调用goToUgcMapDetail方法;最后,验证goToUgcMapDetail方法是否执行,即可。
5 怎么体现单元测试的关键成果
- bug类指标(间接指标):连续迭代的bug总数趋势、迭代内新建bug的趋势、千行bug率
- 单测的需求覆盖度(50%以上),参与人员覆盖度(80%以上)
- 单测case总数趋势,代码行增量趋势
- 增量代码的行覆盖率(接入层80%,客户端30%)
- 单函数圈复杂度(低于40),单函数代码行数(低于80),扫描告警数
其实,单测的关键成果,并没那么好衡量,需要团队根据自身情况去做出选择。
【附】
1 什么时候适合mock?
如果一个对象具有以下特征,比较适合使用mock对象:
- 该对象提供非确定的结果(比如当前的时间或者当前的温度)
- 对象的某些状态难以创建或者重现(比如网络错误或者文件读写错误)
- 对象方法上的执行太慢(比如在测试开始之前初始化数据库)
- 该对象还不存在或者其行为可能发生变化(比如测试驱动开发中驱动创建新的类)
- 该对象必须包含一些专门为测试准备的数据或者方法(后者不适用于静态类型的语言,流行的Mock框架不能为对象添加新的方法。Stub是可以的。)
因此,不要滥用mock(stub),当被测方法中调用其他方法函数,第一反应应该走进去串起来,而不是从根部就mock掉了。