您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件开发专栏 > 开发技术 > 正文

安卓单元测试全攻略,让代码测试一劳永逸

发表于:2017-08-14 作者:AndroidYhy 来源:安卓巴士Android开发者门户
前言

安卓单元测试,只看这一篇就足够啦。真正的完全解析,真正的从0到1,Junit结合Mockito与Robolectric实现从M到V再到P,Jacoco扫描函数、逻辑、代码行数单元测试覆盖率100%的全面测试。你是否还在为了验证联网与未联网状态而频繁的开关WiFi开关?或者你是否还在为一个switch判断而频繁的使用debug断点setValue来观测代码的逻辑判断情况?又或者你是否还在为了校验某个UI文案的正确性而反复的比对UI稿?可能你会反问,难道写完代码自测也有错?当然不是,自测是一个良好的习惯,不过作为一名工程师,你要做的不应该只是看看点点的黑盒测试,而是应该设计出一套能够让代码测试代码,一劳永逸的测试工程。

正文

首先我们从Model层开始,通过具体代码来详尽说明一下一个单元测试覆盖率100%的测试工程是如何建立的。严格意义上讲,Model数据层负责数据加载与储存,是游离于安卓环境之外的存在,所以它可以不需要借助安卓SDK的支持。使用Junit结合Mockito即可做到100%条件分支覆盖率的单元测试。如果项目的Model层有安卓依赖,可能就表明此处的代码需要重构了,这也是单元测试其中的一个意义,让代码逻辑更清晰。清除Model层的安卓依赖的另一层面好处是让测试case更高效,含有android依赖的测试case执行最快也需要5秒,但对于一个没有安卓依赖的Model类,跑完全部case的时间可以降低至毫秒级。所以,去除Model层所不需要的安卓依赖还是很有必要的。

代码

