关于安卓单元测试,你需要知道的一切

前言

该文为自己在公司做Android单元测试分享时的概要描述,可能缺少一些必要的描述,后续整理

什么是单元测试

一般的定义,尤其是是在OOP领域,是一个类的一个方法。在此,我们也这样理解:单元测试,是为了测试某一个类的某一个方法能否正常工作,而写的测试代码。
常见的Java单元测试框架有JUnit、TestNG等等。我们使用Junit。

单元测试不是集成测试

集成测试也是有他的必要性的,然而这不是我们每个程序员应该花多少精力所在的地方。

集成测试的缺点:
集成测试设置麻烦,运行速度慢,发现bug少,在保证代码质量,改善代码设计方面起不到作用。集成测试往往不能测试到其中每个环节的每个方面,某一个集成测试通过了,不代表另一个集成测试也能运行正确。而单元测试会比较完整地测试每个单元各种不同状况、临界条件等等。一般来说,如果每个环节都对的,那么很大概率上,整个流程就是对的。

再来谈谈为什么

  • 对软件质量的提升
  • 方便重构
  • 节约时间
  • 提升代码设计
  • 。。。

自己为什么写单元测试?

  • 如果有一个工具可以告诉自己每次写的新代码或者改动的代码哪里会有问题,可以为自己代码的质量提供保障,那就可以增加自己对所写代码的信心
  • 可以让你摆脱底层与后台各种外部依赖,在你写完代码之后就能马上知道各种情况下,这段代码会发生什么情况
  • 运行单元测试代码比运行一个整个app所花费的时间要少很多很多
  • 代码测试代码,可以更快地发现bug
  • 更好的设计
  • 节约时间(学习成本+项目改造+测试代码编写换来的是更少的改bug时间,更少的测试时间,更稳定也更易于维护的项目,更优美的代码结构,而且最大的时间成本只在于实行的前期)
  • 大牛都写单元测试

最后谈谈怎么测

两种函数(方法),两种不同的测试方式

一个类的方法可以分为两种,一种是有返回值的,另一种是没有返回值的。

这个方法是void的,那么怎么验证这个方法是正确的呢?其实仔细想想,这个方法也是有输出的,它的输出就是,调用了mUserManager的performLogin方法,同时传给他两个参数。所以只要验证mUserManager的performLogin方法得到了调用,同时传给他的参数是正确的,就说明这个方法是能正常工作的。
那怎么样验证这个Activity的login()方法,会调用mUserManager的performLogin方法呢?

Mock以及Mockito的使用

然而我们要验证的是LoginPresenter里面的mUserManager这个对象,但是现在我们没有办法获得这个对象,因为mUserManager是private的,怎么办?

当然,如果你的正式代码里面没有任何地方用到了那个setter的话,那么专门为了测试而增加了一个方法,毕竟不是很优雅的解决办法,更好的解决办法是使用依赖注入,简单解释就是把UserManager作为LoginPresenter的构造函数的参数。

这样的改造值不值得?
总体来说,我认为是值得的,因为这可以让这个类变得可测,也就意味着我们可以验证这个类的正确性,更给以后重构这个类有了保障,防止误改错这个类等等。因此,很多时候,如果你为了做单元测试,不得已要给一些类加一些额外的代码。那就加吧!毕竟优雅不能当饭吃,而解决问题、修复bug可以,做出优秀的、少有bug的产品更可以!

Spy

mock有两大作用:验证方法调用;指定某个方法的返回值,或者是执行特定的动作。而如果不指定的话,一个mock对象的所有非void方法都将返回默认值:int、long类型方法将返回0,boolean方法将返回false,对象方法将返回null等等;而void方法将什么都不做。
然而很多时候,你希望达到这样的效果:除非指定,否者调用这个对象的默认实现,同时又能拥有验证方法调用的功能。这正好是spy对象所能实现的效果。

依赖注入,将mock方便的用起来

一般来说,我们正式代码里面是不会去调用这个setter,修改UserManager这个对象的。因此这个setter存在的意义就纯粹是为了方便测试。这个虽然不是没有必要,却不是太好看,因此在有选择的情况下,我们不这么做。在这里,我们介绍依赖注入这种模式。
在依赖注入(Dependency Injection)的概念里,假如你的代码,一个类用到了另外一个类,那么前者叫Client,后者叫Dependency。这个概念本身跟dagger2,RoboGuice这些框架并没有什么关系,实现依赖注入很简单,dagger这些框架只是让这种实现变得更加简单,简洁,优雅而已。

