Android 单元测试,从小白到入门开始

目录

1 引言

1.1  背景

1.2 术语和缩略语

2 闲谈单测

2.1 说说我理解的单测

2.1.1 对测试金字塔的理解

2.1.2 为什么要做单测?

2.1.3 需要写 UI 测试吗?

2.1.4 单测与重构

2.1.5 TDD

2.1.6 单测写在哪?

2.1.7 单测三段式

2.1.8 一个简单的测试例子

2.2 单测针对于哪些代码进行 

2.3 测试用例该如何设计

2.3.1 确保原因与影响清晰

 2.3.2 只验证有状态变化的函数调用

2.3.3 测试函数名应该有描述性

2.4 单测框架

3 如何开始单测?

3.1 项目中引入单测框架

3.2 参考单测模板代码和优秀文章

Mockito使用

Robolectric使用

单测介绍

3.3 编写单测代码

3.4 单元测试代码分析

4 如何统计覆盖率?

4.1 统计覆盖率

4.2 覆盖率统计 AS 中以及导出 HTML 文件的差异

5 总结

6 参考


1 引言

1.1  背景

        随着 Android 应用越来越壮大,对应代码量显著增加,代码逻辑也日趋复杂,此时需要采取一定的行动去保证代码质量,减少逻辑漏洞等,于是严格地执行单元测试编写任务,将单元测试落实到平常开发和维护任务当中去,就是很重要的一环,不可忽视。

        然而,很多应用开发者之前并未编写过单元测试代码,那么如果有一篇通俗易懂并带有操作步骤的文章,能帮助应用开发者完成从单元测试小白到入门的过渡,就再好不过了,于是本文就是在此情况写就的,如有不好之处,请多多包涵,谢谢。

1.2 术语和缩略语

缩略语/术语

全 称

说 明

Module 模块 本文指 Android Studio 项目中包涵的多个模块其中之一
TDD Test-Driven Development 测试驱动开发,在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。
单测 Android Unit Test 本文为了行文方便,使用单测表示 Android 单元测试。
测开 Test Development Engineer 测试开发工程师,本文主要指进行白盒测试的测开。

2 闲谈单测

2.1 说说我理解的单测

2.1.1 对测试金字塔的理解

Android 单元测试,从小白到入门开始_第1张图片

        本文所指的单测,是金字塔最底层占70%的小型测试(单测) Unit tests不包括最顶层占10%的大型测试(UI 测试) UI Tests,对于中间层20%的集成测试(集测),需看情况去做。

        另外,集成测试更多是指验证一整个执行流程,而单测验证某个行为或逻辑,可以这么理解:集成测试验证执行流程时,会走单测验证过的某个行为或逻辑,即:单测可能是集成测试的一部分(不完全正确,但可以这么理解)。

        比如,Android 中需要在 Activity 显示一张网络图片,单测需验证网络请求图片是否成功,集测需验证从打开 Activity 到显示图片这一过程是否都按预期在执行,UI 测试验证 Activity 显示的图片是否如预期一样。

2.1.2 为什么要做单测?

        除了引言中背景提到的单测必要性之外,还有如下2点理由。

(1)一种代码验证,提高对代码的自信度

        当我们对类函数代码做了修改或者重构,只需要再跑一遍单元测试

        如果通过,说明我们的修改不会对函数原有的行为造成影响

        如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。

(2)驱动代码重构设计,代码重构质量的验证

        刚开始,已有的代码可能基本没有办法写单测,会形成重构驱动,重构的过程会让代码逐渐具备良好的可测性

        如果可以针对代码方便快速地,在无需做任何配置工作的情况下,编写一系列互相独立且稳定运行的测试,那么该代码就具备了可测性

        具备可测性的代码必然是经过了良好设计的,形成可测性代码的过程,也就是编码技能提升的过程。

2.1.3 需要写 UI 测试吗?

        Android 应用从广义来说,属于大前端,也就意味着 UI 变动会很频繁。且对于 UI 问题,应用开发者是很容易就能发现的,一般也比较容易解决。那么,针对 UI 进行测试其实是需要的,但没那么必要,可以不用写 UI 测试。

        不用写 UI 测试,除了上述提到的 2 个理由,另外还有这 2 个理由:Espresso 单测比较简单;测开会做 UI 测试。将重心放在单测上才是王道。