Model层测试代码如下:


  1. @RunWith(MockitoJUnitRunner.class) 
  2.  
  3. public classWeatherModelTest { 
  4.  
  5.     privateWeatherModelmodel; 
  6.  
  7.     @Mock 
  8.  
  9.     ApiServiceapi; 
  10.  
  11.     @Mock 
  12.  
  13.     WeatherDataConvertconvertData; 
  14.  
  15.     @Mock 
  16.  
  17.     WeatherRequestListenerlistener; 
  18.  
  19.     private static finalStringJSON_ROOT_PATH="/json/"; 
  20.  
  21.     privateStringjsonFullPath; 
  22.  
  23.     privateWeatherDatanetData; 
  24.  
  25.     privateMapqueryMap; 
  26.  
  27.     @Before 
  28.  
  29.     public voidsetUp() { 
  30.  
  31.         RxUnitTestTools.openRxTools(); 
  32.  
  33.         model=newWeatherModel(); 
  34.  
  35.     } 
  36.  
  37.     private voidinitResponse() { 
  38.  
  39.         try{ 
  40.  
  41.             jsonFullPath= getClass().getResource(JSON_ROOT_PATH).toURI().getPath(); 
  42.  
  43.         } 
  44.  
  45.         catch(URISyntaxException e) { 
  46.  
  47.             e.printStackTrace(); 
  48.  
  49.         } 
  50.  
  51.         String json = getResponseString("weather.json"); 
  52.  
  53.         Gson gson =newGson(); 
  54.  
  55.         netData= gson.fromJson(json,WeatherData.class); 
  56.  
  57.         model.setApiService(api); 
  58.  
  59.         try{ 
  60.  
  61.             Field field = WeatherModel.class.getDeclaredField("convert"); 
  62.  
  63.             field.setAccessible(true); 
  64.  
  65.             field.set(model,convertData); 
  66.  
  67.         } 
  68.  
  69.         catch(Exception e) { 
  70.  
  71.             //reflect error 
  72.  
  73.         } 
  74.  
  75.         queryMap=newHashMap<>(); 
  76.  
  77.         queryMap.put("city","沈阳"); 
  78.  
  79.     } 
  80.  
  81.     privateStringgetResponseString(String fileName) { 
  82.  
  83.         returnFileUtil.readFile(jsonFullPath+ fileName,"UTF-8").toString(); 
  84.  
  85.     } 
  86.  
  87.     private voidsetFinalStatic(Field field,Object newValue)throwsException { 
  88.  
  89.         field.setAccessible(true); 
  90.  
  91.         Field modifiersField = Field.class.getDeclaredField("modifiers"); 
  92.  
  93.         modifiersField.setAccessible(true); 
  94.  
  95.         modifiersField.setint(field,field.getModifiers() & ~Modifier.FINAL); 
  96.  
  97.     } 
  98.  

首先通过@Mock注解对需要mock的对象进行初始化,然后我们需要对测试类进行测试case分析,WeatherModelmode类是一个网络请求数据model,所以这个model类的核心是request函数。首先对request函数进行分析。必须涵盖的测试点如下:请求参数校验,请求成功且返回码正确处理逻辑校验,请求成功但校验码错误处理逻辑校验和请求失败处理逻辑校验。同时Model类中还有一个观察者解绑函数,所以测试case也需要包含解绑函数处理逻辑测试这一项。通过initResponse,我们可以对接口返回值进行模拟,这里采用读Json文件的方法将接口返回做成Json数据文件,结合服务端的Swagger文档可以很轻易的实现服务端接口数据模拟。


  1. @Test@SuppressWarnings("unchecked")public voidtestParams() { 
  2.  
  3.     model.request(listener,"沈阳"); 
  4.  
  5.     try{ 
  6.  
  7.         Field fieldParam = WeatherModel.class.getDeclaredField("queryMap"); 
  8.  
  9.         Field fieldKey = WeatherModel.class.getDeclaredField("CITY"); 
  10.  
  11.         fieldParam.setAccessible(true); 
  12.  
  13.         setFinalStatic(fieldKey, true); 
  14.  
  15.         Map queryMaps = (Map) fieldParam.get(model); 
  16.  
  17.         String key = (String) fieldKey.get(model); 
  18.  
  19.         assertEquals("验证queryMap的Key",key,"city"); 
  20.  
  21.         String city = queryMaps.get("city"); 
  22.  
  23.         assertEquals("验证queryMap的value",city,"沈阳"); 
  24.  
  25.     } 
  26.  
  27.     catch(Exception e) { 
  28.  
  29.         //reflect error} 
  30.  
  31.     } 

对于有参数的Api,第一步就是验证传参。可能你会觉得大材小用,但众多的血淋淋的惨案告诉我们越是细小的东西越容易产生问题,而单元测试就是帮助我们将细小的问题解决在编码时期而不对外暴露。要验证参数的正确性,首先我们需要要验证向queryMap中put的时候是否正确。对于queryMap,我们需要验证K-V键值对的正确性,还是那句防微杜渐,因为queryMap是一个private变量,在正常情况下我们无法获取到它的值,而为这个变量加一个对业务毫无用处的get/set方法就显得太刻意了,我们的目的是为了解决让代码更健壮,bug更少,而不是为了测试而测试。拿不到queryMap参数测试还怎么进行?难道单元测试也要从入门到放弃?要其实很多事情都是这样,当你觉得某个问题完全没有办法解决的时候,一定是你考虑的不够周全。queryMap对象的值我们可以通过Java反射获得。反射的原理在这里我就不为大家阐述了,在testParams方法中,我们首先通过getDeclaredField获取了queryMap对象,然后我们需要获得到put的key。key的获得使我们陷入了第二个难题,可能你会说,这有什么难的,继续反射啊,可这个key是一个private static变量,通过正常的反射是无法拿到key的,最多会拿到一个异常。还是那句,不要放弃寻找解决方案,最终我们发现只要设置下虚拟机不去检测私有属性,即可完成对private static变量的获取。不要觉得只是很小的一个参数,这么劳师动众不值得,据不完全统计,每天因为接口key值多写或是写错一个字母而产生的bug不计其数。


  1. @Test@SuppressWarnings("unchecked")public voidtestRequestSuccess() { 
  2.  
  3.     initResponse(); 
  4.  
  5.     Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.just(netData)); 
  6.  
  7.     ArgumentCaptor captor = ArgumentCaptor.forClass(WeatherData.class); 
  8.  
  9.     model.request(listener,"沈阳"); 
  10.  
  11.     Mockito.verify(api).getWeather(queryMap); 
  12.  
  13.     Mockito.verify(listener).showLoading(); 
  14.  
  15.     Mockito.verify(listener).hideLoading(); 
  16.  
  17.     Mockito.verify(convertData).convertData(captor.capture()); 
  18.  
  19.     WeatherData result = captor.getValue(); 
  20.  
  21.     intstatus = result.getStatus(); 
  22.  
  23.     assertEquals("验证code",status,1000); 
  24.  

保证的参数传递的前提下,我们接下来需要对接口返回状态进行测试,首先便是成功态的接口返回。Mockito.when的作用是设定预期返回结果,例如case testRequestSuccess()所要测试的是请求成功且返回码正确的情况,所以我们对response的预期就是让它执行onNext方法,同时返回我们初始化好的完全正确的接口数据。Mockito.when使得测试代码可以完全按照我们所预期的执行。不过这个声明必须在方法执行之前,即Mockito.when必须比model.request(listener,"沈阳");先执行才会生效。Junit提供了丰富的assert断言机制,借助assert我们可以实现多种情况的测试,然而对于没有明确返回值的void方法,assert就显得有些无能为力,因为它无法找到一个标准进行断言。这时候需要使用mockito的verify方法,它的作用是验证mock对象的某一个方法是否得到了正确的执行Mockito.verify(listener).showLoading();就是验证加载进度条是否能够正常显示,ArgumentCaptor是一个参数捕获,它可以捕获onNext返回的数据,通过assert断言,我们可以验证成功情况下数据是否正确。数据成功情况下,我们有一个网络数据向视图数据转换的过程,这个转换方法是在convert类中执行的操作,因为我们做的是单元测试而非集成测试,所以基于WeatherModel这个测试类,我们只需要验证到convertData()这个函数是否正确得到了调用即可,数据转换的内容由Convert类的单元测试进行跟踪即可。


  1. @Testpublic voidtestStatusError() { 
  2.  
  3.     initResponse(); 
  4.  
  5.     netData.setStatus(1001); 
  6.  
  7.     Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.just(netData)); 
  8.  
  9.     ArgumentCaptor captor = ArgumentCaptor.forClass(WeatherData.class); 
  10.  
  11.     model.request(listener,"沈阳"); 
  12.  
  13.     Mockito.verify(api).getWeather(queryMap); 
  14.  
  15.     Mockito.verify(listener).showLoading(); 
  16.  
  17.     Mockito.verify(listener).fail(null,ServerCode.get(netData.getStatus()).getMessage()); 
  18.  

在实际开发过程中,服务端通常会对同一接口的不同状态做成不同的服务应答码,虽然返回非常态应答码的时候网络请求也是成功,但它却是有别于常态服务端应答的另一种情况。所以,这里需要对非常态服务应答码进行一个条件分支的测试。testStatusError ()的测试方法与testRequestSuccess()类似,只是我们这次的status模拟值由成功的status换成了一个异常status,同时,验证的函数执行也变成了listener的失败方法


  1. @Testpublic voidtestRequestFail() { 
  2.  
  3.     initResponse(); 
  4.  
  5.     Exception exception =newException("exception"); 
  6.  
  7.     Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.error(exception)); 
  8.  
  9.     model.request(listener,"沈阳"); 
  10.  
  11.     Mockito.verify(listener).fail(null,"exception"); 
  12.  

Request是一个接口,我们不能够保证每次请求我们的服务器都能够给与准确应答,同时用户在发出请求的时候我们也不能够保证用户所处的网络状态是否通畅。所以我们在设计Model类的时候也要将非常态考虑在内,对接口的异常情况进行处理,有时候我们需要自己创造一些异常来验证我们代码的健壮程度。同样的,我们的测试类也需要有一个专门的方法来保证异常态的测试。testRequestFail()的测试方法与成功的方法的不同之处在于我们首先我们需要mock的不是接口数据,而是一个异常,Exception exception = new Exception("exception");注意,这个Exception中的参数即是异常信息,因为我们的fail方法中有异常信息的显示,所以这个参数是必须要加上的,否则e.getLocalizedMessage()会抛出NPE。另外,这个时候的Mockito.when的期望也有所改变,这次我们期望的是函数执行onError方法。


  1. @Testpublic voidtestCancelRequest() { 
  2.  
  3.     Subscription subscription =mock(Subscription.class); 
  4.  
  5.     model.setSubscription(subscription); 
  6.  
  7.     model.cancelRequest(); 
  8.  
  9.     verify(subscription).unsubscribe(); 
  10.  

Model类中最后一个case是testCancelRequest()它的作用是,在合适的时候解绑request,我们的网络请求是异步的,也就是说当我们调用请求的activity或是fragment destroy的时候,如果我们没有解除绑定,是存在内存泄漏风险的。当然,我们能想到的问题,Rxjava的维护者们也一定想到了,Subscription就是方便我们在生命周期结束的时候对Rx解绑。验证方法很简单,还是通过verify方法,验证解绑方法是否得到了正确执行。


  1. dependencies { 
  2.  
  3.     classpath'com.vanniktech:gradle-android-junit-jacoco-plugin:0.6.0' 
  4.  

至此我们已经完成了对model的全覆盖测试,点击测试类前面的运行按钮,可以看到所有测试类运行的情况,绿色代表成功,红色代表存在问题,可以通过下方的Log日志查看引起测试失败的问题点进行改正,借助Jacoco统计工具可以看到单元测试覆盖率的情况。之所以选择使用Jacoco而不是IDE自带的Coverage是因为在测试&条件分支的情况下Coverage存在漏洞,导致没有达到全覆盖的测试显示已覆盖完全。Jacoco的AndroidStudio集成网络资源并不多,集成方法不是存在潜在漏洞就是过于繁琐。经过两天的不断搜索,终于发现了一个史上最简单集成方法,只需要在主工程的gradle文件中添加一个Jacoco插件,gradle就会生成一个Jacoco Task,双击运行即可生成一份Html覆盖率报告。运行我们的model测试类,从jacoco生成的html可以看到,我们的model已经达到了100%的全覆盖。既然如此,我们是不是就可以认为MVP的M层已经ok了呢?等等,我们好像遗漏了点什么,没错,onNext情况下的数据转换类还没有测试,下面我们来对convert类进行一下测试。

首先们来看看convert类代码:


  1. /** 
  2.  
  3. * Author : YangHaoyi on 2017/6/28. 
  4.  
  5. * Email  :  yanghaoyi@neusoft.com 
  6.  
  7. * Description :网络数据与视图数据转换器 
  8.  
  9. * Change : YangHaoYi on 2017/6/28. 
  10.  
  11. * Version : V 1.0 
  12.  
  13. */ 
  14.  
  15. open classWeatherDataConvert { 
  16.  
  17.     open funconvertData(netData: WeatherData):WeatherViewData{ 
  18.  
  19.         valviewData= WeatherViewData() 
  20.  
  21.         viewData.temperature= netData.data?.temperature?:0.0viewData.weatherType= netData.data?.weatherType?:1viewData.ultraviolet= netData.data?.ultraviolet?:0viewData.rainfall= netData.data?.rainfall?:"0"viewData.hourTemperature= netData.data?.hourTemperature?:"10"viewData.windPower= netData.data?.windPower?:"2"returnviewData 
  22.  
  23.         } 
  24.  

从代码可以看出我们的convert类看起来有一些的奇怪,每错,因为它并不是java代码,它是kotlin。好好的java工程为什么要混入kotlin,单单只是为了炫技么?当然不是,数据转换类的作用是对网络数据进行判空并包装成视图数据,我们都知道在java中的判空,需要层层嵌套,例如,我们需要判断Student类中的Score类中的EnglishScore字段,我们的写法如下:


  1. if(Student!=null&&Student.getScore()!=null&&Student.getScore().getEnglishScore()!=null){} 

这是一个很多层的判断,而对于kotlin我们只需要写Student?.score?.englishScore即可,代码量巨减有没有。对于kotlin的特性,有兴趣的同学可以移步官网去详细了解。

让我们回归单元测试,convert类是一个数据判空类,它的作用是对数据进行组装并赋予默认初值,因为服务端的数据不可控,作为手机端我们不能把用户体验完全寄托于后端的兄弟,因为放过任何一个null数据对于App都是一个Crash。所以我们的测试点就是,这个类是否达到了当数据为空的时候赋予默认值,当数据不为空的时候取网络数据值的作用。这里选取一个比较有代表性的testTemperature为例,首先设定模拟WeatherData的值为10D,因为网络数据有值,所以会取网络数据的值即10D,通过assertEquals可以进行断言比对验证,不过有一个需要注意的是double型的断言assertEquals(message,double1,double2)是不可用的,直接运行的话会报测试失败。Double的比对需要加上一个误差值,这里给一个误差值0.1D,再次运行,测试条变绿。同时我们需要测试当WeatherData为空的情况下,viewData是否被赋予了默认值0.0。以此类推,我们需要对每一条数据进行校验,并包装成视图数据。


  1. /** 
  2.  
  3. * Author : YangHaoyi on 2017/7/7. 
  4.  
  5. * Email  :  yanghaoyi@neusoft.com 
  6.  
  7. * Description : 
  8.  
  9. * Change : YangHaoYi on 2017/7/7. 
  10.  
  11. * Version : V 1.0 
  12.  
  13. */ 
  14.  
  15. public classWeatherDataConvertTest { 
  16.  
  17.     privateWeatherDataConvertconvert; 
  18.  
  19.     private static doubleDETAL=0.1D; 
  20.  
  21.     @Beforepublic voidsetUp(){ 
  22.  
  23.         convert=newWeatherDataConvert(); 
  24.  
  25.     } 
  26.  
  27.     @Testpublic voidtestTemperature(){ 
  28.  
  29.         WeatherData netData =newWeatherData(); 
  30.  
  31.         WeatherData.DataBean dataBean =newWeatherData.DataBean(); 
  32.  
  33.         dataBean.setTemperature(10D); 
  34.  
  35.         netData.setData(dataBean); 
  36.  
  37.         WeatherViewData viewData =convert.convertData(netData); 
  38.  
  39.         //断言double不可以用assertEquals(message,double1,double2)//需要改用下面的方法,DETAL为误差值assertEquals(viewData.getTemperature(),10D,DETAL); 
  40.  
  41.     } 
  42.  
  43.     @Testpublic voidtestTemperatureNull(){ 
  44.  
  45.         WeatherData netData =newWeatherData(); 
  46.  
  47.         WeatherData.DataBean dataBean =newWeatherData.DataBean(); 
  48.  
  49.         netData.setData(dataBean); 
  50.  
  51.         WeatherViewData viewData =convert.convertData(netData); 
  52.  
  53.         //断言double不可以用assertEquals(message,double1,double2)//需要改用下面的方法,DETAL为误差值assertEquals(viewData.getTemperature(),0D,DETAL); 
  54.  
  55.     } 
  56.  

Convert类的顺利执行标志着Model层的测试圆满结束,下面让我们来看一看MVP架构下的第二顺位View层的测试,如果我们不借助UI测试框架直接运行UI测试是无法得到预期的验证的,因为我们只会得到一个运行时异常。可是我们在构建工程之前已经下载了对应版本的安卓Sdk,为什么还是会抛出异常呢?在真机或是模拟器上面为什么不会呢?是不是IDE只为我们提供了工程的开发与编译环境,并没有提供工程的运行环境呢?引用Linus Torvalds的那句经典的RTFSC,让我们通过源码来一点点验证我们的猜想。首先我们找到SDK对应的android.jar文件,然后随便找个工程add as library,以我们最常用的Activity为例,源码如下:


  1. public WindowManager getWindowManager() { 
  2.  
  3.     throw newRuntimeException("Stub!"); 
  4.  
  5.  
  6. public Window getWindow() { 
  7.  
  8.     throw newRuntimeException("Stub!"); 
  9.  
  10.  
  11. public LoaderManager getLoaderManager() { 
  12.  
  13.     throw newRuntimeException("Stub!"); 
  14.  
  15.  
  16. public View getCurrentFocus() { 
  17.  
  18.     throw newRuntimeException("Stub!"); 
  19.  
  20.  
  21. protected void onCreate(BundlesavedInstanceState) { 
  22.  
  23.     throw new RuntimeException("Stub!"); 
  24.  
  25.  
  26. public void onCreate(BundlesavedInstanceState, PersistableBundle persistentState) { 
  27.  
  28.     throw newRuntimeException("Stub!"); 
  29.  

我们可以清除的看到所有的方法都不约而同的抛出了RuntimeException("Stub!"),这也就是我们的测试case无法进行的原因。为了应对UI单元测试难以推进的现状,谷歌推出了一套名为Espresso的UI单元测试框架,由于是官方的框架,所以在工程的运行以及相关资料的跟进都做的比较完善。然而Espresso的短板也非常明显,Espresso必须借助于安卓模拟器或是真机环境才能够运行,也正是因为需要在安卓设备上运行,Espresso的运行速度非常缓慢,使之与Jenkins相结合进行自动化构建更是难上加难。这不禁让我陷入沉思,如果UI单元测试需要如此的大费周章,那是否还有测下去的必要?不过很快迭代的bug统计就打消了我放弃UI只做逻辑测试的念头。我们手机组在迭代过程中的UI与逻辑bug比基本可以达到5比1,也就是说有绝大多数问题产生在了视图层,单元测试的目的是减少bug产生,而目前UI就是我们最大的痛点,UI单元测试势在必行。经过不断的资源搜索,最终我到了一个可以不借助安卓设备的UI测试框架Robolectric,它的设计思路是通过实现一套JVM能运行Android代码,从而做到脱离Android环境进行测试。由于robolectric需要从oss.sonatype.org下载一些必要的依赖包,但是oss.sonatype.org是国外的网站,下载速度比较缓慢。这里需要修改整个工程的build.gradle文件,修改mavenCentral()为阿里云{"http://maven.aliyun.com/nexus/content/groups/public/"} 的代理。

Robolectric的依赖为:


  1. testCompile'org.robolectric:robolectric:3.3.2' 

运行Robolectric需要首先对测试类进行配置,如下:


  1. @RunWith(MyRobolectricTestRunner.class)@Config(constants= BuildConfig.class,sdk=24) 

MyRobolectricTestRunner为自定义的指向阿里云的配置文件,BuildConfig为当前model的BuildConfig文件,sdk为使用的sdk版本,之所以指定sdk版本是因为Robolectric需要下载对应sdk的镜像资源,指定版本就会使用本地已经下载好的sdk资源。第一次运行测试的时候会自动到阿里云去下载相关文件,然后会在系统的C盘下生成一个.m2文件夹,如果依旧下载缓慢,可直接拷贝.m2文件夹到自己电脑的相对目录下直接使用。Robolectric几乎可以测试一切安卓方法,使用也是非常简单。例如:


  1. @Beforepublic voidsetUp() {  
  2.   activity= Robolectric.setupActivity(WeatherActivity.class); 

实现的便是创建一个Activity,一行代码即可模拟activity的创建与运行。一行代码就解决了一直困扰我们对于android环境无法获取的苦恼。有了Activity对象,瞬间觉得可以解决所有问题。例如测试页面的跳转:


  1. @Testpublic voidtestToHelpCenter(){ 
  2.  
  3.     view.toHelpCenter(); 
  4.  
  5.     //设置期待IntentIntent expectedIntent =newIntent(activity,WeatherHelpCenterActivity.class);//获取实际IntentIntent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();//通过Assert验证Assert.assertEquals(expectedIntent.getComponent(),actualIntent.getComponent()); 
  6.  

设置好当前页面与跳转页面,Robolectric就能够帮助我们模拟出我们所期待的Intent,同时通过ShadowApplicaiton可以获取到模拟运行后的实际Intent的值,结合Junit即可完成对Intent的验证,进而验证页面跳转逻辑。

TextView是我们在开发过程中最常用也是最容易出错的一个UI组件,尤其是团队的设计师是一个非常把不同地方的文案设计得非常想象而又有着细微差别的时候,我们非常容易多打或是少打一个字,又或是错别或是形近字。为了保证产品质量,我们不得不一遍又一遍的比对UI稿件,锱铢必较,逐字观察,简直苦不堪言。所谓程序即生活,难道我们生活中就没有这种校验文字的困扰么?生活中我们又都是怎么解决的呢?记得许多年前时不时会看到有人去ATM转账转错的新闻,今年来倒是很少有这样的新闻了,原因就在于银行对于银行卡号作了二次校验。对于TextView的测试也是利用了二次校验的方法,第一次文字使用业务代码,第二次代码使用测试代码进行校验,如果两次不一致则证明文字存在问题。这样就可以有效的避免了靠肉眼比对的不确定性,让程序去验证程序。


  1. @Testpublic voidtestShowTemperature(){ 
  2.  
  3.     //模拟视图数据WeatherViewData viewData =newWeatherViewData(); 
  4.  
  5.     viewData.setTemperature(23.1D); 
  6.  
  7.     view.updateCache(viewData); 
  8.  
  9.     //执行待测函数view.showTemperature();//通过Id获得view实体TextView tvTemperature = (TextView)activity.findViewById(R.id.tvTemperature); 
  10.  
  11.     String text = tvTemperature.getText().toString(); 
  12.  
  13.     //验证文字显示assertEquals("验证温度",text,"23.1"); 
  14.  

首先通过view.showTemperature();调用执行函数,在通过Id找到对应的TextView组件,通过getText获取TextView的显示文字,再通过Junit的aseertEquals进行字符串验证即可。如果发生比对失败,通过下方的Log提示click to see difference即可准确的看到差异点。

Robolectric对于提示Tost的测试也是非常的简单,只需要:


  1. @Testpublic voidtestShowDataError(){ 
  2.  
  3.     view.showDataError(); 
  4.  
  5.     assertEquals("数据转换异常",ShadowToast.getTextOfLatestToast()); 
  6.  

测试Resource中的颜色:


  1. @Testpublic voidtestInitTitle(){ 
  2.  
  3.     TextView tvTitle = (TextView)activity.findViewById(R.id.tvTitle); 
  4.  
  5.     view.initTitle(); 
  6.  
  7.     String title = tvTitle.getText().toString(); 
  8.  
  9.     assertEquals("验证标题初始化",title,"帮助中心"); 
  10.  
  11.     Application application = RuntimeEnvironment.application; 
  12.  
  13.     ColorStateList color = ColorStateList.valueOf(application.getResources().getColor(R.color.colorWhite)); 
  14.  
  15.     assertEquals("验证颜色",color,tvTitle.getTextColors()); 
  16.  

测试Dialog:


  1. @Testpublic voidtestShowTelDialog(){ 
  2.  
  3.     view.showTelDialog(); 
  4.  
  5.     //因为提示框 dialog 在 view 中属于私有变量,不需要对外暴露方法,如果为了测试而写一个get set 方法似乎太过牵强//所以采用 Java 反射的方法获取dialog对象try{// /通过类的字节码得到该类中声明的所有属性,无论私有或公有Field field = WeatherHelpCenterImpl.class.getDeclaredField("telDialog");// 设置访问权限(这点对于有过android开发经验的可以说很熟悉)field.setAccessible(true);// 得到私有的变量值Object dialog = field.get(view); 
  6.  
  7.     TConfirmDialog telDialog = (TConfirmDialog) dialog; 
  8.  
  9.     //获取到Dialog对象之后,再通过反射获取Dialog中TextView对象Field fieldDialog = TConfirmDialog.class.getDeclaredField("tvTitle");// 设置访问权限fieldDialog.setAccessible(true);//获取telDialog中的TextView对象Object title = fieldDialog.get(telDialog); 
  10.  
  11.     TextView tvTitle = (TextView) title; 
  12.  
  13.     //通过assert方法验证标题assertEquals("验证标题",tvTitle.getText().toString(),"客服电话");//获取到Dialog对象之后,再通过反射获取Dialog中TextView对象fieldDialog = TConfirmDialog.class.getDeclaredField("tvConfirm");//获取telDialog中的TextView对象Object confirm = fieldDialog.get(telDialog); 
  14.  
  15.     TextView tvConfirm = (TextView) confirm; 
  16.  
  17.     //通过assert方法验证标题assertEquals("验证确定按钮",tvConfirm.getText().toString(),"拨打电话");//获取到Dialog对象之后,再通过反射获取Dialog中TextView对象fieldDialog = TConfirmDialog.class.getDeclaredField("tvCancel");//获取telDialog中的TextView对象Object cancel = fieldDialog.get(telDialog); 
  18.  
  19.     TextView tvCancel = (TextView) cancel; 
  20.  
  21.     //通过assert方法验证标题assertEquals("验证取消按钮",tvCancel.getText().toString(),"取消"); 
  22.  
  23.  
  24. catch(Exception e) { 
  25.  
  26.     //error} 
  27.  

Dialog的测试点需要包括Dialog的显示与隐藏,Dialog的提示文字与按钮的文字显示,因为很多是私有变量,所以这里用到了一些Java反射来帮助获取对象。

目前为止,我们已经完成了对Model层与View层的测试,MVP三兄弟只剩下P层还没有测试,下面我们就来看看P层该如何测试。P层作为M层与V层的纽带,起到了隔离视图与数据直接交互的作用。因为P层持有的只是V的接口,所以P层也可以抽离成简单的纯Java测试。让我们先来看看P层的测试代码:


  1. /** 
  2.  
  3. * Created by YangHaoyi on 2017/7/8. 
  4.  
  5. * Email  : yanghaoyi@neusoft.com 
  6.  
  7. * Description : 
  8.  
  9. * Version : 
  10.  
  11. */ 
  12.  
  13. public classWeatherPresenterTest { 
  14.  
  15.     privateWeatherPresenterpresenter; 
  16.  
  17.     privateIWeatherViewview; 
  18.  
  19.     privateWeatherControlcontrol; 
  20.  
  21.     privateWeatherModelweatherModel; 
  22.  
  23.     privateWeatherRequestListenerlistener; 
  24.  
  25.     @Beforepublic voidsetUp(){ 
  26.  
  27.         view=mock(IWeatherView.class); 
  28.  
  29.         control=mock(WeatherControl.class); 
  30.  
  31.         weatherModel=mock(WeatherModel.class); 
  32.  
  33.         listener=mock(WeatherRequestListener.class); 
  34.  
  35.         presenter=newWeatherPresenter(view); 
  36.  
  37.         presenter.updateWeatherModel(weatherModel); 
  38.  
  39.         presenter.updateControl(control); 
  40.  
  41.         presenter.updateListener(listener); 
  42.  
  43.     } 
  44.  
  45.     @Testpublic voidtestRequest(){ 
  46.  
  47.         presenter.request(); 
  48.  
  49.         verify(weatherModel).request(listener,view.getLocationCity()); 
  50.  
  51.     } 
  52.  
  53.     @Testpublic voidtestCancelRequest(){ 
  54.  
  55.         presenter.cancelRequest(); 
  56.  
  57.         verify(weatherModel).cancelRequest(); 
  58.  
  59.     } 
  60.  
  61.     @Testpublic voidtestShowHourTemperature(){ 
  62.  
  63.         presenter.showHourTemperature(); 
  64.  
  65.         verify(control).buttonWasPressed(WeatherControl.TEMPERATURE); 
  66.  
  67.     } 
  68.  
  69.     @Testpublic voidtestShowPrecipitation(){ 
  70.  
  71.         presenter.showPrecipitation(); 
  72.  
  73.         verify(control).buttonWasPressed(WeatherControl.PRECIPITATION); 
  74.  
  75.     } 
  76.  
  77.     @Testpublic voidtestShowWindPower(){ 
  78.  
  79.         presenter.showWindPower(); 
  80.  
  81.         verify(control).buttonWasPressed(WeatherControl.WINDPOWER); 
  82.  
  83.     } 
  84.  
  85.     @Testpublic voidtestToHelpCenter(){ 
  86.  
  87.         presenter.toHelpCenter(); 
  88.  
  89.         verify(view).toHelpCenter(); 
  90.  
  91.     } 
  92.  

由于这只是一个示例Demo,没有过多的业务逻辑,结合了几个简单的设计模式,Presenter的代码变成了绝大多数的顺序执行,通过Mockito的verify即可完成验证。这里需要说明一下的是之所以结合设计模式是因为单元测试的原则是每一个条件分支都需要有一条测试Case做保证,对于多分支甚至是多嵌套分支就会比较繁琐,需要写大量的重复代码,同时也增大了漏测的几率,适当的添加设计模式可以很好的弥补这一点,将嵌套条件判断测底删除,极大程度减少甚至删除条件判断。经过完善代码后的单元测试,测试的只是一些简单的if/else单分支判断。验证方法与Model层的测试方法大同小异,借助Junit与Mockito我们可以轻易的实现Presenter层的测试。

写在后面,很多朋友对单元测试都是抱着一种排斥的态度,觉得写单元测试是在浪费时间。其实不然,如果你把代码调试,bug修复与回归测试的时间也算人进去的话,你就会发现,单元测试其实能够帮助我们节约大量的时间。单元测试的编写要本着验证问题的心态就编码,切不可以完成任务指标的心态去编码,觉得只是Leader安排的指标。很多时候一个有经验的前人安排你去做某件事的时候,并不是想让你完成什么,只是以一个过来人的角度告诉你终南捷径,东西就在你眼前,谁把话听进去了,谁就得到了。

Jacoco代码覆盖率