【Dev Club 分享第九期】安卓单元测试:What, Why and How
发布于 1 年前 作者 Bugly_Tony 290074 次浏览 来自 技术

Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。

本期,我们邀请了蘑菇街 Android 开发工程师——小创,为大家分享《安卓单元测试:What, Why and How》。

分享内容简介:

单元测试一直是软件开发过程中保证软件质量、提高代码设计非常重要的一环,然后国内环境普遍不重视这点,移动开发圈更是如此。这次分享主要介绍什么是单元测试、为什么要做单元测试、以及如何在安卓平台上做单元测试。

下面是本期分享内容整理


大家晚上好,我是小创,目前工作于 蘑菇街 支付金融部门。今天很高兴跟大家分享一下,我在安卓单元测试方面的一些经验。

这次分享主要介绍什么是单元测试、为什么要做单元测试、以及如何在安卓平台上做单元测试。

单元测试一直是软件开发过程中保证软件质量、提高代码设计非常重要的一环。然而国内环境普遍不重视这点,移动开发界更是如此。希望这次分享能让大家了解到单元测试的一些知识,提高大家对单元测试的重视程度。

下面,我们从为什么开始。

1. 为什么要写单元测试?

说到为什么要写单元测试的话,我相信大部分人都能承认、也能理解单元测试在保证代码质量,防止bug或尽早发现bug这方面的作用,这可能是大家觉得单元测试最大的作用。

然而我觉得,除了这方面的作用,单元测试还能在非常大的程度上改善代码的设计,同时还能节约时间,让人工作起来更有信心、更开心,以及其他的一些好处。这些都是我的切身感受,我相信也是多数真正实践过单元测试的人的切身感受,而不是为了宣传这个东西而说的好听的大话。

说到节约时间,大家可能就会好奇了,写单元测试需要时间,维护单元测试代码也需要时间,应该更费时间才对啊?

这就是在开始分享之前,我想重点澄清的一点,那就是**单元测试本身其实不会占用多少时间,相反,还会节约时间。**只是:

  1. 学习如何做单元测试需要时间;
  2. 在一个没有单元测试的项目中加入单元测试,需要一定的结构调整的时间,因为一个有单元测试跟没有单元测试的项目,结构上还是有较大不同的。

打个比方,开车这件事情,需要很多时间吗?我相信很少人会说开车这件事情需要很多时间,而是:

  1. 学习开车,需要一定的时间;
  2. 如果路面不平的话,那么修路需要一定的时间。

单元测试也是类似的道理。

**那为什么说单元测试可以节约时间呢?**简单说几点:

  1. 如果没有单元测试的话,我们每次写的新代码,都只能把app运行起来,测试相应的功能,才能知道代码是否是正确的,这比运行一次单元测试要慢多了。运行一次app需要多少时间,我相信大家都是有深刻体会的,gradle有多慢,相信大家也是有深刻体会的。
  2. 单元测试可以减少bug,尽早发现bug,从而减少了debug和fix bug的时间。有句话说我们写代码90%的时间在改bug,另外10%的时间在写新的bug。这句话虽然有点夸张,但是也能说明改bug确实占用了非常多的时候。既然单元测试能减少bug,自然也能节约时间。
  3. 重构的时候,大大提高重构的正确性,减少手工测试的时间。

所以,我希望大家能去掉"没时间写单元测试"这个印象,如果工作上安排太紧。没有时间学习如何做单元测试的话,可以自己私底下学,然后在慢慢应用到项目中。

2. 如何在安卓平台做单元测试?

2.1 单元测试与其它测试的区别

接下来介绍一下安卓单元测试是怎么做的。

首先澄清一下概念,在安卓上面写“测试”,有很多技术方案。有JUnit、Instrumentation test、Espresso、UiAutomator等等,还有第三方的Appium、Robotium、Calabash、Robolectric等等。

我们现在讲的是使用JUnit和Robolectric等其他的一些框架,写可以在我们开发环境的JVM上面直接运行的单元测试。其他的几种其实都不属于单元测试,而是集成测试或者叫Functional test等。

这两者明显的不同是:

  • 前者可以直接在开发用的电脑的JVM上,或者是CI上面的JVM上运行,而且可以只运行那么一小部分代码,速度非常快。
  • 后者必须要有模拟器或真机,把整个project打包成一个app,然后上传到模拟器或真机上,再运行相关的代码,速度相对来说慢很多。

2.2 单元测试的定义

单元测试的定义相信大家都知道,就是为我们写的某一个代码单元(比如说一个方法)写的测试代码。

