Android平台单元测试流程实践
有过实际工程经验的开发同学肯定都听说过单元测试,但是可能因为对其了解不够清晰,业务变更频繁,认为单元测试的投入没有那么明显的回报,所以从未有过具体的实践。但实际上,单元测试也并非要做到完全覆盖,再怎么频繁迭代的工程也有其稳定的部分,只要在适当的地方增加单测逻辑,覆盖足够的情况,就能在工程维护,开发效率,编码思维方面看到收益。
如果之前只是听过,但是从未做过单测,那便可以跟着本文一起往下,了解单测的概念以及如何为工程添加单测。
What?
是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件(方法)。
Why?
单元测试的目标是隔离程序逻辑并证明某个方法逻辑是完全正确的。一个单元测试提供了代码逻辑需要满足的严密的书面规范。因此,单元测试带来了一些益处。 单元测试可以让软件开发在早期的过程中就能发现问题。
从开发阶段来说,很多方法的测试都可以移到到单元测试中来做,而不需要经过超长的编译流程后跑在虚拟机/真机上进行验证,这也一定程度上提升了开发效率。
对于开发同学,在设计方法时,需要考虑并保证其可测试性,这样有助于降低方法逻辑间的耦合;编写单测时,也需要覆盖重点方法中所有可能出现的场景,一些特殊的case,也可以通过单测用例来记录体现,这也一定程度上降低了开发维护的成本。
How?
工具
那么,应该怎么启动单元测试的覆盖呢?让我们先从工具入手,工具是“先驱者”们经验的总结,能够帮我们规避很多问题。首先,我们跑单测,需要用一个运行环境,能够让单测跑在我们的开发机器上,最合适的就是JUnit。
JUnit
JUnit可以说是JVM语言单测的基石,IDEA对其有良好的支持,Android的编译环境下也可以很好发挥其作用。
1 | class SumTest { |
如上例所示,只需要通过注解就可以很好的标识可执行的方法,一个类的Test一般和该类放在同一个包下,这样就可以访问到该类中package/protected/internal的成员。有了JUnit,很多逻辑独立的方法就可以进行单测覆盖了,比如工具方法、数据存取、网络请求。但是实际业务逻辑中,这些方法的比重并不高,绝大多数方法都会耦合业务逻辑,涉及到状态的改变,模块间的交互,这时候,想要去掉这些依赖,我们就需要用Mock。
MockK
Mock,可以理解为模拟,这种机制能够帮助我们在运行单测的时候模拟当前单元所依赖的其他模块的功能,这样,即使其他的模块没有加入当前单元的构建,不存于运行环境,也无妨,我们可以通过Mock来虚拟一个;而且在很多情况下Mock机制也能够帮助我们完成控制反转。如这一节标题所示,我们要使用的工具是MockK。
举一个比较常见的的例子,论坛中的打开帖子详情,其中某些逻辑与用户的状态有关
1 | fun openPost(postId: Long) { |
这个方法里涉及到了一些其他的模块,比如埋点、网络请求、toast打印、AccountManager、Account验证。可是我们应该只验证也只关心获取帖子内容的逻辑,在openPost这个方法中,我们只有在认定其他模块没有问题的情况下,才能保证该方法去执行单测的正确性。这时候就可以用到MockK了。
1 | @BeforeClass |
上面的逻辑中,@BeforeClass表示的是在实际开始执行单测前的逻辑,我们可以在这里进行Mock,这样就相当于准备好了我们的单测运行环境,这时,所有可能触发的其他模块的逻辑,都在我们的Mock中,我们可以通过对其他模块调用的监听来完成我们用例的验证。
1 | @test |
先稍稍总结下使用mock的思路
- 寻找需要mock的场景
- 设置预期的表现
- 验证结果
按照这个思路,绝大多数场景我们都可以通过Mock来解除被测单元和其他单元间的耦合,这无疑是一个强大的功能。但,mock也不是万能的,“得益于”mock工具的使用方式,mock某些类时,单元测试也和这些类建立了直接引用和联系,这些外部模块的改动也需要让所有相关的mock都进行相应的修改,使用的mock越多,后续维护的成本就越大。并且,在逻辑特别错综复杂时,找到所有的mock点,为他们设置预期,并且去验证结果 ,这样的流程是非常繁琐的。因此,mock只建议作为处理依赖的“最终手段”,而不是通用手段。平常的编码,就应当在设计方法,拆分逻辑时减少他们和外部的耦合,这一思路会在后续展开讨论。
现在我们掌握了mock大法,理应开始大展拳脚了,可是在跑关于Android的类时,又遇到了问题。在跑单测的JVM上并没有Android的类,难道需要mock所有Android的类吗?这显然并不现实。好在已经有工具支持——Robolectric,它可以在JVM环境中模拟Android的行为。
Robolectric
在JUnit中使用Robolectric也比较简单,指定对应的Runner即可
1 | @RunWith(RobolectricTestRunner::class) |
好了,这样就已经OK了,像Handler,android.util.Log这种及其常用且和Android的UI体系没有太大关联的类,在Robolectric的帮助下运行单测时已经全部被Shadow替换了。也可以通过API做一些通用的替换,比如,我们可以重定向所有Log的输出到控制台
1 | ShadowLog.stream = System.out |
Truth
简化验证语句的编写,让控制台的日志更加易读,不赘述了。
FAQ
JUnit版本?
JUnit使用JUnit4,JUnit5和Robolectric兼容不好。
为什么不使用Mockito?
Mockito对Kotlin的支持实在说不上好,最常用的when方法和kotlin关键字冲突、又无法mock companion object field,诸多困难让我转向了MockK,目前几个case写下来还是很不错的。
运行时报错:No tests found for given includes Error
可能有两种情况,1. test class\function名字改了, 2. class为internal,需要改为public(JUnit5可以使用internal)
Roboletric在beforeClass时不生效
Robolectric的Runner只有在test case中生效,beforeClass不生效
改造
在之前的讨论中,我们其实多次讨论到了可测试性,那么如何提高编写代码的可测试呢?直接抛结论很不直观,让我们从一些实际的例子入手
1 | fun openPost(postId: Long) { |
还是个拉取帖子详情的流程,可以看到第3行去获取了用户的模式。这里其实相当于一个隐式输入,如果不去查看isAdmin()方法的源码话,它的的调用者并不会知道内部有这个逻辑。这里其实有两种方式去解决
控制反转,修改获取协议的方式,比如通过抽象一个Provider来获取,测试时使用不同用例的Provider实现;或者通过Mock修改不同case下的check值
引入方法参数,将check值获取的逻辑移出,通过参数来获取这个值
由于在openPost()方法中,需要关注的应该是拉取,考虑到单一职责的设计原则,我们在这里更应该选择第二种方式。不过,选择第二种方式,也就意味着将依赖逻辑都抛给了调用方,这时就需要由调用方来处理对应的隐式输入了,这时候我们也应该判断,由底层抛出的逻辑是否是当前方法的职责,如果是,那么控制反转,若不是,就选择继续引入参数。
以小见大,在这个case里我们讨论到了单一职责,控制反转,其实他们都是SOLID的一部分,一份参考与遵守SOLID原则的代码,他的可测试性是相对较高的。推荐大家在旧代码覆盖单测的过程中与开发新功能的过程中,参考SOLID进行代码的重构与设计。
范围
那么,需要单测覆盖的范围呢?理想情况下,我们肯定会希望每个方法都进行单测,每一行代码都被覆盖到,保证100%的覆盖率,让任何改动都尽在我们的掌控之中,但这显然没什么必要,要达到全覆盖,这项工作的开销就远大于收益了。从目前的项目结构和迭代节奏来看,判断单测优先级可以按重要程度和迭代频率两个维度来考虑。如果重要程度非常高,且迭代频率很低,那么说明这个功能模块就是绝佳的单测覆盖场景,可以立马开始写用例了。相对的,如果迭代频率高,且是一些不重要的业务模块,那就完全没有必要去考虑为这些逻辑配上单测流程。剩下的迭代频率低、重要度低,和迭代频率高、重要度高的模块,根据经验来说,重要度的权重更高一些,可以先从后者开始覆盖。
另外,决定要做单测时,一个类中的方法也可以按访问级别做优先级的划分,比如一般情况下public方法相对private方法会更加稳定,可以优先做public方法的覆盖。
以下方法可以不做单测
- 非常简单的方法,能够在调用点验证的,比如getter/setter/toString等等
- 构造方法,只做资源准备的话可以不验证
- wrapper方法,可以只验证被包装的逻辑
需要做单测,但是容易被忽略的
- 静态代理的方法,虽然逻辑不多,但是有可能需要验证转发的行为
- 复写的方法,需要在子类验证逻辑
异步
单元测试是同步的,异步执行的逻辑常常会在单测方法跑完之后,因此需要特别注意。如果,异步执行的是主要逻辑,那么可以考虑只验证异步中的逻辑,将其当作一个单独的单元来测试(比如网络请求)。如果一个方法中主要线程的逻辑和其异步执行的逻辑同样重要,那么就要借助工具了。Handler可以考虑使用Robolectric将异步逻辑立即执行,Thread、ThreadPool可以考虑通过mock,直接执行对应的Runnable,如果使用RxJava,也可以借助API切换进程。
覆盖率
覆盖率是一个比较重要的指标,覆盖率工具也能够可视化的从指令层面上展现代码测试的覆盖情况。目前主流的代码测试覆盖率框架为Jacoco,但是使用下来对Kotlin的支持有些问题。调研并使用了Kover后,发现效果也非常不错。Kover的使用非常简单,root gradle中引入并开启report,指定输出目录即可
1 | // 同样还可以开启xml格式的报告输出 |
增量检查
可以期待后续文章
规范 & 原则
最后一部分,简单的定一些规范原则,在进行单测覆盖的初期先试行,会在开发过程中随时修改,有稳定版后会单独维护文章
- 编写用例时注意错误/边界条件,不要在测试方法中使用try-catch
- 每个用例都应该有说明,或者你的用例名称足够说明他是什么场景下会出现的
- 单元测试的逻辑也可能会有Bug或者遗漏用例的情况,在后续的开发、测试过程中如果发现错漏要及时修改
- 一般来说,不需要专门测试private方法,可以通过覆盖调用该方法的其他高访问级别的方法来间接验证。如果private方法非常复杂且需要详细测试,就要考虑该方法能否放到单独的类中。如果不行的话,最终手段可以提高其访问级别,并通过@VisibleForTesting注解标记,告知编译器这个方法不应该被外部调用
- 测试类最好和被测试类放在同一个包名下
- 测试类的类名跟Test后缀
- 测试方法的方法名要有test_前缀
- 测试方法之间不能互相引用
参考文章
google Android单测示例:https://github.com/android/testing-samples
gradle单元测试官方指南:https://docs.gradle.org/current/userguide/java_testing.html#sec:java_testing_basics
mockK官方介绍文档:https://mockk.io/#markdown-toc
robolectric文档:http://robolectric.org/
Truth官方文档: https://truth.dev/·
kover github: https://github.com/Kotlin/kotlinx-kover