Android 单元测试初探【译】

本文翻译自 Simple unit tests for Android,如能力允许,建议点击原文阅读。

如何测试 Android App 逻辑,是时候向大家分享这个主题了。长久以来,我都没意识到它是 Android App 开发中的一个重要组成部分。但是,学习永远不嫌晚,无论是你,亦或是我 :)

概述

首先,让我们来了解一下单元测试是什么?以及为什么我们需要编写单元测试。

以下是维基百科关于单元测试的定义:

Unit testing is a software testing method used for testing individual units of source code in order to determine whether they are fit for use.

The idea is to write code for each non-trivial function or method. It allows you to relatively quickly check if the latest change in code causes regression, i. e. new errors appear in the part of the program that was already tested, and makes it easy to identify and eliminate such errors.

译文:

单元测试是一种软件测试方法,用于测试独立的源代码单元,来确定这些代码是否满足需求。
这种思想是为每个重要的功能或方法写一段测试代码。它使得你能相对快速地检查出最新的代码改动是否导致了回归(regression)。例如,新的错误出现在已经测试过的程序中,并且会使得发现和解决这些错误变得更加容易。

简单地说,单元测试就是检验你的代码效率的一系列方法。但是它是怎么工作的呢?就让我们从头开始说起吧。如果没有错误发生的话,测试就认为是完成了的。而且对于各种各样的检验来说,我们使用类似于 assertXXX “家族”这样的补充方法来进行测试。(详情请看下面的示例)

Android 有 2 种单元测试方法:

  • Local unit tests:这是只用 JVM 来执行的测试,主要是用来测试不与操作系统交互的业务逻辑。
  • Instrumented unit tests:这是用于测试与 Android API 互联的逻辑。它们是在物理设备或者模拟器上执行的,所以会比 Local unit texts 花费更多时间。

那么我们就有 2 种测试方法了,至于哪一种方法就基于我们要测试的逻辑目标。当然,如果可能的话,最好是写本地测试。

此外,在创建测试时,请注意包目录结构,以下这样的结构是很实用的:

  • app/src/main/java:app 源代码
  • app/src/test/java:local tests 测试代码
  • app/src/androidTest/java:instrumented tests 测试代码

配置

Android Studio 在创建项目的时候,会自动生成上述的目录结构,而如果因为某些原因,你没有使用 Android Studio 来构建项目,那么你就要记住上述的目录结构,并自己创建了。

