长久以来,测试对于很多安卓开发小白们都是一个盲区。这个很大程度上是因为做app,大家都习惯了自己手动测试feature,毕竟是所见即所得的东西,点几个按钮看看能不能按照要求展示几个页面好像并不是那么难。其次是因为很多代码写的并不是特别可测 (比如代码都写在activity里面),导致没法进行单元测试。
以上的几个原因,最终导致了很多接触安卓开发没多久的朋友(尤其是在小厂,对迭代速度要求更快的地方)没怎么接触过安卓的单元测试,也不知道test coverage是什么,更加意识不到单元测试的重要性。产生了一种类似于咱们大学刚接触高等数学证明题的感觉:
对应到咱们今天讲的测试,很多人在看完同事写的测试代码之后也有类似的震惊。"这还要测?" “这也能测?”
今天我想着重讲一下安卓开发中单元测试的意义,来说明“这也要测”的意义。同时提供一些安卓测试中的小技巧,把“这也能测”的问题一并给解决 :)
单元测试的意义
对于安卓开发来说,大部分小白们对于单元测试处于懵逼的状态。不知道测试有啥用。我刚刚开始工作的时候就特别不喜欢写test,觉得是浪费时间。我的想法是,就算单元测试成功了,你的app跑起来也不一定能work啊。。。 所以还不如专心在手动测试上。
其实这个想法也不能说完全错,甚至可以说是对了一半。因为单元测试成功不一定代码app的功能就没问题。但是反过来说,如果单元测试都不对,那app的功能肯定有问题。
软件开发中有一个大假设,就是如果你的每个模块都能自己独立且正确运行的话,这个软件就大概率能正确的运行。比如,如果我们app中每个class都能通过各种的独立单元测试,那么把他们拼接起来这个app应该就没毛病了。
单元测试位于软件开发测试的金字塔的最底层,也是最重要的那一层。单元测试都跑不过,就别谈集成测试 , UI 测试了。安卓也不例外。
可能光这么说大家还是体会不到单元测试的好处。那么我就选一个方面来具体的说说单元测试在实际开发过程中,可以给我们带来什么好处。
怎么改?单元测试说了算
在大厂工作的朋友肯定都有过接手别人项目的经验,当你在尝试修改某一个class的时候,你怎么确定你添加的代码就是对的呢 (在不运行app做手动测试之前)?
答案就是单元测试。unit test在很多情况下,可以当做你修改代码的规则. class A 哪里改了会影响到class B,都可以在跑unit test之后发现,这也是你作为一个项目后来者了解细节的方式。
用一个我以前自己类似的经历做例子。假如有以下MVP pattern的代码:
class Presenter{
enum Status{
LARGE,
MEDIUM
}
public Status getFinancialStatus(int size){
if(size < 1000){
return Status.SMALL
}
else{
return Status.MEDIUM
}
}
}
以上代码通过房子size大小判断是small还是medium。现在产品经理说咱给他添加一个large的size把。于是你兴高采烈改了代码,简单的很,不就是加一个if else么:
class Presenter{
enum Status{
LARGE,
SMALL,
MEDIUM
}
public Status getFinancialStatus(int size){
if(size < 1000){
return Status.SMALL
}
else if( size < 6000){
return Status.MEDIUM
}
else{
return Status.LARGE
}
}
}
结果app跑起来之后crash了!
仔细一看,原来Activity里面有这样的代码(这里的例子都只是模拟场景,为的是说明测试的重要性,现实开发中肯定不可能把这种条件判断写在activity里面)
class HouseActivity extends Activity{
public void display(int size){
if(size > 10000){
throws IllegalStatusException()
}
Status status = presenter.getFinancialStatus(size)
.....其他逻辑
}
}
HouseActivity 的单元测试长这个样子:
@Test(expected = IndexOutOfBoundsException.class)
public void sizeTooLargeAssertException(){
activity.display(30000)
}
原来我们在activity里面有逻辑,限制最多只能展示大于10000的size,如果我在运行app之前就已经实现跑过了HouseActivity 的单元测试,我就会提前知道原来我们的app不处理大于10000的数据。
以上只是一个简单的例子,但是这个例子说明了一个很大的问题,就是在提交你的代码之前,运行一个有效的单元测试是有多么重要。他可以帮你测试修改的代码会对其他模块有什么影响,如果破坏了既有的测试(规则),你应该怎么处理。要知道很多代码在修改之后,你以为你打开app手动测试一下通过了肯定就没问题,但是你有没有想过,这个代码,这个类,会不会对其他页面有影响。这个就是单元测试的作用:
制定一套既有的规则,所有新增/修改的代码要按照这个规则来运行。
测试这种规则,要比你手动打开app测试更加健壮且快速(compile 一个完整app vs 运行 一个纯java的测试)。
在理想状态下,每一个类的每一行代码都要被unit test cover,一套单元测试的coverage(覆盖率)可以体现你给你代码制定规则的数量和健壮程度。比如说还是用上面的例子:
class Presenter{
enum Status{
LARGE,
MEDIUM
}
public Status getFinancialStatus(int size){
if(size < xxx){
return Status.SMALL
}
else{
return Status.MEDIUM
}
}
}
你的测试如果只有:
@Test
public void smallSizeReturnSmallStatus(){
int size = 90
assertThat(presenter.getFinancialStatus(size)).isEqualTo(Status.SMALL)
}
那你对Presenter这个类的coverage只有50%。为什么?因为你的test没有覆盖到else这个语句,补上一下测试:
@Test
public void largeSizeReturnLargeStatus(){
int size = 3000
assertThat(presenter.getFinancialStatus(size)).isEqualTo(Status.MEDIUM)
}
跑完这两个测试,你的presener的单元测试覆盖率就是100%了,恭喜!
顺便说一句,现在android studio已经支持显示unit test 覆盖率了,有兴趣可以看看
https://developer.android.com/studio/test
比如我有个dummy class
给它的else语句加个test:
Android Studio不仅会给出覆盖率等重要数据,还会给代码加上标记,这样开发者就可以轻易的看出来哪一行代码没有被测试覆盖,是否需要加测试(原谅色代表被覆盖,红色代表没有被覆盖)
测什么?
我们都知道一个类的单元测试是要保证这个类能正常运行。那么什么是类能正常运行呢?这个标准是什么?
还是以例子为主:
class HousePagePresenter{
//http service client
private HouseApiService service = new HouseApiService()
public Data getHouseData(int size){
if(size < 1000){
return service.call(Status.SMALL)
}
else{
return service.call(Status.MEDIUM)
}
}
}
HouseApiService 是一个做http call的类,参数是Status。当size小于1000就传SMALL,反之MEDIUM。那对于HousePagePresenter来说,这个类怎么样运行才是正确的?
那就是当getHouseData() 传入的参数小于1000的时候,service 类成员要调用call 方法,而且参数是SMALL,反之是MEDIUM。
HousePagePresenter只需要保证在合适的size的前提下,service能调用call并且使用正确的Status就行了。我们只在乎service有没有做出正确的动作,至于动作结果,不重要!
怎么测?
那说回来,这个怎么测?
首先,要给一个代码做测试,要先保证他是可测的。上面的代码其实是没法测试的!Not testable.因为HouseApiService作为私有对象,我们没办法模拟(Mock)它,从而无法验证它的行为在一定条件下是否符合我们的期望。
正确的做法是,要做“依赖注入”。把HousePagePresenter对因为HouseApiService的依赖,从类对象的方式转移成别的方式,或者说可测的方式,比如移到构造函数里面(也可以通过别的方式比如说setter)。
class HousePagePresenter{
public HousePagePresenter(HouseApiService service){
this.service = service
}
//http service client
private HouseApiService service;
public Data getHouseData(int size){
if(size < 1000){
return service.call(Status.SMALL)
}
else{
return service.call(Status.MEDIUM)
}
}
}
这样的好处可以说是非常大。这样,我们在测试HousePagePresenter类的时候,就不需要真正的创建一个HouseApiService了,而是可以模拟:
@Test
public void smallSizeServiceCall(){
int size = 900
HouseApiService service = mock(service.class)
HousePagePresenter presenter = new HousePagePresenter(service)
presenter.getHouseData(size)
//验证service是不是真正调用了call,并且参数也是期望值
verify(service).call(Status.SMALL)
}
通过把Service移到构造函数,让代码可以通过mockito mock的方式生成一个模拟的Service,这个service不会做任何真正的http call,只会记录自己call()方法被调用的情况。这就够了,这已经能证明HousePagePresenter这个类没问题,如果service有问题,那应该在service自己的单元测试里面解决。
具体怎么解决依赖注入,可以稍微看一下一个视频
https://www.bilibili.com/video/BV1e54y1S72A/?spm_id_from=333.788.recommend_more_video.-1
有人觉得只有用dagger这类依赖注入库才叫依赖注入,这是一个常见的误解。想了解更多的朋友可以自行搜索一下。
安卓控件没法测?
很多朋友会说自己有很多逻辑需要安卓本身的控件支持,这部分真的没法测啊。乖乖,谷歌已经给我们提供了从UI到系统api的全家桶,想偷懒不写test?不存在的。。。
纯UI的单元测试
对于fragment 和 activity本身的UI测试,Roboletric 框架提供了ActivityRule支持,允许开发者在unit test中启动测试activity,从而启动fragment。同时配合Espresso框架可以再unit test代码中获取View对象,达到测试View的目的。
比如::
//设置测试activity类
private activityScenarioRule = ActivityScenarioRule(TestActivity.class)
@Before
void setup{
//启动测试fragment
activityScenarioRule.scenario.onActivity{
activity.setFragmemnt(new TestFragment());
}
}
@Test
void whenButtonClicked_executeMethod(){
// 通过onView获取button,手动模拟点击事件
onView(R.id.button).performClick();
verify(presenter).getHouseData()
}
结合ActivityScenarioRule和Espresso,我们可以把Fragment或者Activity当成一个正常的再正常不过的类来进行测试了。我刚刚入职谷歌的时候就想偷懒不给UI写test,找借口说UI测不了,直接被senior大哥焦作人。。。
系统API
假如你的方法里需要获得当前手机运营商信息,那你可能需要TelephonyManager
这个系统api来帮忙。
fun getCarrierId(){
val manager: TelephonyManager = applicationContext.getSystemService(TelephonyManager::class.java)
if(manager.simCarrierId == 1){
//做什么逻辑
}
else{
//做其他逻辑
}
}
这种情况,你需要Shadow object来帮忙啦!
Roboletric 提供各种系统级别API的shadow,帮助你在测试的时候模拟不同的其情况。
比如:
@Test
fun testCarrierId(){
val shadowTelephonyManager = Shadows.of(context.getSystemService(TelephonyManager::class::java))
//给shadow强行设置一个值
shadowTelephonyManager.setSimCarrierId(-1);
//继续测试getCarrierId()方法
}
通过shadow,我们就可以测试那些含有系统级别api的类和方法了。
有了Roboletric之后,以前那些复杂的UI,和系统api测试再也不是问题了。我写了这么久测试之后发现,基本上没有不可以shadow,或者不能mock的东西了。每当我发现自己的代码的某一行测不了,那肯定是我的代码没有写成可测的形式。
结尾
来谷歌的这几个月可以说我在各种被教做人,知识非常匮乏。谷歌在测试,和代码规范方面比之前亚麻可以说严了不只一个级别,第一个月我就打破了自己修改代码的记录,一个PR修改了30次。。。
但是被教做人的同时我也学到了不少,尤其是unit test。以前在亚麻和创业公司随意惯了,写测试?不存在的。。。在写崩组内系统次数逐渐增加之后,我也渐渐意识到了单元测试的重要性,也想着趁脑子还有货和大家多分享一下,也请各位大牛多多指正!
祝大家五一快乐!羡慕国内的朋友已经到处游山玩水了。。。美帝疫情还是一天新增好几万。。。。