20220601-单元测试浅谈

0 前言

        说起单元测试,每个开发人员都很熟悉,但并未得到大家的重视。很多开发人员认为单测属于可有可无,意义不大。或者有时间就写,没时间就算了的情况。甚至认为:“反正有测试同学帮忙把控代码质量,为什么还要开发浪费时间写单测呢?难道不是重复工作么?”这个问题其实很有代表性,很多开发有这个想法,就算他们写了单测,可能也只是敷衍了事或者随意发挥。

        这里解释一下前言中的几个关键点:

  1. 职责的不同。开发人员的职责是在保证质量的前提下完成一个功能的开发,而测试人员的职责是为产品的质量把关,保证项目交付的质量。也就是说保证代码质量是开发这一环节的工作职责之一,在测试阶段发现了代码里的过多缺陷,就说明了一些场景未考虑全面。
  2. 成本的不同。对于同一个缺陷,在越早期发现,修复的成本就越小,这个道理想必大家都知道。如果很多的质量问题,都需要到测试阶段才能发现,才来返工修复,这对整体项目的时间和资源来说绝对是不小的浪费。
  3. 角度的不同。对于开发人员来说,写单测更多的是针对单个方法的逻辑的测试,而对于测试人员来说,更多的是针对功能的黑盒测试,而单测能够覆盖到某些特殊场景。

1 什么是单元测试

        在面向对象语言里,一个方法到一个类,都可以是一个单元,它取决于我们的测试意图。

        在Google官方文档中,将测试分为三级,最底层的属于小型测试即单元测试,本文以单测简称之,第二层属于中型测试即集成测试,第三层属于大型测试即UI测试,每一层的比例约为小型测试占 70%,中型测试占 20%,大型测试占 10%。

  1. 小型测试:针对单个函数的测试,关注其内部逻辑,mock所有需要的服务。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告。
  2. 中型测试:验证两个或多个制定的模块应用之间的交互。
  3. 大型测试:也被称为“系统测试”或“端到端测试”。大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。

2 为什么要做单元测试

        你是不是也有这些疑问?

  • 单测浪费了太多的时间

  • 单测仅仅是证明这些代码做了什么
  • 我是很棒的程序员,我是不是可以不进行单测?
  • 后面的集成测试将会抓住所有的bug
  • 单测的成本效率不高我把测试都写了,那么测试人员做什么呢?
  • 公司请我来是写代码,而不是写测试
  • 测试代码的正确性,并不是我的工作
  • 只是 Bug 少了一点

        据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单测的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点。 

        上面那张图,来自微软的统计数据:bug在单测阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。

        下面这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。

 

        那么单测的作用到底是什么?意义究竟如何体现?

  1. 单测可以很好保证代码质量,一个好的单测能够覆盖各种业务场景,在这个前提下该目标单元都能够验证通过,说明该单元是足够健壮的。
  2. 单测可以一定程度提高代码合理性,当我们发现给一个方法写单测非常困难,比如需要覆盖的业务场景过多,那说明此方法可以在一定程度上继续进行拆分;又比如需要mock的内容过多,那说明此方法违背了单一责任原则,处理了太多的逻辑,那么需要重新设计等等。
  3. 单测能够有效防止回溯问题的出现,提升回归测试的质量,随着功能的不停增加,全量回归性价比会越来越低,测试人员仅会对主流程进行测试,对于那些不在测试范围内的边界功能,单测能够进行覆盖从而提升回归测试的质量。
  4. 通过单测快速熟悉代码,单测不仅起到了测试的作用,还是一种很好的“文档”,通过单测代码,我们不需要深入的阅读代码,便能知道这段代码做什么工作,有哪些特殊情况需要考虑,包含哪些业务。

小结:对于单测,我们不搞虚的,希望能实实在在为项目质量保驾护航。

3 针对哪些代码做单元测试

3.1 单元测试动机

  1. 针对逻辑复杂、核心的业务去写单测
  2. 针对痛点、易错的代码去写单测
  3. 针对不易理解的代码去写单测
  4. 针对改动较多的部分去写单测

3.2 单元测试代码对象

        单测是指对软件中的最小可测试单元进行检查和验证,要以类功能作为测试目标的单个或者一连串的函数测试,也就是说,单测可以是对某个类的具体函数的功能、内部逻辑进行验证。

        而针对代码复杂性和依赖性,有如下图的原则描述可参考:

  1. 第一象限复杂依赖多:重构减少依赖,变成复杂依赖少,然后写单测
  2. 第二象限复杂依赖少:适合写单测
  3. 第三象限简单依赖少:看情况写
  4. 第四象限简单依赖多:不用重构,不用单测

        这里对第四象限多提一嘴,对它们写单测意义并不大,不要为了提高单测覆盖率,而花费很多时间和精力去写单测,这样得不偿失。

3.3 单元测试什么时候写

        比较好的节奏是:每个功能Sprint的开发周期,单测与具体实现代码同时进行。

4 具体怎么做单元测试

4.1 单元测试成长过程

        首先,单测成长的过程大致可分为如下4个阶段:

  1. 会写,全员写,不要求写好。
  2. 写好,有效。关注可测性问题,试点解决
  3. 可测试性提升。识别可测性问题,熟练使用重构方法进行重构;识别代码架构设计问题;case与业务代码同步编写
  4. 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掉了。

2 参考

        【Android 单元测试,从小白到入门开始_Swuagg的博客-CSDN博客_android 单元测试教程

        【从头到脚说单测——谈有效的单元测试 - 云+社区 - 腾讯云

猜你喜欢

转载自blog.csdn.net/Agg_bin/article/details/125085069
今日推荐