此外,在 AS 中配置 app module 的build.gradle文件:( Android Studio 创建项目时已自动添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
...
defaultConfig {
...
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
...
}

dependencies {
...
testCompile 'junit:junit:4.12'
}

另外还有个流行框架,它可以让我们写测试流程更加容易,那就是 Mockito 框架。它的特性包括创建模拟对象(译者注:后文直接称 mock 对象)、跟踪对象,并且也有进行结果验证的辅助工具。要使用它的话,只需要加上一行依赖:

1
testCompile 'org.mockito:mockito-core:1.10.19'

Local tests in Android

现在让我们来看 2 个非常简单的单元测试例子,它们用来验证 2 个对象是否一致(在我们的例子中是两个数值对象):

1
2
3
4
5
6
7
8
9
10
11
12
public class ExampleUnitTest {

@Test
public void addition_correct() throws Exception {
assertEquals(4, 2 + 2);
}

@Test
public void addition_isNotCorrect() throws Exception {
assertEquals("Numbers isn't equals!", 5, 2 + 2);
}
}

在 local test 包(即 app/src/test/java/packageName 包)下面新建一个类,然后放入我们的测试方法。为了让 JUnit 知道这些方法是测试方法,我们需要给他们添加 @Test 注解标记。如果第 1 个参数(期望值)和第 2 个参数(结果值)不一致的时候,assertEquals() 方法会显示 AssertionError 错误。

我们来运行测试方法(右击类名,在弹出的菜单中选择 Run ‘ExampleUnitTest’),并查看输出结果:

从上图可以看到,第一个方法正确执行了,但是第二个方法由于 5 != (2 + 2) 所以会有报错信息。

你也可以使用 expected 参数来配置一个预期的异常( expected Exception ):

1
2
3
4
5
@Test(expected = NullPointerException.class)
public void nullStringTest() {
String str = null;
assertTrue(str.isEmpty());
}

在这个例子中,因为我们预期的就是这个异常(NullPointerException),所以测试将被执行。

你也可以为耗时操作指定 timeout 参数,并以毫秒为单位定义该值。如果方法没有在指定时间内执行完,将会被判定为失败。

1
2
3
4
@Test(timeout = 1000)
public void requestTest() {

}

另外还有一个非常有趣的 Matchers 机制。比如说,assertThat(T actual, Matcher<? super T> matcher) 接收它们做为输入。它们由 org.hamcrest.CoreMatchers 类中的方法返回,属于重叠逻辑操作。下面我们来看几个例子:

1
2
3
assertThat(x, is(3));
assertThat(x, is(not(4)));
assertThat(list, hasItem("3"));

正如它们的名字所暗示的,is() 可以描述为 “等于”,is(not()) 就是 “不等于”,而 hasItem() 就是检验某些元素是不是在 list 中。并且它们都可以用一个连续的句子读出来。在这里你可以找到一个完整的 matchers 列表(list of matchers)

目前为止,我们已经看到了一些简单的例子,可以用来编写简单的测试。但接下来,我还会带大家来看看 Mockito 库,它可以让我们的测试变得更优秀。

正如前面介绍过的,Mockito 用于创建所谓的 mock 对象(mock objects)。这些对象的目的是代替那些没办法测试、或者不适合测试的复杂对象。有两个方法可以声明 mock 对象:

1
List mockedList = mock(List.class);

或者:

1
2
@Mock
List mockedList;

记住,要使用 @Mock 注解的话,你必须使用 @RunWith(MockitoJUnitRunner.class) 注解来标记你所定义的类,或者在 @Before 方法中调用 MockitoAnnotations.initMocks(this);

1
2
3
4
@Before
public void init() {
MockitoAnnotations.initMocks(this);
}

创建完 mock 对象后,你就可以见证奇迹了!比如说,要从字符串资源文件中重写 app name 的话,你就可以这样做:

1
2
when(mockContext.getString(R.string.app_name))
.thenReturn("Fake name");

现在,当我们调用 getString(R.string.app_name) 方法的时候,将会返回 “Fake name”,即使它并没有被隐式地调用:

1
2
SomeClass obj = new SomeClass(mockContext);
String result = obj.getAppName();

译者注:

1
2
3
4
5
6
7
8
9
10
11
12
public class SomeClass {

private Context mContext;

public SomeClass(Context context) {
this.mContext = context;
}

public String getAppName() {
return mContext.getString(R.string.app_name);
}
}

但是如果我们要重写所有的字符串资源呢?不可能让我们给每一个字符串都做一次这样的操作吧!当然不会,肯定有别的办法,那就是 anyXXX(), anyInt(), anyString() 等等这些方法。现在,如果你把 R.string.app_name 替换为 anyInt()(不要忘了,在 Android 里面所有的资源都是 Integer 类型的),那么所有的字符串都会被替换为我们定义的字符串。
这时候,我们就可以在测试的最后,使用 assertThat() 来检验结果是否正确:

1
assertThat(result, is("Fake name"));

如果要移除 Exception 的话,我们可以使用 when(...).thenThrow(...);
还有一个 verify() 方法,如果在它之前没调用指定的方法,测试就会失败。我们来看下代码:

1
2
3
4
5
mockedList.add("Useless string");
mockedList.clear();

verify(mockedList).add("one");
verify(mockedList).clear();

它的工作原理是这样的:我们传递 mock 的 list 给 verify(mockedList) 方法,然后指定我们关注的方法。在这个例子中,我们的指定方法是 添加 string清除 list。我相信,这是一个很重要的工具! :)

但是,当你使用 spy 对象的时候,这个方法(verify 方法)的所有潜能将会展现出来。它们的主要区别在于,跟 mocks 不一样的是,它们并不直接被创建出来。你可能会问:“那到底关键点是什么呢?” 关键在于,通过创建一个 “spy” 对象,你可以像观察一个假对象一样观察它:

1
2
3
4
5
6
7
List spyList = spy(new LinkedList());

spyList.add("one");
spyList.clear();

verify(spyList).add("one");
verify(spyList).clear();

Instrumented tests in Android

使用这种类型的测试,我们可以获取到真正的 context,以及所有的 Android API 功能。除此之外,还有一个 “上帝模式”,我们可以用来管理 activity 的生命周期。为了让测试被识别为 instrumented 测试,需要使用相应的注解来标记类:

1
2
3
4
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
...
}

而测试方法本身还是像 local tests 一样来标记,使用 @Test 注解。我们来看看,context 的包是否对应于我们的 app:

1
2
3
4
5
@Test
public void useAppContext() throws Exception {
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.stfalcon.demoapp", appContext.getPackageName());
}

使用下面的方法,你可以监听 Activity 的状态:

1
2
3
4
5
6
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(new ActivityLifecycleCallback() {
@Override
public void onActivityLifecycleChanged(Activity activity, Stage stage) {
//do some stuff
}
});

甚至可以管理它们的状态:

1
2
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
instrumentation.callActivityOnCreate(activity, bundle);

总结

这些可能是我在熟悉这个主题时发现的最重要的东西。不要朝我扔石头 :) 但我觉得对于初学者来说,这些已经足够入门了。如果你想深入了解 JUnit 的功能,建议你阅读 JUnit 官方文档,另外还有 instrumented test 的功能。

参考资料

Getting Started with Testing
JUnit4 官方 Wiki
JUnit 入门教程(极客学院 Wiki)
邹小创的 Android 单元测试系列

PS:欢迎关注 SherlockShi 个人博客

感谢你的支持,让我继续努力分享有用的技术和知识点!