Android测试驱动开发(TDD)

本文介绍测试驱动开发(Test-Driven development)的流程与精要,还有在Java、Android上的测试手段。

什么是TDD?

测试驱动开发(TDD)是由敏捷开发派生而来,它描述的是这样一种开发流程:

tdd

图片来自FirebaseStudio

1. 添加测试用例

每一个功能点的添加,开发者都必须对它进行详细地分析,然后快速地、有针对地书写测试代码。它和正常的开发流程不同的是,开发者需要在开发每一个功能点之前,仔细想清楚需求,先用测试用例描述出功能点的需求异常情况

这要求我们遵循一定的设计原则,先设计出接口行为,添加空的实现,然后将测试用例快速写出来。

2. 测试不通过

此时我们需要跑一遍测试,我们没有写任何功能的代码之前,新增的测试用例肯定会失败 ,因为我们没有实现功能细节。

3. 添加代码

针对我们写下的测试用例,完善代码。注意,在此阶段,你的所有目标就是让代码通过步骤一加入的测试用例。你不应该添加跟步骤一所加入的测试中无关的代码。

4. 重复动作

当所有的测试用例都能运行通过时,你若要应该再增加、改变功能,必须从步骤1开始重新进行。

5. 代码优化

由于你在步骤3中是面向测试用例编程,所以可能会导致代码风格、结构有不妥当的地方。此时你应该重新审视一遍自己的代码,如果有不适的地方(如重复功能的类、语义不详的名称、混乱的工程结构等),你应该对其进行一定的重构。然后你需要重复运行写好的测试用例,测试通过让你对自己的改动具有信心。

理解

TDD的作用已经不仅仅局限于”测试“,它为软件开发流程提供了一个新的思路:先写测试,用测试衡量代码。我参考了很多国内的资料,却没有发现一个比较好的将TDD应用在工程中的例子。这其中有很多原因,你可以找出很多很多理由来不写测试(UI测试不好写、产品需求容易改、维护麻烦等等),但是不可否认的是TDD提供的理念是有一定借鉴意义的。

我先将测试技术做一个系统的介绍,后面再来谈一些TDD的“哲学”。

Java单元测试简介

单元测试是指对软件中的最小可测试单元进行检查和验证,它满足:

  • 一致性 重复运行相同单元测试的结果一致。
  • 隔离性 单元测试只在一个小模块内进行,不与其他模块交互,也不影响其他单元测试。

单元测试主要的工作就是验证行为的结果。这个概念跟上文中说的触发动作期望结果是一致的。下面先介绍一些技术与术语:

JUnit

JUnit是一个Java的Unit测试库。它提供了一套比较成熟的单元测试解决方案,每个测试类里面有多个测试用例,并提供了用例前后的Hook、Rule等方便测试。值得一提的是JUnit3、4的区别非常大,在4.x中引入了大量的改进,我们可以用两段代码来感受一下:

JUnit3的测试代码

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
public class JUnit3Test extends TestCase {
@Override
public void setUp(){
//在每个测试方法执行之前要调用的代码
}
@Override
protected void tearDown() throws Exception {
//在每个测试方法结束后执行的代码
}
public void testCase1() {
//所有测试用例需要用test开头
}
// 如果期望一个测试用例内产生指定异常,只能这么写。
public void testException() {
try {
doSomething();
fail();
} catch (NullPointerException e) {
Assert.assertTrue(true);
}
}
private void doSomething() throws Exception{
throw new NullPointerException();
}
}

JUnit4的测试代码,有很多语法改进,并可以方便地设置期望Exception,并且有很多JUnit3无法做到的(Rule, Before/AfterClass, Timeout, static import)

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
public class JUnit4Test {
@Rule
public TemporaryFolder mFolder
= new TemporaryFolder(); //在每个测试方法执行前建立临时文件,结束后删除它。
@Before
public void before() {} //同JUnit3中setup
@After
public void after() {} //同JUnit3中的teardown
@BeforeClass
public static void beforeClass() { //在该类第一个测试方法执行前被调用,只会调用一次。
PowerMockito.mockStatic(ForMock.class);
}
@AfterClass
public static void afterClass() {} //在该类第一个测试方法执行后被调用,只会调用一次。
@Test(expected = NullPointerException.class,
timeout = 200)
public void testExceptionAndTimeout() { //所有测试类使用@Test注解
doSomething();
}
private void doSomething() throws NullPointerException {
throw new NullPointerException();
}
}

Mock