DI的常见实现方式

一种是setter injection,如之前的代码;通过方法的参数传递进去(argument injection),也是实现DI的一种方式。

然而更常用的方式,是将Dependency作为Client的构造方法的参数传递进去(Constructor Injection):

其实一般来说,提到DI,指的都是这种方式。这种方式的好处是,依赖关系非常明显。你必须在创建这个类的时候,就提供必要的dependency。这从某种程度上来说,也是在说明这个类所完成的功能。因此,尽量使用Constructor injection。
说到这里,你可能会有一个疑问,如果把依赖都声明在Constructor的参数里面,这会不会让这个类的Constructor参数变得非常多?如果真的发生这种情况了,那往往说明这个类的设计是有问题的,需要重构。为什么呢?我们代码里面的类,一般可以分为两种,一种是Data类,比如说UserInfo,OrderInfo等等。另外一种是Service类,比如UserManager, AudioPlayer等等。所以这个问题就有两种情况了:

  • 如果Constructor里面传入的很多是基本类型的数据或数据类,那么或许你要做的,是创建一个(或者是另一个)数据类把这些数据封装一下。这点请参考Martin Fowler的《重构》第十章“Introduce Parameter Object”。
  • 如果传入的很多是service类,那么这说明这个类做的事情太多了,不符合单一职责的原则(Single Responsibility Principle,SRP),因此,需要重构。

对于Dagger2:

  • 一个灵活的,易于测试的,符合SRP的,结构清晰的项目,关键在于要应用依赖注入这种模式,而不是用什么来做依赖注入。
  • 等你学会使用dagger以后,要记得在测试的时候,如果可以直接mock dependency并传给被测类,那就直接创建,不是一定要使用dagger来做DI

Dagger2

为什么我们要用Dagger2

在Dagger2里面,负责生产这些Dependency的统一工厂叫做 Module ,所有的Client最终是要从Module里面获取Dependency的,然而他们不是直接向Module要的,而是有一个专门的“工厂管理员”,负责接收Client的要求,然后到Module里面去找到相应的Dependency,提供给Client们。这个“工厂管理员”叫做 Component。

Module

这种用来生产Dependency的、用@Provides修饰过的方法叫Provider方法。当Client向管理员(Component)索要一个Retrofit的时候,Component会自动找到Module里面找到生产Retrofit的这个 provideRetrofit(OkHttpClient okhttpClient)方法,找到以后试图调用这个方法创建一个Retrofit对象,返回给Client。但是调用这个方法需要一个OkHttpClient,于是Component又会去找其他的provider方法,看看有没有哪个会生产OkHttpClient。于是就找到了上面的第一个provider方法: provideOkHttpClient()。找到以后,调用这个方法,创建一个OkHttpClient对象,再调用 provideRetrofit(OkHttpClient okhttpClient)方法,把刚刚创建的OkHttpClient对象传进去,创建出一个Retrofit对象,返回给Client。当然,如果最后找到的 provideOkHttpClient()方法也需要其他参数,那么管理员还会继续递归的找下去,直到所有的Dependency都被满足了,再一个一个创建Dependency,然后把最终Client需要的Dependency呈递给Client。
注意:provideSharedPreferences(Context context)需要一个context对象。

但是这种方法不是很好,为什么呢,因为context的获得相当于是写死了,只能从MyApplication.getContext(),如果测试环境下想把Context换成别的,还要给MyApplication定义一个setter,然后调用MyApplication.setContext(…),这个就绕的有点远。更好的做法是,把Context作为 AppModule的一个构造参数,从外面传进来(DI模式)

Dependency工厂管理员:Component

Component给Client提供Dependency的方法

一般来说,有两种,当然总共不止这两种,只不过这两种最常用,也最好理解,一般来说用这两种就够了。

方法一:在Component里面定义一个返回Dependency的方法

