0


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

1 引言

1.1 背景

  1. 随着 Android 应用越来越壮大,对应代码量显著增加,代码逻辑也日趋复杂,此时需要采取一定的行动去保证代码质量,减少逻辑漏洞等,于是严格地执行单元测试编写任务,将单元测试落实到平常开发和维护任务当中去,就是很重要的一环,不可忽视。
  2. 然而,很多应用开发者之前并未编写过单元测试代码,那么如果有一篇**通俗易懂**并带有**操作步骤**的文章,能帮助应用开发者完成从**单元测试小白到入门**的过渡,就再好不过了,于是本文就是在此情况写就的,如有不好之处,请多多包涵,谢谢。

1.2 术语和缩略语

缩略语/术语

全 称

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

2 闲谈单测

2.1 说说我理解的单测

2.1.1 对测试金字塔的理解

  1. 本文所指的单测,是金字塔**最底层占70%的小型测试(单测) Unit tests**,**不包括最顶层占10%的大型测试(UI 测试) UI Tests**,对于中间层20%的集成测试(集测),需看情况去做。
  2. 另外,集成测试更多是指验证一整个执行流程,而单测验证某个行为或逻辑,可以这么理解:集成测试验证执行流程时,会走单测验证过的某个行为或逻辑,即:单测可能是集成测试的一部分(不完全正确,但可以这么理解)。
  3. 比如,Android 中需要在 Activity 显示一张网络图片,单测需验证网络请求图片是否成功,集测需验证从打开 Activity 到显示图片这一过程是否都按预期在执行,UI 测试验证 Activity 显示的图片是否如预期一样。

2.1.2 为什么要做单测?

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

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

  1. 当我们对类函数代码做了修改或者重构,只需要再跑一遍单元测试
  2. 如果通过,说明我们的修改不会对函数原有的行为造成影响
  3. 如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。

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

  1. 刚开始,已有的代码可能基本没有办法写单测,会形成重构驱动,重构的过程会让代码逐渐具备良好的可测性
  2. 如果可以针对代码方便快速地,在无需做任何配置工作的情况下,编写一系列互相独立且稳定运行的测试,那么该代码就具备了可测性
  3. 具备可测性的代码必然是经过了良好设计的,形成可测性代码的过程,也就是编码技能提升的过程。

2.1.3 需要写 UI 测试吗?

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

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

2.1.4 单测与重构

  1. 在没有进行合理的代码解耦前,就马上的进行单测编写,最终导致的有可能是编写不下去、用例难以维护、阅读性差等。
  2. 根本的原因其实是代码的耦合度过高,功能类与协作类之间是强引用关系(这里是指架构设计上的说法,非内存引用),当需要模拟协作类的一些返回结果来进行测试时,无法替换或者模拟(Mock)。**层级间、功能类间以接口的形式访问是一个较优的单测方式。**
  3. 没写过单测的童鞋可能不知道,其实重构也是单测的一部分,切记不要在本就不优雅的代码上写单测,请先重构。单测为代码质量保驾护航,重构提升代码质量和自我编程能力。

2.1.5 TDD

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