一个单元测试大概可以分为三个部分:

  1. setup:即new 出待测试的类,设置一些前提条件
  2. 执行动作:即调用被测类的被测方法,并获取返回结果
  3. 验证结果:验证获取的结果跟预期的结果是一样的

2.3 void方法如何测试 & 常见测试误区

然而一个类的方法分两种,一种是有返回值的方法,一种是没有返回值的方法,即void方法。

对于有返回值的方法,测试起来固然是很容易的。但是对于没有返回值的方法,该怎么测试呢?这里的关键是,怎么样获取这个方法的“返回结果”?

这里举一个例子来说明一下,顺便澄清一个十分常见的误解。

比如说有一个Activity,管他叫DataActivity,它有一个public void loadData()方法, 会去调用底层的DataModel#loadDataFromNetwork()方法,异步的执行一些网络请求。当网络请求返回以后,更新用户界面。

这里的loadData()方法是void的,它该怎么测试呢?

一个最直接的反应可能是,调用loadData()方法(当然,实际可能是通过其他事件触发),然后一段时间后,验证界面得到了更新。

然而这种方法是错的,这种测试叫集成测试,而不是单元测试。因为它涉及到很多个方面,它涉及到DataModel的实现、网络服务器,以及网络返回正确时,DataActivity内部的处理,等等。

集成测试固然有它的必要性,但这不是我们应该最关注的地方,也不是最有价值的地方。我们应该最关注的是单元测试。

关于这一点,有一个Test Pyramid的理论:

Test Pyramid理论基本大意是,单元测试是基础,是我们应该花绝大多数时间去写的部分,而集成测试等应该是冰山上面能看见的那一小部分。

那么对于这个case,正确的单元测试方法,应该是去验证loadData()方法调用了DataModel的loadDataFromNetwork()方法,同时传递的参数是正确的。“调用了DataModel的loadDataFromNetwork()方法,同时参数是xxx” 这个才是loadData()这个方法的“返回结果”。

2.4 Mock的概念以及Mockito框架

要验证某个对象的某个方法得到调用了,就涉及到mock的使用。这里对mock的概念做个简单介绍,以免很多同学不熟悉,mock就是创建一个虚假的、模拟的对象。在测试环境下,用来替换掉真实的对象。

这样就能达到两个目的:

  1. 可以随时指定mock对象的某个方法返回什么样的值,或执行什么样的动作。
  2. 可以验证mock对象的某个方法有没有得到调用,或者是调用了多少次,参数是什么等等。

要使用mock,一般需要使用mock框架,目前安卓最常用的有两个,Mockito和JMockit。

两者的区别是,前者不能mock static method和final class、final method,后者可以。

我个人使用和推荐的是Mockito,因为它比较成熟稳定,兼容性也比较好。Mockito在github上面有2000多个mark,而JMockit只有100多个,跟Robolectric的兼容性也有问题。

但是使用Mockito,就有一个问题,那就是static method和final class、final method没有办法mock,对于这点如何解决,我们稍后会介绍到。

关于Mock和Mockito的使用,可以参考这篇文章

2.5 在测试环境中使用Mock:依赖注入

接下来的一个问题就是,如何在测试环境下,把DataModel换成mock的对象,而正式代码中,DataModel又是正常的对象呢?

这个问题也有两种解决方案:

  • 一是使用专门的testing product flavor;
  • 二是使用依赖注入。
2.5.1 testing product flavor

第一种方案就是用一个专门的product flavor来做testing,在这个testing flavor里面,里面把需要mock的类写一份mock的implementation,然后通过factory提供给client,这个factory的接口在testing flavor和正式的flavor里面是一样的。在跑testing的时候,专门使用这个testing flavor,这样通过factory得到的就是mock的类。

这种情况看起来很简单,但其实很不灵活,因为只能有一种mock实现;此外,代码会变得很丑陋,因为你需要为每一个dependency提供一个factory,会觉得很刻意;再者,多了一个flavor,很多gradle任务都会变得很慢。

关于这种方案,可以参考这个视频

2.5.2 依赖注入

因此,我们用的是第二种,依赖注入。

先简单介绍一下依赖注入(Dependency Injection)的概念。

假如某一个类,比如说DataActivity,内部用到另外一个类,比如说DataModel。那么DataModel叫做DataActivity的依赖(Dependency),DataActivity叫做DataModel的Client。

依赖注入的基本理念是,Dependency(DataModel)的创建过程不在Client(DataActivity)内部去new,而是由外部去创建好Depencendy(DataModel)的实例,然后通过某种方式set给Client(DataActivity)。

这种模式应用是非常广泛的,抛开单元测试不说,它本身就是一种非常好的代码设计。只不过单元测试让依赖注入这种模式变得非做不可而已。

