说起单元测试,每个开发人员都很熟悉,但并未得到大家的重视。很多开发人员认为单测属于可有可无,意义不大。或者有时间就写,没时间就算了的情况。甚至认为:“反正有测试同学帮忙把控代码质量,为什么还要开发浪费时间写单测呢?难道不是重复工作么?”这个问题其实很有代表性,很多开发有这个想法,就算他们写了单测,可能也只是敷衍了事或者随意发挥。
这里解释一下前言中的几个关键点:
在面向对象语言里,一个方法到一个类,都可以是一个单元,它取决于我们的测试意图。
在Google官方文档中,将测试分为三级,最底层的属于小型测试即单元测试,本文以单测简称之,第二层属于中型测试即集成测试,第三层属于大型测试即UI测试,每一层的比例约为小型测试占 70%,中型测试占 20%,大型测试占 10%。
你是不是也有这些疑问?
单测浪费了太多的时间
据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单测的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点。
上面那张图,来自微软的统计数据:bug在单测阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。
下面这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。
那么单测的作用到底是什么?意义究竟如何体现?
小结:对于单测,我们不搞虚的,希望能实实在在为项目质量保驾护航。
单测是指对软件中的最小可测试单元进行检查和验证,要以类功能作为测试目标的单个或者一连串的函数测试,也就是说,单测可以是对某个类的具体函数的功能、内部逻辑进行验证。
而针对代码复杂性和依赖性,有如下图的原则描述可参考:
这里对第四象限多提一嘴,对它们写单测意义并不大,不要为了提高单测覆盖率,而花费很多时间和精力去写单测,这样得不偿失。
比较好的节奏是:每个功能Sprint的开发周期,单测与具体实现代码同时进行。
首先,单测成长的过程大致可分为如下4个阶段:
我们代码中经常会有日期工具类,下面是对获取有效日期的测试例子。
object DateUtils {
fun getValidDate(milliseconds: Long): Long {
var validDate = milliseconds
val timeInMillis = Calendar.getInstance(Locale.US).timeInMillis
if (milliseconds <= 0L) {
validDate = timeInMillis
}
return validDate
}
}
我们对边界情况(日期小于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,这里我们假定,程序多次获取当前时间戳是一样的。
上面是很经典的测试写法,也有很多人称这种写法为“三段式”,“三段式”包括模拟前提、执行语句、断言结果。
上面测试例子中,time1 和 time2的创建就是模拟前提,例子中最后一行把执行语句和断言合在了一起,分解开来的话就是:
val validDate2 = DateUtils.getValidDate(time2)
Assert.assertEquals(time2, validDate2)
终端应用中,通常会存在页面跳转逻辑,通过传入不同的参数类型,跳转不同页面,下面我们来一起看看。
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)
}
}
}
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的输入,会导致什么样的输出,所以对具体实现细节我们可以封装起来,这也是软件设计中最少知道原则的使用。
@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方法是否执行,即可。
其实,单测的关键成果,并没那么好衡量,需要团队根据自身情况去做出选择。
如果一个对象具有以下特征,比较适合使用mock对象:
因此,不要滥用mock(stub),当被测方法中调用其他方法函数,第一反应应该走进去串起来,而不是从根部就mock掉了。
【Android 单元测试,从小白到入门开始_Swuagg的博客-CSDN博客_android 单元测试教程】
【从头到脚说单测——谈有效的单元测试 - 云+社区 - 腾讯云】