2.1.6 单测写在哪?

  1. 首先,我们暂不写 UI 测试,只做单测,那么只需要在 **src/****test/java/包名/ 下**写即可,一般来说可以不用在 src/androidTest/java/包名/ 下写单测代码。
  2. 其次,针对于 Android 应用,你可以在每个 Module 下都为其编写单测代码,然后统计各个 Module 的覆盖率,最后求一个平均覆盖率,即为整个应用的覆盖率。但,我们知道这其实是比较麻烦的,每次统计时都需要去计算每个 Module 的覆盖率然后求平均,当然如果用 jacoco 可以优雅自动统计的话就另当别论。
  3. 所以,为了方便统计单测覆盖率,本文暂推荐在**指定 Module 的 src/****test/java/包名/ 下**写所有 Module 的单测代码,这个指定 Module 可以是 app 模块,也可以是新建的专为写单测的 unit_test 模块。

2.1.7 单测三段式

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

2.1.8 一个简单的测试例子

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

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

  1. object DateUtils {
  2. fun getValidDate(milliseconds: Long): Long {
  3. var validDate = milliseconds
  4. val timeInMillis = Calendar.getInstance(Locale.US).timeInMillis
  5. if (milliseconds <= 0L || milliseconds > timeInMillis) {
  6. validDate = timeInMillis
  7. }
  8. return validDate
  9. }
  10. }

(2)测试代码

  1. @Test
  2. fun testGetValidData() {
  3. // 1、模拟前提
  4. val currentTimeMillis1 = System.currentTimeMillis()
  5. // 2、执行语句
  6. val validDate1 = DateUtils.getValidDate(-1)
  7. // 3、断言结果
  8. Assert.assertTrue(validDate1 in (currentTimeMillis1 - 1000)..(currentTimeMillis1 + 1000))
  9. val currentTimeMillis2 = System.currentTimeMillis()
  10. val validDate2 = DateUtils.getValidDate(0)
  11. Assert.assertTrue(validDate2 in (currentTimeMillis2 - 1000)..(currentTimeMillis2 + 1000))
  12. val currentTimeMillis3 = System.currentTimeMillis()
  13. val validDate3 = DateUtils.getValidDate(currentTimeMillis3 + 100000)
  14. Assert.assertTrue(validDate3 in (currentTimeMillis3 - 1000)..(currentTimeMillis3 + 1000))
  15. val milliseconds4 = System.currentTimeMillis() - 100000
  16. val validDate4 = DateUtils.getValidDate(milliseconds4)
  17. Assert.assertEquals(milliseconds4, validDate4)
  18. }

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

  1. 单测是指对软件中的最小可测试单元进行检查和验证,要以类功能作为测试目标的单个或者一连串的函数测试,也就是说,单测可以是对某个类的具体函数的功能、内部逻辑进行验证。
  2. 而针对代码复杂性和依赖性,有如下图的原则描述可参考:

  • 复杂依赖少:适合写单测

  • 复杂依赖多:重构减少依赖,变成复杂依赖少,然后写单测

  • 简单依赖少:看情况写

  • 简单依赖多:不用重构,不用单测

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

2.3 测试用例该如何设计

2.3.1 确保原因与影响清晰

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

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

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

  1. 对函数执行过程中验证不带来状态发生改变的函数是无意义的,例如isUserActivegetPermissionisVAlidPermission等都是对程序没有发生状态变化的,
  2. 验证是否这类函数是否执行,只会增加后续代码修改带来的不稳定。

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

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

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

正确应该命名为:should_LockOutUser_when_ThreeInvalidLoginAttempts

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

2.4 单测框架

  1. Junit4

**

  1. + MockitopowerMock)+ robolectric

**

**

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

**

3 如何开始单测?

3.1 项目中引入单测框架

  1. 单测依赖介绍如下:
  1. // JUnit4:本地单元测试
  2. 'junit:junit:4.13.2',
  3. 'androidx.test:core:1.4.0',
  4. // Robolectric:本地单元测试依赖 Android 框架
  5. 'org.robolectric:robolectric:4.4',
  6. // Mockito:本地单元测试模拟框架
  7. "org.mockito:mockito-core:3.12.4",
  8. // mock final类时出现错误:Mockito cannot mock/spy because : - final class,增加如下模拟框架
  9. 'org.mockito:mockito-inline:3.12.4',
  10. // PowerMock:Mockito的一种扩展(以实现完成对private/static/final方法的Mock)
  11. 'org.powermock:powermock-module-junit4:2.0.9',
  12. 'org.powermock:powermock-api-mockito2:2.0.9'
  1. 比如在 app Module build.gradle dependencies 下,依赖 JUnit4 如下:testImplementation 'junit:junit:4.13.2'
  2. UI 测试暂不做,但为了区分依赖,也罗列如下:
  1. // AndroidJUnitRunner and JUnit Rules:插桩单元测试
  2. 'androidx.test:runner:1.4.0',
  3. 'androidx.test:rules:1.4.0',
  4. // runner 和 rules 的扩展包:@RunWith(AndroidJUnit4.class) 在此扩展包的 runners 下
  5. 'androidx.test.ext:junit:1.1.3',
  6. // Espresso:Android 界面测试
  7. 'androidx.test.espresso:espresso-core:3.4.0'
  1. 依赖 espresso 如下:androidTestImplementation androidx.test.espresso:espresso-core:3.4.0'
  2. UI 测试时需在defaultConfig中添加:testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  1. defaultConfig {
  2. testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  3. }
  1. UI 测试覆盖率统计开关打开:testCoverageEnabled true
  1. android {
  2. buildTypes {
  3. debug {
  4. testCoverageEnabled true
  5. }
  6. release {
  7. minifyEnabled false
  8. proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
  9. }
  10. }
  11. }
  1. 单测可以访问编译版本的资源:includeAndroidResources = true
  1. android {
  2. testOptions {
  3. unitTests {
  4. includeAndroidResources = true
  5. }
  6. }
  7. }

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

Mockito使用

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

Mockito的使用 - 简书

Robolectric使用

Robolectric使用教程 - HansChen - 博客园

Android单元测试之Robolectric - 简书

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

单测介绍

https://segmentfault.com/a/1190000006811141

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

3.3 编写单测代码

  1. 其实编写单测和写代码是一样的,只是使用不同工具完成功能或测试。具体操作的话,在 app/src/test/java/包名/ 下创建和代码一样的包结构,然后新建测试文件,编写单测代码即可。有如下注意事项:
  • 测试文件命名:一般是文件名加上Test后缀,比如针对 TimeUtils.kt 这个文件测试,那么测试文件可命名为TimeUtilsTest。
  • 测试文件存放路径:细心的童鞋应该看到了,上一条的 TimeUtils.kt 这个文件是 kotlin 语言编写的,那么测试文件应该放在与 kotlin 相关的目录下,简单来说就是在包名和包结构中添加一层 kotlin 文件夹,即:app/src/test/java/包名/kotlin/ 。为保持统一测试代码也建议用 kotlin 书写。
  • 测试方法命名:期望输出_测试场景,如 fiveMethodsShouldBeInvoked_WhenInitData

3.4 单元测试代码分析

  1. 为了给应用开发者一个直观的印象,这里还是决定贴出一份单测代码,如有不足之处,还请海涵:
  1. @Config(shadows = [ShadowLog::class, MockCA::class, MockDoExerciseApi::class, MockPortalApi::class], sdk = [23], application = BaseTestApplication::class)
  2. class ProfilePresenterTest : BaseTestRobolectricClass() {
  3. @Spy
  4. lateinit var v: ProfileContract.V
  5. lateinit var p: ProfilePresenter
  6. @Before
  7. fun setUp() {
  8. MockitoAnnotations.openMocks(this)
  9. p = spy(ProfilePresenter::class.java)
  10. p.attachToView(v)
  11. }
  12. /**
  13. * 命名规则:期望输出_测试场景
  14. */
  15. @Test
  16. fun fiveMethodsShouldBeInvoked_WhenInitData() {
  17. p.initData()
  18. verify(v).updateWeight("")
  19. verify(v, never()).finishActivity()
  20. verify(v, atLeastOnce()).updateHeight("")
  21. verify(v, atLeast(1)).updateExerciseGoal("")
  22. verify(v, times(1)).updateExerciseFrequency("")
  23. verify(v, atMost(1)).updateExerciseTime("")
  24. // 检查是否所有的用例都涵盖了,如果没有将测试失败。放在所有的测试后面
  25. verifyNoMoreInteractions(v)
  26. }
  27. @Test
  28. fun finishActivityMethodShouldBeInvoked_WhenResetUserInfo() {
  29. PrivateAccessor.invoke<ProfilePresenter>(p, "resetUserInfo")
  30. verify(v).finishActivity()
  31. }
  32. companion object {
  33. private val TAG = ProfilePresenterTest::class.java.simpleName
  34. }
  35. }
  1. 第一行的 @Config 部分可参考 Robolectric 框架
  2. 第二行继承了 BaseTestRobolectricClass 文件,它是作为单测代码的基类,稍后贴出源码
  3. @Spy 与 Mockito.spy() 方法相同,只是一个使用注解方便些
  4. fiveMethodsShouldBeInvoked_WhenInitData 为测试方法,verify 验证 initData 方法执行后,有5个方法会执行一次,never() 与 times() 等都是限定验证时方法的调用次数的
  5. 最后一个方法用到了 PrivateAccessor 类,它可以通过反射的方式支持验证私有方法和属性。