关于依赖注入更详细的说明和做法,大家可以看这篇文章

为了更方便的做依赖注入,如今有很多框架专门做这件事情,比如RoboGuice, Dagger、Dagger2等等。

我们用的是Dagger2。理由很简单,这是目前最好用的DI框架。

关于Dagger2的文章,目前网上很多,相信大家也看过不少,但是好像我并没有看到讲述没有关于如何在测试环境下使用Dagger2的文章,这个还是略感遗憾的。虽然说本身就是一个非常优秀的设计,而不仅仅是为了单元测试,但离开单元测试,使用依赖注入就少了很有说服力的一个理由。

那么这里我就介绍一下,怎么样把Dagger2应用到单元测试中。

熟悉dagger2的童靴可能知道,Dagger2里面最关键的有两个概念,Module 和Component。Module是负责生成诸如DataModel这样的Dependency的地方。而Component则是给Client提供Dependency的统一接口。也就是说,DataActivity通过Component,来得到一份DataModel的实例。

现在,关键的地方来了,Component本身是不生产dependency的,它只是搬运工而已,真正生产dependency的地方在Module。所以,创建Component需要用到Module,不同的Module生产出不同的dependency。在正式代码里面,我们使用正常的Module,生产正常的DataModel。而在测试环境中,我们写一个TestingModule,让它继承正常的Module,然后override掉生产DataModel的方法,让它生产mock的DataModel。在跑单元测试的时候,使用这个TestingModule来创建Component,这样的话,DataActivity通过Component得到的DataModel对象,就是mock出来的DataModel对象。

使用这种方式,所有production code都不用专门为testing增加任何多余的代码,同时还能得到依赖注入的其他好处。

关于Dagger2的介绍和使用,以及在单元测试中的运用,大家可以参考这篇文章

2.6 Robolectric:解决Android单元测试最大的痛点

接下来讲讲Android单元测试最大的痛点,那就是JVM上面运行纯JUnit单元测试时,是不能使用Android相关的类(比如Activity、View等等)的,因为我们开发用到的安卓环境是没有具体实现的,里面只定义了一些接口,所有方法的实现都是throw new RuntimeException("stub");。如果我们单元测试代码里面用到了安卓相关的代码的话,那么运行时就会遇到类似Class xxx is not mocked这样的问题。

要解决这个问题,一般来说有三种方案:

  1. 使用Android提供的Instrumentation系统,将单元测试代码运行在模拟器或者是真机上。

  2. 用一定的架构,比如MVP等等,将安卓相关的代码隔离开了,中间的Presenter或Model是纯java实现的,可以在JVM上面测试。View和其他android相关的代码则不测。

  3. 使用Robolectric框架,这个框架基本可以理解为在JVM上面实现了一套安卓的模拟环境,同时给安卓相关的类增加了其他一些增强的功能,以方便做单元测试。使用这个框架,我们在JVM上面跑单元测试的时候,就可以使用安卓相关的类了。

第一种方案能work,但是速度非常慢,因为每运行一次单元测试,都需要将整个项目打包成apk,上传到模拟器或真机上,就跟运行了一次app似得,这个显然不是单元测试该有的速度,更无法做TDD。这种方案首先被否决。

刚开始,我采用的是Robolectric,原因有两个:1. 我们项目当时还没有比较清楚的架构,android跟纯java代码的隔离没有做好;2. 很多安卓相关的代码,还是需要测试的,比如说自定义View等等。

然而慢慢的,我的态度从拥抱Robolectric,到尽量不用它,尽量使用纯java代码去实现。可能大家觉得安卓相关的代码会很多,而纯java的很少,然而慢慢的你会发现,其实不是这样的,纯java的代码其实真不少,而且往往是核心的逻辑所在。

之所以尽量不用Robolectric,是因为Robolectric虽然相对于Instrumentation testing来说快多了。但毕竟它也需要merge一些资源,build出来一个模拟的app,因此相对于纯java和JUnit来说,这个速度依然是很慢的。

用具体的数字来对比说明:

  • 运行Instrumentation testing:几十秒,取决于app的大小
  • Robolectric:10秒左右
  • JUnit:几秒钟之内

当然,虽然运行一次Robolectric在10秒左右,但是对比运行一次app,还是要快太多。因此,刚开始的时候,从Robolectric开始完全是OK的。

以上就是现在我们这边单元测试用到的几个基本技术:JUnit4 + Mockito + Dagger2 + Robolectric。基本来说,并没有什么黑科技,都是业界标准。

3. 案例实践

