Android单测实践经验

组里推行单测的一些经验和踩过的坑。

单测的好处

  • 可以覆盖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
    12
    android {
    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
13
android {
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() {
@Override
public void run() {
Assert.assertEquals(123, 1234);
latch.countDown();
}
}).start();
latch.await();
}

我们在主线程用CountDownLatchawait方法hang住,在其他线程执行完测试代码后使用Assert验证结果是否正确,验证完成调用countDown方法让主线程继续。乍一看逻辑没问题,但是一旦Assert验证结果不对抛出AssertionError的时候,countDown()执行不到,主线程会一直hang住。而且由于Junit会ignore 非主线程抛出的AssertionError,在控制台的输出你根本看不到AssertionError的发生。这给很多第一次遇到这种问题的开发者造成了不少麻烦。怎么解决呢?

  • 方法一:catch住其他线程的AssertionError,然后扔给主线程,主线程恢复执行后发现存在其他线程的AssertionError就抛出。
  • 方法二:采用mock的方法把多线程执行的方法扔到主线程执行,连同步代码都省了。

这两种方法各有利弊,对于大多数场景来说,多线程执行的方法扔到主线程执行也没问题,采用方法二非常简单,对写单测的人来说几乎毫无感知。对于一些要求必须在某些线程执行的代码,就只能采用方法一来解决了,写起来会麻烦一点,但是可以更好满足多线程的要求。

对于方法一,我封装了一个工具类,见Waiter.java


后记:

虽然单测有一定的好处,但是并不能取代人工/自动化测试,同时单测会增加研发的开发成本,所以需要正确看待单测,不生搬硬套,不过度追求覆盖率,落到实处,以保障质量为准。熟练之后写单测的时间占写代码的时间比重最好不要过大,否则可能拖慢研发效率。以上。欢迎评论。