BaseTestRobolectricClass 源码参考:

  1. @RunWith(RobolectricTestRunner::class)
  2. @Config(shadows = [ShadowLog::class], sdk = [23], application = BaseTestApplication::class)
  3. abstract class BaseTestRobolectricClass {
  4. protected val mContext: Context = ApplicationProvider.getApplicationContext()
  5. companion object {
  6. @JvmStatic
  7. protected val TAG: String = this::class.java.simpleName
  8. @BeforeClass
  9. @JvmStatic
  10. fun setup() {
  11. ShadowLog.stream = System.out
  12. }
  13. }
  14. }

4 如何统计覆盖率?

4.1 统计覆盖率

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

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

  1. 现象:AS中总代码行高于生成的HTML文件,所以显示的代码行覆盖率低于生成的HTML文件
  2. 原因:见截图。可知,HTML文件代码行中,并未包含activityfragmentview相关的代码行(不知道是因为没写UI测试导致的,或是ASHTML时导致的)
  3. 解决:目前开发时,以AS为准。

5 总结

  1. 一般来说,单测初级阶段,在统计出覆盖率后,行覆盖率达到25%或更高指标时,就算差不多了。但写单测的路也不应就此停下,在维护代码过程中会涉及对单测的修改;在后面新增功能代码时也需新增单测代码。
  2. 好了,本文到此也差不多该收尾了,希望能给单测小白一些收获或感悟,文后还附上了官方文档供参考。

6 参考

  1. 1Google官方测试文档:https://developer.android.com/training/testing/fundamentals
  2. 2Mockito官方文档:Mockito (Mockito 3.5.10 API)
  3. 3Robolectric官方文档:Robolectric
标签: 单元测试 android

本文转载自: https://blog.csdn.net/Agg_bin/article/details/120768579
版权归原作者 Swuagg 所有, 如有侵权,请联系我们删除。

“Android 单元测试,从小白到入门开始”的评论:

还没有评论