Dagger2的工作原理是,在你的java代码编译成字节码的过程中,Dagger2会对所有的Component(就是用 @Component修饰过的interface)进行处理,自动生成一个实现了这个interface的类,生成的类名是Component的名字前面加上“Dagger”。比如我们定义的 AppComponent,对应的自动生成的类叫做DaggerAppComponent。我们知道,实现一个interface需要实现里面的所有方法,因此,DaggerAppComponent是实现了 loginPresenter();这个方法的。实现的方式大致就是从 AppComponent管理的 AppModule里面去找LoginPresenter的Provider方法,然后调用这个方法,返回一个LoginPresenter。
因此,使用这种方式,当Client需要Dependency的时候,首先需要用DaggerAppComponent这个类创建一个对象,然后调用这个对象的 loginPresenter()方法,这样Client就能获得一个LoginPresenter了。

使用Dagger2的好处:

  • 自动创建真正需要的对象,依赖管理更加简单安全,不用再考虑依赖的先后顺序,Dagger2自动完成
  • 当依赖关系改变时,只需要改变Module里面的方法比如说UserManager不再使用SharedPreference,而是使用database,这个时候UserManager的构造函数里面少了一个SharedPreferences,多了一个DatabaseHelper这样的东西,那么如果使用正常的方式管理Dependency,所有new UserManager的地方都要改,而是用Dagger2,你只需要在 AppModule里面添加一个DatabaseHelper Provider方法,同时把UserManager的provider方法第一参数从SharedPreferences改成DatabaseHelper就好了,所有用到UserManager的地方不需要做任何更改,LoginPresenter不需要做任何更改,LoginActivity不需要任何更改。
    *这种把问题(我们这里是依赖关系)描述出来,而不是把实现过程写出来的编程风格叫声明性编程(Declarative programming),跟它对应的叫命令式编程(Imperative Programming),相对于后者,前者的优势是:可读性更高,side effect更少,可扩展性更高等等。这是一种编程风格,跟语言、框架无关。当然,有的语言或框架天生就能让程序员更容易的使用这种style来编程。这方面最显著的当属Prolog。对于Java或Android开发者来说,想让我们的代码更加declarative,最好的方式是使用Dagger2和RxJava。
方法二:Field Injection

DaggerAppComponent实现这个方法的方式是,去LoginActivity里面所有被 @Inject修饰的field,然后调用 AppModule相应的Provider方法,赋值给这个field。这里需要注意的是,@Inject field不能使private,不然Dagger2找不到这个field。
通常来说,这种方式比第一种方式更简单,代码也更简洁。假设LoginActivity还需要其他的Dependency,比如需要一个统计打点的Dependency(StatManager),那么你只需要在AppModule里面定义一个Provider方法,然后在LoginActivity里面声明另外一个field就好了:

不过,需要注意的一点是,这种方式不支持继承,比如说LoginActivity继承自一个 BaseActivity,而@Inject StatManager mStatManager;是放在BaseActivity里面的,那么在LoginActivity里面调用 appComponent.inject(this);并不会让BaseActivity里面的 mStatManager得到实例化,你必须在 BaseActivity里面也调用一次appComponent.inject(this);。
@Singleton

*如果你不需要做单元测试,而只是使用dagger2来做DI,组织app的结构的话,其实AppModule里面的很多Provider方法是不需要定义的(Constructor Injection)。

Dagger2在单元测试里面的使用

理论上来说,DaggerAppComponent对象整个app只需要一份就好了。所以我们在用dagger2的时候,一般的做法是,我们会在app启动的时候创建好,放在某一个地方。比如,我们在自定义的Application#onCreate()里面创建好,然后放在某个地方,我个人习惯定义一个类叫ComponentHolder,然后放里面:

假设LoginActivity有两个EditText和一个Login Button,点击这个Button,将从两个EditText里面获取用户名和密码,然后调用LoginPresenter的Login方法:

单元测试里面,不要滥用dagger2

如果被测类(比如说LoginActivity)的Dependency(LoginPresenter)是通过 field injection inject进去的,那么再测这个类(LoginActivity)的时候,就必须用dagger2,不然很难优雅的把mock传进去。相反,如果被测类有Constructor(比如说LoginPresenter),Dependency是通过Constructor传进去的,那么就可以不使用Dagger2,而是直接new对象出来测。
应该说,DI是一种很好的模式,哪怕不做单元测试,DI也会让我们的app的架构变得干净很多,可读性、维护性和可拓展性强很多,只不过单元测试让DI的必要性变得更加显著和迫切而已。而Dagger2的作用,或者说角色,在于它让我们写正式代码的时候使用DI变得易如反掌,程序及其简洁优雅可读性高。同时,它在某些情况下让原来很难测的代码变得易于测试。