接下来,我通过一个具体的案例,跟大家介绍一下,一个真实的app,具体是怎么单测的。

这里是蘑菇街App收银台界面的样子

假设当前Activity名字为CheckoutActivity,当它启动的时候,CheckoutActivity会去调一个CheckoutModelloadCheckoutData()方法。这个方法又会去调更底层的一个封装了用户认证等信息的网络请求Api类(mApi)的get方法,同时传给这个Api类一个callback。

这个callback的做的事情是将结果通过Otto Bus(mBus) post出去。CheckoutActivity里面Subscribe了这个Event(方法名是onCheckoutDataLoaded()),然后根据Event的值相应的显示数据或错误信息。

这几个类的关系图如下:

代码简写如下:

这里,CheckoutActivity里面的mCheckoutModel、CheckoutModel里面的mApimBus,都是通过Dagger2注入进去的。在做单元测试的时候,这些都是mock。

对于这个流程,我们做了如下的单元测试:

  1. CheckoutActivity启动单元测试:通过Robolectric提供的方法,启动一个Activity。验证里面的mCheckoutModelloadCheckoutData()方法得到了调用,同时参数(订单ID等)是对的。

  2. CheckoutModelloadCheckoutData单元测试1:调用CheckoutModelloadCheckoutData()方法,验证里面的mApi对应的get方法得到了调用,同时参数是对的。

  3. CheckoutModelloadCheckoutData单元测试2:mock Api类,指定当它的get方法在收到某些调用的时候,直接调用传入的callback的onSuccess方法,然后调用CheckoutModelloadCheckoutData()方法,验证Otto bus的post方法得到了调用,并且参数是对的。

  4. CheckoutModelloadCheckoutData单元测试3:mock api类,指定当它的get方法在收到某些调用的时候,直接调用传入的callback的onFailure方法,然后调用CheckoutModelloadCheckoutData()方法,验证Otto bus的post方法得到了调用,并且参数是对的。

  5. CheckoutActivityonCheckoutDataLoaded单元测试1:启动一个CheckoutActivity,调用他的onCheckoutDataLoaded(),传入含有正确数据的Event,验证相应的数据view显示出来了

  6. CheckoutActivityonCheckoutDataLoaded()方法单元测试2:启动一个CheckoutActivity,调用他的onCheckoutDataLoaded(),传入含有错误信息的Event,验证相应的错误提示view显示出来了。

这里需要说明的一点是,上面的每一个测试,都是独立进行的,不是说下面的单元测试依赖于上面的。或者说必须先做上面的,再做下面的。

4. 其他问题

以上就是我们这边做单元测试用到的技术,以及一个基本流程,下面聊聊其他的几个问题。

4.1 哪些东西需要测试呢?

  1. 所有的Model、Presenter/ViewModel、Api、Utils等类的public方法
  2. Data类除了getter、setter、toString、hashCode等一般可以自动生成的方法之外的逻辑部分
  3. 自定义View的功能:比如set data以后,text有没有显示出来等等,简单的交互,比如click事件,负责的交互一般不测,比如touch、滑动事件等等。
  4. Activity的主要功能:比如view是不是存在、显示数据、错误信息、简单的点击事件等。比较复杂的用户交互比如onTouch,以及view的样式、位置等等可以不测。因为不好测。

4.2 CI和code coverage

要把单元测试正式化,CI是非常重要的一步,我们有一个运行Jenkins的CI server,每次开发者push代码到master branch的时候,会运行一次单元测试的gradle task,同时使用Jacoco来做code coverage

4.3 private方法怎么测

把private方法改成package或者protected,然后把对应的测试类的包名变成跟待测类一下,这样,这个方法就可以测试了。 这个看起来有点别扭,但其实,安卓源代码有些地方就是这样做的。

5. 遇到的坑,以及好的practice建议

5.1 Native libary的问题

无论是纯JUnit还是Robolectric,都不支持load native library,会报UnsatisfiedLinkError的错。所以如果你的被测代码里面用到了native lib,那么可能需要给System.loadLibrary加上try catch。

如果是被测代码用到的第三方lib,而里面用到了native lib的话,一般有两种解决办法,一种是将用到native lib的第三方类外面自己在包一层,然后在测试的情况下mock掉。第二种是用Robolectric,给那个类创建一个shadow class。

第一种方法的好处是可以在测试的时候随时改变这个类的返回值或行为,缺点是需要另外创建一个wrapper类,会有点繁琐。第二种方式不能随时改变这个类的行为,但是写起来非常简单。所以,看自己的需要,选择相应的方法。

这两种方法,也是解决static method, final class/method不能mock的主要方式。