2.1.4 单测与重构

        在没有进行合理的代码解耦前,就马上的进行单测编写,最终导致的有可能是编写不下去、用例难以维护、阅读性差等。

        根本的原因其实是代码的耦合度过高,功能类与协作类之间是强引用关系(这里是指架构设计上的说法,非内存引用),当需要模拟协作类的一些返回结果来进行测试时,无法替换或者模拟(Mock)。层级间、功能类间以接口的形式访问是一个较优的单测方式。     

        没写过单测的童鞋可能不知道,其实重构也是单测的一部分,切记不要在本就不优雅的代码上写单测,请先重构。单测为代码质量保驾护航,重构提升代码质量和自我编程能力。

2.1.5 TDD

        项目如果走的敏捷开发,会涉及到测试驱动开发这一设计方法论,但说实话,在写代码前先写好测试代码,这对开发者能力要求很高,且目前国内开发环境其实对这块的理解并不如理想之见,暂无需考虑。

2.1.6 单测写在哪?

        首先,我们暂不写 UI 测试,只做单测,那么只需要在 src/test/java/包名/ 下写即可,一般来说可以不用在 src/androidTest/java/包名/ 下写单测代码。

        其次,针对于 Android 应用,你可以在每个 Module 下都为其编写单测代码,然后统计各个 Module 的覆盖率,最后求一个平均覆盖率,即为整个应用的覆盖率。但,我们知道这其实是比较麻烦的,每次统计时都需要去计算每个 Module 的覆盖率然后求平均,当然如果用 jacoco 可以优雅自动统计的话就另当别论。

        所以,为了方便统计单测覆盖率,本文暂推荐在指定 Module 的 src/test/java/包名/ 下写所有 Module 的单测代码,这个指定 Module 可以是 app 模块,也可以是新建的专为写单测的 unit_test 模块。

2.1.7 单测三段式

        经典的单测三段式:模拟前提、执行语句、断言结果。有时会把这三者的部分或全部合在一起,如:Assert.assertEquals(4, 2+2),就把三者融合在一起。

2.1.8 一个简单的测试例子

        我们代码中经常会有日期工具类,下面是对有效日期判断的方法进行的测试例子。

(1)有效日期判断的源码

object DateUtils {
    fun getValidDate(milliseconds: Long): Long {
        var validDate = milliseconds
        val timeInMillis = Calendar.getInstance(Locale.US).timeInMillis
        if (milliseconds <= 0L || milliseconds > timeInMillis) {
            validDate = timeInMillis
        }
        return validDate
    }
}

(2)测试代码

@Test
fun testGetValidData() {
    // 1、模拟前提
    val currentTimeMillis1 = System.currentTimeMillis()
    // 2、执行语句
    val validDate1 = DateUtils.getValidDate(-1)
    // 3、断言结果
    Assert.assertTrue(validDate1 in (currentTimeMillis1 - 1000)..(currentTimeMillis1 + 1000))

    val currentTimeMillis2 = System.currentTimeMillis()
    val validDate2 = DateUtils.getValidDate(0)
    Assert.assertTrue(validDate2 in (currentTimeMillis2 - 1000)..(currentTimeMillis2 + 1000))

    val currentTimeMillis3 = System.currentTimeMillis()
    val validDate3 = DateUtils.getValidDate(currentTimeMillis3 + 100000)
    Assert.assertTrue(validDate3 in (currentTimeMillis3 - 1000)..(currentTimeMillis3 + 1000))

    val milliseconds4 = System.currentTimeMillis() - 100000
    val validDate4 = DateUtils.getValidDate(milliseconds4)
    Assert.assertEquals(milliseconds4, validDate4)
}

2.2 单测针对于哪些代码进行 

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

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

Android 单元测试,从小白到入门开始_第2张图片

  • 复杂依赖少:适合写单测
  • 复杂依赖多:重构减少依赖,变成复杂依赖少,然后写单测
  • 简单依赖少:看情况写
  • 简单依赖多:不用重构,不用单测

       这里对代码简单依赖多的情况多提一嘴,对它们写单测意义并不大,不要为了提高单测覆盖率,而花费很多时间和精力去写单测,这样得不偿失。

2.3 测试用例该如何设计

2.3.1 确保原因与影响清晰

        模拟前提条件和测试断言之间相隔了200行,这很难知道这断言是否正确,可读性差

Android 单元测试,从小白到入门开始_第3张图片

         确保模拟前提与验证结果相近,查看问题时能快速定位