Robolectric,在JVM上调用安卓的类

Android是对单元测试最不友好的环境
通过实现一套JVM能运行的Android代码,然后在Unit Test运行的时候去截取Android相关的代码调用,然后转到他们实现的代码去执行这个调用的过程。除了实现Android里面的类的现有接口,Robolectric还做了另外一件事情,极大地方便了Unit Testing的工作。那就是他们给每个Shadow类额外增加了很多接口,可以读取对应的Android类的一些状态。比如我们知道ImageView有一个方法叫setImageResource(resourceId),然而并没有一个对应的getter方法叫getImageResourceId(),这样你是没有办法测试这个ImageView是不是显示了你想要的image。而在Robolectric实现的对应的ShadowImageView里面,则提供了getImageResourceId()这个接口。你可以用来测试它是不是正确的显示了你想要的Image。

Robolectric就是一个能够让我们在JVM上跑 测试 时够调用安卓的类的框架,至于我们是拿它来做单元测试还是集成测试,完全取决于我们自己。而回到我们强调的 单元测试,测一个小的独立的代码单元,Robolectric的角色,应该是一个让我们在做 单元测试 的过程中,能够调用安卓的类,测试安卓的类,把安卓的类当做普通的纯java类的一个framework,仅此而已。

Junit Rule的使用

一个JUnit Rule就是一个实现了TestRule的类,这些类的作用类似于@Before、@After,是用来在每个测试方法的执行前后执行一些代码的一个方法。JUnit Rule还能做一些@Before这些Annotation做不到的事情,那就是他们可以动态的获取将要运行的测试类、测试方法的信息。
多用组合,少用继承

怎么用JUnit Rule

使用框架自带的Rule

很多测试框架比如JUnit、Mockito自带给我们很多已经实现过好了的JUnit Rule,我们可以直接拿来用。比如Timeout,TemporaryFolder,等等。这些Rule的使用方法非常简单。

那么,对于上面这个ExampleTest的每一个测试方法。它们的运行时间都不能超过1秒钟,不然就会标志为失败。而它的实现方式就是在每个方法测试之前都会记录一下时间戳,然后开始倒计时,1秒钟之后如果方法还没有运行结束,就把结果标记为失败。
这里需要注意的一点是Rule需要是public field

实现自己的Rule

简单来说,写一个Rule就是implement一个TestRule interface,实现一个叫apply()的方法。这个方法需要返回一个Statement对象。

使用Mockito Annotation快速创建Mock

在测试方法运行之前,自动把用@Mock标注过的field实例化成Mock对象,这样在测试方法里面就可以直接用了,而不用手动通过Mockito.mock(YourClass.class)的方法来创建。这个实现化的过程是在MockitoRule这个Rule里面进行的。不适用MockitoRule也可以用下面的方式:

使用@InjectMocks

不推荐这种方式,依赖注入的方式存在优先级,容易产生错误

使用@Spy创建Spy对象

如果PasswordValidator没有默认无参的构造方法,那么@Spy PasswordValidator spyValidator;这个方式是会报错的。

DaggerMock

用处:让Dagger2与单元测试的结合更加简洁方便

The Old Way

首先,你要mock出一个Module,让它的某个Provider方法在被调用的时候,返回你想到的mock的Dependency。然后使用这个mock的Module来build出一个Component,再把这个Component放到你的ComponentHolder。

自己简化一下:

如果能有一个工具,能达到这样的效果就好了:我们在Test类里面定义一个@Mock field(比如上面的loginPresenter),这个工具就能自动把这个field作为Dagger2的Module对应的provider方法(provideLoginPresenter(…))的返回值。也就是说,自动的mock对应的Module,让它返回这个@Mock field,然后用这个mock的Module来build一个component,并放到ComponentHolder里面去。

New Hope(DaggerMock)

起作用的是@Rule public DaggerRule daggerRule = new DaggerRule(); 这行代码。可见,它是通过JUnit Rule来实现的。
工作原理:

  1. 初始化一个测试类里面的所有用@Mock field为mock对象(loginPresenter)
  2. mock AppModule,通过反射的方式得到AppModule的所有provider方法,如果有某个方法的返回值是一个LoginPresenter,那么就使用Mockito,让这个方法(provideLoginPresenter(…))被调用时,返回我们在测试类里面定义的mock loginPresenter。
  3. 使用这个mock AppModule来构建一个Component,并且放到ComponentHolder里面去。