5.2 尽量写出易于测试的代码

static method、直接new object、singleton、Global state等等这些都是一些不利于测试的代码方式,应该尽量避免,用依赖注入来代替这些方式。

5.3 创建公共的单元测试library

如果你们公司也是组件化开发的话,抽出一个公共的单元测试类库来做单元测试,里面可以放一些公共的helper、utils、Junit rules等等,这个可以极大的提高写单元测试的速度。

5.4 把安卓里面的“纯java”代码copy一份到自己的项目里面

安卓里面有些类其实跟安卓没太大关系的,比如说TextUtils、Color等等,这些类完全可以把代码copy出来,放到自己的项目里面,然后其他地方就用这个类,这样也能部分摆脱android的依赖,使用JUnit而不是Robolectric,提高运行test的速度。

5.5 充分发挥JUnit Rule的作用

JUnit Rule是个很强大的工具,然而知道的人却不多。它的基本作用是,让你在执行某个测试方法前后,可以做一些事情。

如果你的好几个测试类里面有很多的共同的setup、teardown工作,你可能会倾向于使用继承,结合@Before@After来减少duplication,这里更建议大家使用JUnit Rule来实现这个目的,而不是用继承,这样可以有更大的灵活性。

此外,JUnit Rule还能实现@Before@After这些annotation无法实现的一些功能。

关于JunitRule的具体使用,可以参考这篇文章

5.6 善于利用AndroidStudio来加快你写测试的速度

AndroidStudio有很多feature可以帮助我们更快的写代码,比如code generation和Live Template等等。

这点对于写正式代码也适用,不过对于写测试代码来说,效果更为突出。因为大部分测试代码的结构、风格都是类似的,在这里live template能起非常大的作用。

此外,如果你先写测试,可以直接写一些还不存在的Class或method,然后alt+enter让AndroidStudio自动帮你生成。

5.7 不要最求完美

刚开始的时候,不用追求测试代码的质量,也不用追求完美,如果有些地方不好写测试,可以先放放,以后再来补,有部分测试总比没有测试好。

Martin Fowler 说过: Imperfect tests, run frequently, are much better than perfect tests that are never written at all.

然而等你熟悉写测试的方法以后,强烈建议先写测试!因为如果你先写了正式代码,那你对这写代码是如何work的已经有一个印象了,因此你往往会写出能顺利通过的测试,而忽略一些会让测试不通过的情况。如果先写测试,则能考虑得更全面。

5.8 未来的打算

使用Groovy和RoboSpock或者是Kotlin和Spek,实现BDD,这是很可能的事情,只是目前我这边还没太多那方面的实践,因此就不说太多了。以后有一定实践了,到时候可以再跟大家交流。

这些基本就是这次分享的主要内容,大家可以访问我的网站http://chriszou.com/ ,或关注我的公众号:

上面分享中提到的每一个比较重要的点(单元测试的定义、JUnit使用、Mock和Mockito、依赖注入、Robolectric等),都在里面有相应的单独文章介绍。
谢谢大家!

互动问答

Q1:感谢分享,想问下关于测试部分有没有简单的完整代码例子可以参考?

有的,分享中的部分代码在这个Repo: https://github.com/ChrisZou/android-unit-testing-tutorial 。这里面有上面提到的每个关键的点的示例代码

Q2:Groovy和Kotlin学习是不是对将来android开发的必要性 看过很多文章都讲到这个技术

Groovy目前看来不觉得。它对android支持的那个lib有点太大,此外,动态语言在性能上也是个大问题。kotlin看起来很有希望,就看google对它的态度了。

Q3:你们在实际项目中,是开发来写这些test case吗?会写多少?

是的,全部的单元测试都是开发自己写的。目前我们部门的模块,单元测试覆盖率都在50%以上

Q4:在团队开发中,怎么推广单元测试?

推广的确是个大问题,因为单元测试的好处只有实践过,才能真实的体会到。所以最好是有上面领导的支持。

Q5:单元测试在效率和健壮之间怎么平衡?

这个是随着自身做单元测试的技术而定的,刚开始的时候,可以能比较底层,比较好测的代码入手,慢慢的再扩大范围

Q6:单元测试的粒度,不能保证业务功能是正常的,你们有更大粒度的自动测试吗?如有,能否介绍一下

之前有做过探索,但是因为业务流程和环境的一些问题,效果不是很好。目前这个问题解决了,接下来估计会重新投入一定的人力。主要是用Espresso和UiAutomator

Q7:你们除了单元测试,还会做哪些事情提升代码质量?

其它的主要就是Code Review了,我们这边Code Review执行得还是比较好的