Mock是测试中非常经常使用到的一个术语,它的作用就如字面意思:“虚拟对象”。它有很多方面的作用,特别是在测试上。我们之前所说,测试就是需要列举出“触发动作”和验证”期望结果”。那么现在有两个问题:

  1. 通常我们在设计代码的时候,不会去保留操作结果,这会给程序带来非常多冗余的变量、代码逻辑。
  2. 触发动作经常是有一定上下文、环境依赖的,测试中难以模拟出这个情况。

为了应对这样的窘境,各种各样的Mock开源库出现了,举两个用得最多的例子:

  • Mockito 是一个开源的Mock库。提供了创建虚拟类(Mock)、局部扩展(Spy)、验证调用行为(Verify)的API。
  • PowerMock 是针对上述Mockito的拓展。它通过自定义的ClassLoader,能够达到它们无法做到的一些事情:针对静态类、final类、private方法的Mock与Verify。

这里需要注意,使用Mock之前应该仔细思考它的必要性,如果你花很多时间、大量代码来生成Mock、验证Mock对象的行为,这可能就偏离了测试的初衷,走入了“为测试而测试”的陷阱。你可能就要思考,为什么你的代码需要这么多Mock才能够被单元测试,会不会写的耦合度太高了?

好,言归正传,我们来简单看一下它们用法。想象这么一个场景,你的操作需要依赖时间,你要检查日期的改变、时间的流逝等相关的事情,那么你如果手工测试就非常尴尬,花费不必要的时间在等待时间上,而用PowerMock,你可以做到改变时间:

1
2
3
4
5
6
7
8
9
10
11
@RunWith(PowerMockRunner.class)
@PrepareForTest({System.class})
public class TimeUtilsTest {
@Before
public void setUp() {
Random random = new Random(RANDOM_SEED);
PowerMockito.mockStatic(System.class);
when(System.currentTimeMillis()).thenReturn(0l); //直接将System.currentTimeMillis设置为0.
}
}

关于Mockito的更多用法,可以参考我另一篇文章使用Mockito和Roboletric进行Android单元测试

断言

Assert,是单元测试中非常重要的环节之一。它是用于校验输出结果与期望结果的语法。最原始的Assert语法就是一个assert关键字后面跟个布尔值:

1
assert actual == expected;

后来大家发现这样太不好使了,于是JUnit对它做了一些封装:

1
2
3
4
5
6
7
8
9
10
// 8大基础类型/String/利用Object.equals()进行的Equals/NotEquals判断
Assert.assertEquals(expected, actual);
Assert.assertNotEquals(expected, actual);
// String/Object的相同实例判断
Assert.assertSame(expected, actual);
Assert.assertNotSame(expected, actual);
// 8大基础类型/String/Object的数组相同判断
Assert.assertArrayEquals(expectedArray, actualArray);

由于JUnit原生的Assert还是不够强大,只能判断等于、不等于的情况,它实际上只提供了很基础的相同值/实例的判断,这样代码可读性、易用性就会收到影响。所以各类开源库出现,对它进行了封装:

  • Hamcrest 是一个开源的Assert语法拓展库,它提供了方便、强大的情景匹配API,不仅支持Java,也支持其他多种语言。
  • AssertJ 是另一个开源的Assert语法的拓展库,它提供了各类方便的情景检查与链式调用的语法糖,易于拓展,用起来非常舒服。
  • AssertJ-Android 是Square针对AssertJ的一个拓展,提供了对Android各类控件的情景检查。

AssertJ与Hamcrest对于Assert的功能加强上区别不是很大,他们都提供了非常强大的情景Matcher,只不过两者的编程方式不太一样。你看段代码感受一下就知道了:

Hamcrest

1
2
3
4
5
6
7
8
9
10
11
import static org.hamcrest.Matchers.*;
@Test
public void testHamcrest() {
List<String> strings = new ArrayList<>();
strings.add("test");
MatcherAssert.assertThat(strings, not(empty()));
MatcherAssert.assertThat(strings, contains("test"));
MatcherAssert.assertThat(strings, hasSize(1));
MatcherAssert.assertThat(strings, is(instanceOf(ArrayList.class)));
}

Hamcrest的断言方式就是assertThat(something, condition),我们看看AssertJ:

AssertJ

1
2
3
4
5
6
7
8
9
10
@Test
public void testAssertJ() {
List<String> strings = new ArrayList<>();
strings.add("test");
Assertions.assertThat(strings)
.isInstanceOf(ArrayList.class)
.isNotEmpty()
.contains("test", Index.atIndex(0))
.hasSize(1);
}