疑问:

  1. 它怎么知道要使用AppModule
  2. 它怎么知道要build什么样的Componant
  3. 它怎么知道要把build出来的Component放到哪?

其实上面的DaggerRule,不是DaggerMock这个library自带的,是我们自己实现的。DaggerMock给了我们提供了一个父类Rule:DaggerMockRule,这个Rule已经帮我们做了绝大多数事情了。我们自定义的DaggerRule,其实也是继承自DaggerMockRule的,而我们在自定义Rule里面做的事情,也只不过是告诉DaggerMock,上面说到的三个问题的答案:要使用哪个Module、要build哪个Component、要把build好的Component放到哪,仅此而已。

一般来说,一个Component类对应于一个这样的DaggerRule就好了。自此,你可以只负责使用@Mock来定义mock了,Dagger2的事情就交给这个DaggerRule就好了。

异步代码怎么测试

要测试异步代码,有两种思路,一是等异步代码执行完了再执行assert操作,二是将异步变成同步。

思路1,等待异步代码执行完毕:使用CountDownLatch

CountDownLatch是一个类,它有两对配套使用的方法,那就是countDown()和await()。await()方法会阻塞当前线程,直到countDown()被调用了一定的次数,这个次数就是在创建这个CountDownLatch对象时,传入的构造参数。

基本上,所有有Callback的异步,包括RxJava(Subscriber其实就相当于Callback的角色),都可以使用这种方式来做测试,不论内部是通过什么样的方式来实现异步的。不过,使用CountDownLatch来做单元测试,有一个很大的限制,那就是countDown()必须可以在测试代码里面写,换句话说,必需有Callback。如果被测的异步方法(比如上面的loadRepos())不是通过Callback的方式来通知结果,而是通过post EventBus的Event来通知外面方法运行的结果,那CountDownLatch是无法解决这个异步方法的单元测试问题的。
此外,CountDownLatch还有一个缺点,那就是写起来有点罗嗦,创建对象、调用countDown()、调用await()都必须手动写,而且还没有通用性,你没有办法抽出一个类或方法来简化代码。

思路2,将异步变成同步

将异步变成同步也是解决异步代码测试问题的一种比较直观的思路。使用这种思路的主要手段是依赖注入,但是根据实现异步的方式不同,也有一些其它的手段。下面介绍几种常见的异步实现,以及相应的单元测试的方法。

直接new Thread的情况

如果你直接在正式代码里面new Thread()来做异步,那么你的代码是没有办法变成同步的,换成Executor这种方式来做吧。

Executor或ExecutorService的情况

如果你的代码是通过Executor或ExecutorService来做异步的,那在测试中把异步变成同步的做法,跟在测试中使用mock对象的方法是一样的,那就是使用依赖注入。在测试代码里面将同步的Executor注入进去。

AsyncTask

建议是不要使用AsyncTask,这个东西有很多问题,其中之一是它的行为是很难预测的,之二是如果你在Activity里面使用的话,其实这部分代码往往是不应该放在Activity里面的。
不过,如果你实在需要使用AsyncTask,同时又想对这些代码作单元测试的话,建议是使用 AsyncTask#executeOnExecutor()而不是直接使用AsyncTask#execute(),然后通过依赖注入的方式,在测试环境下将同步的Executor注入进去。

其他

实际上,在Android上实现异步当然不止这几种方式,还有ThreadHandler、IntentService、Loader等方式。

总结

Junit+Mockito+Robolectric+Dagger2+Jacoco
更简化:Junit Rule+Mockito Annotation+DaggerMock


版权声明

![Creative Commons BY-NC-ND 4.0 International License](/images/cc.png)

Lam’s Blog by Binghe Lin is licensed under a Creative Commons BY-NC-ND 4.0 International License.
林炳河创作并维护的Lam’s Blog采用创作共用保留署名-非商业-禁止演绎4.0国际许可证

本文首发于Lam’s Blog - Knowledeg as Action,版权所有,侵权必究。

本文永久链接:http://linbinghe.com/2017/52c78b37.html