Android 单元测试,从小白到入门开始_第4张图片

 2.3.2 只验证有状态变化的函数调用

        对函数执行过程中验证不带来状态发生改变的函数是无意义的,例如isUserActive、getPermission、isVAlidPermission等都是对程序没有发生状态变化的,

        验证是否这类函数是否执行,只会增加后续代码修改带来的不稳定。

Android 单元测试,从小白到入门开始_第5张图片

        正确应该只验证addPermission是否执行 

Android 单元测试,从小白到入门开始_第6张图片

2.3.3 测试函数名应该有描述性

        测试函数名应该具备可直接理解该测试用例的意图和验证结果,如下这种写法,很难让人知道测试目的是为了验证三次输入错误。

Android 单元测试,从小白到入门开始_第7张图片        正确应该命名为:should_LockOutUser_when_ThreeInvalidLoginAttempts 

        should_期望结果_when_测试场景。确保测试用例名字中包括被测试的场景和期望的输出。

2.4 单测框架

    Junit4 + Mockito(powerMock)+ robolectric

    简单单测 + 模拟难以实例化的类 + 实现 Android 框架(更多内容请参考相应框架学习)

3 如何开始单测?

3.1 项目中引入单测框架

        单测依赖介绍如下:

// JUnit4:本地单元测试
    'junit:junit:4.13.2',
    'androidx.test:core:1.4.0',
// Robolectric:本地单元测试依赖 Android 框架
    'org.robolectric:robolectric:4.4',
// Mockito:本地单元测试模拟框架
    "org.mockito:mockito-core:3.12.4",
// mock final类时出现错误:Mockito cannot mock/spy because : - final class,增加如下模拟框架
    'org.mockito:mockito-inline:3.12.4',
// PowerMock:Mockito的一种扩展(以实现完成对private/static/final方法的Mock)
    'org.powermock:powermock-module-junit4:2.0.9',
    'org.powermock:powermock-api-mockito2:2.0.9'

        比如在 app Module 的 build.gradle 的 dependencies 下,依赖 JUnit4 如下:testImplementation 'junit:junit:4.13.2'

        UI 测试暂不做,但为了区分依赖,也罗列如下:

// AndroidJUnitRunner and JUnit Rules:插桩单元测试
    'androidx.test:runner:1.4.0',
    'androidx.test:rules:1.4.0',
// runner 和 rules 的扩展包:@RunWith(AndroidJUnit4.class) 在此扩展包的 runners 下
    'androidx.test.ext:junit:1.1.3',
// Espresso:Android 界面测试
    'androidx.test.espresso:espresso-core:3.4.0'

        依赖 espresso 如下:androidTestImplementation ‘androidx.test.espresso:espresso-core:3.4.0'

        UI 测试时需在defaultConfig中添加:testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