AssertJ写起来也很舒服,但是优越之处就是它封装了更多基础类型的使用,这是Hamcrest没有的,可以说使用起来更简单。

覆盖率

覆盖率(Coverage) 即跑一遍单元测试,统计覆盖到的各类代码的指标。简单来说有这么几种:

  • 函数覆盖率 一个类里面被单元测试调用到的函数在它所有函数中的占比。
  • 语句覆盖率分支覆盖率 一个类中,单元测试所覆盖的语句、分支在这个类的语句、分支中的占比。举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int func(int a, int b) {
int result = 0;
if(a > b) {
result = a;
} else if(a < b) {
result = b;
} else if(a == b) {
result = -1;
}
return result;
}
//测试代码
public int testFunc() {
Assert.assertEquals(func(1, 0), 1);
Assert.assertEquals(func(1, 1), -1);
}

上面的代码中我们只测试了(a > b) 和 (a = b)的情况,那么(a < b)这个if分支就没有走,并且它涉及到的语句也没有走。即它们没有被覆盖。

Java导出测试覆盖率的工具使用最多的是Jacoco, Intelij Idea(Android Studio)与Eclipse都有相应的工具。在Eclipse上可以用Eclmma,而Intellij Idea已经将它内置为IDE的一部分。

值得一提的是AndroidTest无法直接用工具测试覆盖率,不过它的gradle插件内置了Jacoco。对于AndroidTest覆盖率,可以通过在build.gradle中添加

1
2
3
4
5
6
7
android {
buildTypes {
debug {
testCoverageEnabled = true
}
}
}

之后运行命令行./gradlew createDebugCoverageReport即可导出report到build/report路径下。

最后,覆盖率其实有的时候不能说明什么,测试覆盖率高的代码不一定就是很稳定的代码,这点肯定要清楚。写了很多测试,调用了很多工程代码,最后可能然并卵。但是有一点是认可的,未覆盖率 能帮助你对代码的回归思考:

如果你写测试都不测这个函数,那它会不会有问题?那它是不是真的会用到?

如果真的打算写测试,它是可以帮助你思考的一个指标。

Android 测试概览

在Android上的测试技术总的来说可以分为两种:

  • 本地测试(Local Unit test) 跑在JVM上的单元测试,在/src/test/java下的代码。、配合Robolectric也可以跑部分Android相关的代码测试。容易书写,容易运行。
  • Instrument 测试 跑在Android设备上的测试,在/src/androidTest/java下的代码。默认使用junit3的写法,使用AndroidJUnit4可以使用junit4的单元测试(这也是官方推荐的做法)。(之间区别可以参考junit3 vs junit4)。

可以简单用一张图来总结:

AndroidTest

那来说说它们的各类应用场景吧。

Roboletric

roboletric的出现让一些仅跟UI交互的Android代码变得非常容易测试,它能够让一些仅依赖UI、对于Android设备环境没有强需求的模块(GPS、电池状态、跨App请求等)能够直接在JVM上跑起部分Android相关的测试代码

它的用法可以参考使用Mockito和Roboletric进行Android单元测试

值得一提的是,Robolectric同样也用到了自定义ClassLoader,配合PowerMock使用时需要参考Wiki

Android Instrument 测试

Instrument测试是在Android设备上跑的测试,它运行在与目标App同一个进程中。它能够为你提供各类Android设备、运行时、多应用交互相关的API,这是Local Unit测试无法比拟和提供的。主要的测试手段是EspressoUIAutomator

注意:它默认只支持JUnit3的写法,通过Support库可以支持JUnit4. 通过添加如下依赖:

1
2
3
4
dependencies {
androidTestCompile 'com.android.support.test:runner:0.4'
androidTestCompile 'com.android.support.test:rules:0.4'
}

同时在Gradle里面指定用JUnitRunner运行Instrument:

