单测的好处
- 可以覆盖QA所测不到的点,比如工具类/基类的健壮性
- 研发对自己写的代码更熟悉,更容易测出bug,TDD。
- 便于代码重构,重构后跑一遍单测,保证重构后功能正常。(对单测的健壮性要求较高,实际执行中往往代码重构后单测也需要修改,只不过单测写得好的可以尽量少的改动)。代码后期修改维护也是一样的道理。
Android单测
Android单测分两种,一种是纯Java逻辑的测试,运行于JVM之上,不具备Android依赖,对应Android中test包;另一种是Instrumentation test,执行于Android device,但是执行速度慢,内存占用多。业内普遍采用的单测是前者,虽然运行在JVM上,但是可以通过robolectric mock Android Runtime,从而发挥其运行快,占内存少的优点。robolectric采用Shadow
机制偷天换日,将Android Runtime的类替换成了自己实现的对应的Shadow类,从而解决Native代码无法执行 和 JVM没有Android system process等问题。 由于JVM与真实执行环境毕竟有差异,比如网络请求,文件IO等操作往往需要mock。常用的mock库是powermock, 和 robolectric一样,powermock也利用了字节码修改技术,它可用来mock实例,方法,字段等等。Junit是单测的基础框架,单测执行和验证结果都依赖Junit。
单测的编写原则
- 哪些代码需要写单测?不同业务范畴不同,但是可从几个地方评估:一个是重要性,需要保证健壮性的核心模块更需要写单测,例如启动流程,例如使用频率高的工具类;另一个是复杂度,复杂度越高的模块出错概率越大,也更应该写单测,复杂度主要看逻辑,例如条件分支多不多以及依赖的其他类多不多。
- 测试粒度:单个方法。测试用例中不应该包含switch if等分支语句,有的话拆到多个测试用例中。
- 测试case尽量不要try catch, try catch会掩盖异常,可能使结果不对。
- 尽量少mock。越少mock越接近真实环境,代码修改或者重构的时候单测的修改也会少很多。
- 条件覆盖 > 行覆盖,不要过于追求行覆盖率,本末倒置。
- 写代码的研发写对应的单测,最熟悉逻辑的人最清楚哪里可能出错。
- 测试case命名包含
所测方法
+输入
+期望输出
覆盖率
覆盖率可通过Android Studio自带的工具查看,在单测模块下某个包或者类右键会出现run test with coverage ,即可查看覆盖率。也可通过gradle命令查看。两种本质都是通过jacoco来实现的。后者步骤如下:
【Step1】: 新建jacoco.gradle文件(build.gralde同级)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60/**
* 单测运行命令:./gradlew clean -PuseJacoco=true -PunitTest=true :lib-xxx:jacocoTestReport
* 覆盖率html报告:build/test-results/jacocoHtml/$project.name/
* test html报告:build/report/test/testDebugUnitTest/
*/
apply plugin: 'jacoco'
configurations {
jacocoAnt
jacocoRuntime
}
jacoco {
toolVersion = "0.7.4.201502262128"
}
def coverageSourceDirs = [
'src/main/java'
]
task jacocoTestReport(type: JacocoReport, dependsOn: ['instrument', 'testDebugUnitTest']) {
group = "Reporting"
description = "Generate Jacoco coverage reports"
classDirectories = fileTree(
dir: 'build/intermediates/classes/debug',
excludes: ['**/R.class',
'**/R$*.class',
'**/BuildConfig.*',
'**/MainActivity.*']
)
sourceDirectories = files(coverageSourceDirs)
executionData = files('build/jacoco/testDebugUnitTest.exec')
}
jacocoTestReport {
reports {
html.enabled true
html.destination file("build/test-results/jacocoHtml/$project.name/")
}
}
def offline_instrumented_outputDir = "$buildDir.path/intermediates/classes-instrumented/debug"
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
}
gradle.taskGraph.whenReady { graph ->
if (graph.hasTask(instrument)) {
tasks.withType(Test) {
doFirst {
systemProperty 'jacoco-agent.destfile', buildDir.path + '/jacoco/testDebugUnitTest.exec'
classpath = files(offline_instrumented_outputDir) + classpath + configurations.jacocoRuntime
}
}
}
}
task instrument(dependsOn:'compileDebugUnitTestSources') {
doLast {
println 'Instrumenting classes'
ant.taskdef(name: 'instrument',
classname: 'org.jacoco.ant.InstrumentTask',
classpath: configurations.jacocoAnt.asPath)
ant.instrument(destdir: offline_instrumented_outputDir) {
fileset(dir: "$buildDir.path/intermediates/classes/debug")
}
}
}【Step2】: build.gradle中开启jacoco相关
1
2
3
4
5
6
7
8
9
10
11
12android {
buildTypes {
debug {
if (useJacoco) {
testCoverageEnabled = true
}
}
}
}
if (useJacoco) {
apply from: 'jacoco.gradle'
}【Step3】: 运行并生成报告
1
./gradlew clean -PuseJacoco=true -PunitTest=true :lib-xxx:jacocoTestReport
实践中踩的坑
首次运行Robolectric需要从官网下载其依赖的android-all.jar
建议自建maven,从官网下慢,从其他镜像下的可能不完整。
运行前置环境
Android Studio里edit configrations
配置Junit的VM options
里加上-noverify
参数。
JDK 1.7开始增加并校验class文件中的stack map frame信息,如果依赖的powermock或者robolectric版本过低可能出现:java.lang.VerifyError: Expecting a stackmap frame at branch target.....
,因为低版本的powermock或者robolectric进行字节码修改的时候可能针对的是Java 6 class格式进行的,导致没有valid stack map frame。
Robeletric内存泄漏
由于Robeletric框架自身存在内存泄漏,单测case超过一定数量后整体运行一次的过程就会出现OOM或者直接卡死。因为其运行再JVM上并不是真机上,所以内存泄漏的定位也不太容易。有个绕过的方法就是多进程执行,执行少量case后就释放内存重新执行。1
2
3
4
5
6
7
8
9
10
11
12
13android {
testOptions {
unitTests {
includeAndroidResources = true
// disable unit test bytecode verifier
all {
jvmArgs '-noverify'
maxParallelForks = Runtime.runtime.availableProcessors()
forkEvery = 100
}
}
}
}
mock相关
- final/static方法的mock需要把所属类加入prepareForTest列表中,建议加在类上,不要加在方法上。
- 利用Powermock的
whenNew
方法 mock实例的创建的时候需要把创建实例的类加到prepareForTest列表中,不然不生效但是也不报错。。。
如何测试void方法
和有返回值的方法相比,void方法是没有输出的。针对这类方法的测试也就没有万金油。一般有几种测试思路:
- 如果我们想要的输出在执行完void方法后存在了某个field里,取出这个field的值直接验证即可
- 如果我们想要的输出在执行void方法流程中作为参数传入了某个方法,通过继承等方式hook这个方法,得到我们想要的输出进行验证。
- 如果所测方法无法真实执行,就只能通过Powermock的verifyXx方法验证是否被调用,以及调用次数。
多线程assert
现实代码中多线程很常见, 但是这些代码的单测经常遇到坑。首先是Junit在执行完main test thread的代码后会立即shutdown,同时造成其他线程不管有没有执行完都会立即退出。要是其他线程中有assert语句的话很可能没执行到,导致你误以为测试通过了。那么怎么办呢?主线程hang住 等其他线程执行完再退出不就行了吗?
但是事情往往没这么简单。以下面这段简单的代码为例:1
2
3
4
5
6
7
8
9
10
11
12
13
14/**
* just a example
*/
private static void example() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
new Thread(new Runnable() {
public void run() {
Assert.assertEquals(123, 1234);
latch.countDown();
}
}).start();
latch.await();
}
我们在主线程用CountDownLatch
的await
方法hang住,在其他线程执行完测试代码后使用Assert验证结果是否正确,验证完成调用countDown
方法让主线程继续。乍一看逻辑没问题,但是一旦Assert验证结果不对抛出AssertionError
的时候,countDown()
执行不到,主线程会一直hang住。而且由于Junit会ignore 非主线程抛出的AssertionError
,在控制台的输出你根本看不到AssertionError
的发生。这给很多第一次遇到这种问题的开发者造成了不少麻烦。怎么解决呢?
- 方法一:catch住其他线程的
AssertionError
,然后扔给主线程,主线程恢复执行后发现存在其他线程的AssertionError
就抛出。 - 方法二:采用mock的方法把多线程执行的方法扔到主线程执行,连同步代码都省了。
这两种方法各有利弊,对于大多数场景来说,多线程执行的方法扔到主线程执行也没问题,采用方法二非常简单,对写单测的人来说几乎毫无感知。对于一些要求必须在某些线程执行的代码,就只能采用方法一来解决了,写起来会麻烦一点,但是可以更好满足多线程的要求。
对于方法一,我封装了一个工具类,见Waiter.java
后记:
虽然单测有一定的好处,但是并不能取代人工/自动化测试,同时单测会增加研发的开发成本,所以需要正确看待单测,不生搬硬套,不过度追求覆盖率,落到实处,以保障质量为准。熟练之后写单测的时间占写代码的时间比重最好不要过大,否则可能拖慢研发效率。以上。欢迎评论。