defaultConfig {
    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

        UI 测试覆盖率统计开关打开:testCoverageEnabled true

android {
    buildTypes {
        debug {
            testCoverageEnabled true
        }
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

        单测可以访问编译版本的资源:includeAndroidResources = true

android {
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

3.2 参考单测模板代码和优秀文章

Mockito使用

        Mockito是一套模拟框架,允许模拟公有、非静态类的公有函数,提供函数返回模拟、函数调用验证、参数匹配等功能。

Mockito的使用 - 简书

Robolectric使用

Robolectric使用教程 - HansChen - 博客园

Android单元测试之Robolectric - 简书

Android单元测试框架Robolectric的学习使用_川峰的博客-CSDN博客_robolectric

单测介绍

https://segmentfault.com/a/1190000006811141

Android单元测试只看这一篇就够了 - 简书

3.3 编写单测代码

        其实编写单测和写代码是一样的,只是使用不同工具完成功能或测试。具体操作的话,在 app/src/test/java/包名/ 下创建和代码一样的包结构,然后新建测试文件,编写单测代码即可。有如下注意事项:

  • 测试文件命名:一般是文件名加上Test后缀,比如针对 TimeUtils.kt 这个文件测试,那么测试文件可命名为TimeUtilsTest。
  • 测试文件存放路径:细心的童鞋应该看到了,上一条的 TimeUtils.kt 这个文件是 kotlin 语言编写的,那么测试文件应该放在与 kotlin 相关的目录下,简单来说就是在包名和包结构中添加一层 kotlin 文件夹,即:app/src/test/java/包名/kotlin/ 。为保持统一测试代码也建议用 kotlin 书写。
  • 测试方法命名:期望输出_测试场景,如 fiveMethodsShouldBeInvoked_WhenInitData

3.4 单元测试代码分析

        为了给应用开发者一个直观的印象,这里还是决定贴出一份单测代码,如有不足之处,还请海涵:

@Config(shadows = [ShadowLog::class, MockCA::class, MockDoExerciseApi::class, MockPortalApi::class], sdk = [23], application = BaseTestApplication::class)
class ProfilePresenterTest : BaseTestRobolectricClass() {
 
    @Spy
    lateinit var v: ProfileContract.V
 
    lateinit var p: ProfilePresenter
 
    @Before
    fun setUp() {
        MockitoAnnotations.openMocks(this)
        p = spy(ProfilePresenter::class.java)
        p.attachToView(v)
    }
 
    /**
     * 命名规则:期望输出_测试场景
     */
    @Test
    fun fiveMethodsShouldBeInvoked_WhenInitData() {
        p.initData()
        verify(v).updateWeight("")
        verify(v, never()).finishActivity()
        verify(v, atLeastOnce()).updateHeight("")
        verify(v, atLeast(1)).updateExerciseGoal("")
        verify(v, times(1)).updateExerciseFrequency("")
        verify(v, atMost(1)).updateExerciseTime("")
        // 检查是否所有的用例都涵盖了,如果没有将测试失败。放在所有的测试后面
        verifyNoMoreInteractions(v)
    }
 
    @Test
    fun finishActivityMethodShouldBeInvoked_WhenResetUserInfo() {
        PrivateAccessor.invoke(p, "resetUserInfo")
        verify(v).finishActivity()
    }
 
    companion object {
        private val TAG = ProfilePresenterTest::class.java.simpleName
    }
 
}
  1. 第一行的 @Config 部分可参考 Robolectric 框架
  2. 第二行继承了 BaseTestRobolectricClass 文件,它是作为单测代码的基类,稍后贴出源码
  3. @Spy 与 Mockito.spy() 方法相同,只是一个使用注解方便些
  4. fiveMethodsShouldBeInvoked_WhenInitData 为测试方法,verify 验证 initData 方法执行后,有5个方法会执行一次,never() 与 times() 等都是限定验证时方法的调用次数的
  5. 最后一个方法用到了 PrivateAccessor 类,它可以通过反射的方式支持验证私有方法和属性。

BaseTestRobolectricClass 源码参考:

@RunWith(RobolectricTestRunner::class)
@Config(shadows = [ShadowLog::class], sdk = [23], application = BaseTestApplication::class)
abstract class BaseTestRobolectricClass {
 
    protected val mContext: Context = ApplicationProvider.getApplicationContext()
 
    companion object {
        @JvmStatic
        protected val TAG: String = this::class.java.simpleName
 
        @BeforeClass
        @JvmStatic
        fun setup() {
            ShadowLog.stream = System.out
        }
    }
 
}

4 如何统计覆盖率?

4.1 统计覆盖率

        在 src/test/java 上右键选择如图 Run...,会跑整体单测代码。跑完后还会在写过单测代码的文件后显示单测覆盖率。也可导出覆盖率为 HTML 文件,但不比 AS 准确。

Android 单元测试,从小白到入门开始_第8张图片

 Android 单元测试,从小白到入门开始_第9张图片

 Android 单元测试,从小白到入门开始_第10张图片

4.2 覆盖率统计 AS 中以及导出 HTML 文件的差异

        现象:AS中总代码行高于生成的HTML文件,所以显示的代码行覆盖率低于生成的HTML文件

        原因:见截图。可知,HTML文件代码行中,并未包含activity、fragment和view相关的代码行(不知道是因为没写UI测试导致的,或是AS导HTML时导致的)

        解决:目前开发时,以AS为准。

5 总结

        一般来说,单测初级阶段,在统计出覆盖率后,行覆盖率达到25%或更高指标时,就算差不多了。但写单测的路也不应就此停下,在维护代码过程中会涉及对单测的修改;在后面新增功能代码时也需新增单测代码。

        好了,本文到此也差不多该收尾了,希望能给单测小白一些收获或感悟,文后还附上了官方文档供参考。

6 参考

        【1】Google官方测试文档:https://developer.android.com/training/testing/fundamentals

        【2】Mockito官方文档:Mockito (Mockito 3.5.10 API)

        【3】Robolectric官方文档:Robolectric

你可能感兴趣的:(Android:单元测试,单元测试,android)