1
2
3
4
5
android {
defaultConfig {
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
}

然后在你要测试的类上加@RunWith(AndroidJUnit4.class)注解,即可使用JUnit4的功能。

AndroidJUnit4

引入JUnit4不仅让你能够使用它的一些优点,同时能享受一些引入的rule:

  • ActivityTestRule<Activity.class> 这个Rule能在你的测试方法运行前启动指定Activity,然后在测试方法结束后将Activity手动finish掉,主要是保证了Activity的启动与不影响其他测试
  • ServiceTestRule 在测试中通过它启动的Service能够保证在测试方法运行时Service被启动or绑定,并且在测试方法结束后将Service解绑or销毁。
  • UiThreadTestRule 可以让你在任何时候使用mRule.runOnUIThread()在UI线程上运行任务。

Espresso

Espresso是在Instrument测试提供了强大的UI测试框架的库,它能够做到:

  1. UI控件的查找匹配;
  2. 各类动作的触发与校验;
  3. Intent的检测与模拟。

你只需要额外引入依赖:

1
androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2'

即可使用Espresso.

具体的使用方法可以参考Android官网

Espresso vs Robolectric

大致的一看,Robolectric的功能和Espresso的功能区别不太大,那么我们应该如何选择两个测试框架呢?

Robolectric比Espresso的好用之处就是两点:

  1. 无需实机测试,JVM上就能跑;
  2. 集成CI自动化测试很方便;

相应的,也有一些缺点:

  1. 无法覆盖实机功能关联的API(相机、GPS、Activity之间交互);
  2. 不是实机环境,Mock太多,做除UI外的太复杂的测试时不能令人信服。

我认为在你在做一些纯UI测试、对Android运行时环境要求不高的时候,可以使用Robolectric,这时候它很好用,也很好写。但是其他情况时最好使用Espresso,它更加值得信赖。

UIAutomator

它同样也是用于UI测试的框架,但是不同于Espresso的是,它的目的主要是App与设备、触屏动作(拖动滑动)、其他应用之间的交互。

我们可以通过添加如下依赖来使用UIAutomator:

1
androidTestCompile 'com.android.support.test.uiautomator:uiautomator-v18:2.1.1'

具体的使用方法可以参考Android官网

TDD的基本原则

完善测试开发流程比测试技术本身要重要很多倍。

在由产品主导业务功能、项目迭代周期快、强大的手工QA团队的现状下,程序员自己写测试用例的必要性还有吗?答案是肯定有的。团队中肯定有写一些类库、公共组件,这些库往往是纯JAVA(如字符串、文件IO、线程库)或Android的sdk(网络、下载、账号、图片加载)等。它们与业务、UI关系不大,在界面上又很难以有所体现,手工QA团队很难验证。我相信在这时候,一个基于TDD的开发流程是会有一些帮助的。单元测试的通过让你有信心将这些库的改动引入到项目中。

在下面的介绍中,你会发现测试代码都是非常容易去写的,开源界也涌出了各类测试框架方便开发者写测试用例。但是在开始写测试用例之前,需要 想清楚为什么要做测试。你可能不需要完全遵守TDD的流程,但是如果仅仅注重测试环节,而不思考更深层次的意义,可能导致:

  • 代码很难被测试;
  • 仅仅为了写测试而写测试,测试效果不大;
  • 测试通过了,仍然有问题!
  • 测试覆盖度很高,但是代码质量依然很低。

在这个过程中,你会发现TDD是非常有工程学上的借鉴意义的。我认为TDD的几个核心原则就是:

分析&先写测试

你需要对新增的功能点进行系统分析,拆分每个功能点里面的正常、异常情况。每一个功能点,都应该是有一个或多个对应的触发动作期望结果的。如果你连触发改动的动作与期望结果都无法准确地描述出来,那么只能说明你不熟悉新增的功能,或者这个功能点不完善。在系统分析之后,写下测试用例。

依赖倒置原则(DIP)

很多人看到“先写测试”这一点的时候,都很难去想象如何在没有实现细节的时候去写测试。“我都没有代码,如何写测试?”,这可能是最直观的感受。
当然,完全不写代码,就写测试用例是不可能的。但是在只声明接口行为,不写具体实现,此时去写测试用例,是很容易做到的。这时候依赖倒置原则就有比较大的用武之地,你的功能应该依赖上层接口,这样很容易造出一个空实现的架子,用于测试用例。

快速写测试、运行测试

市面上有五花八门的测试技术与框架,各类的mock横行霸道。但是我们应该注意一点:我们写的测试不能是脆弱、编写成本高的。如果某个功能点,我们辛辛苦苦写了很多测试,但是下一个版本迭代,所有的测试用例都被推翻,那么我们可能就要考虑测试的必要性或者是不是测试的方法不对了。或者如果测试非常难写(相对于手工测试来说)、测试点是不可重复运行的,那都可以考虑不写测试。

关于项目与工程的应用上,更多还是应该结合项目自身来适应。

在此特别推荐一个博客系列:World-Class Testing Development Pipeline for Android。讲述了Karumi团队在做Android 自动化测试时候的一些思考与碰到的问题,如果你希望写好测试用例,十分建议一读。

英语不好的同学可以读Mark大神的翻译

参考文章: