给安卓开发小白们的unit test指南 - 这也能测?这也要测?

长久以来,测试对于很多安卓开发小白们都是一个盲区。这个很大程度上是因为做app,大家都习惯了自己手动测试feature,毕竟是所见即所得的东西,点几个按钮看看能不能按照要求展示几个页面好像并不是那么难。其次是因为很多代码写的并不是特别可测 (比如代码都写在activity里面),导致没法进行单元测试。

以上的几个原因,最终导致了很多接触安卓开发没多久的朋友(尤其是在小厂,对迭代速度要求更快的地方)没怎么接触过安卓的单元测试,也不知道test coverage是什么,更加意识不到单元测试的重要性。产生了一种类似于咱们大学刚接触高等数学证明题的感觉:

Screenshot 2021-05-02 at 10.56.22 AM.png

对应到咱们今天讲的测试,很多人在看完同事写的测试代码之后也有类似的震惊。"这还要测?" “这也能测?”

download.jpeg

今天我想着重讲一下安卓开发中单元测试的意义,来说明“这也要测”的意义。同时提供一些安卓测试中的小技巧,把“这也能测”的问题一并给解决 :)

单元测试的意义

对于安卓开发来说,大部分小白们对于单元测试处于懵逼的状态。不知道测试有啥用。我刚刚开始工作的时候就特别不喜欢写test,觉得是浪费时间。我的想法是,就算单元测试成功了,你的app跑起来也不一定能work啊。。。 所以还不如专心在手动测试上。

其实这个想法也不能说完全错,甚至可以说是对了一半。因为单元测试成功不一定代码app的功能就没问题。但是反过来说,如果单元测试都不对,那app的功能肯定有问题。

软件开发中有一个大假设,就是如果你的每个模块都能自己独立且正确运行的话,这个软件就大概率能正确的运行。比如,如果我们app中每个class都能通过各种的独立单元测试,那么把他们拼接起来这个app应该就没毛病了。

1_nrKara4sTWMOpJRTWRCJ6Q.png

单元测试位于软件开发测试的金字塔的最底层,也是最重要的那一层。单元测试都跑不过,就别谈集成测试 , 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

Screenshot 2021-05-02 at 3.42.08 PM.png

给它的else语句加个test:

Screenshot 2021-05-02 at 3.41.30 PM.png

Android Studio不仅会给出覆盖率等重要数据,还会给代码加上标记,这样开发者就可以轻易的看出来哪一行代码没有被测试覆盖,是否需要加测试(原谅色代表被覆盖,红色代表没有被覆盖)

Screenshot 2021-05-02 at 3.41.37 PM.png

测什么?

我们都知道一个类的单元测试是要保证这个类能正常运行。那么什么是类能正常运行呢?这个标准是什么?

还是以例子为主:

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。以前在亚麻和创业公司随意惯了,写测试?不存在的。。。在写崩组内系统次数逐渐增加之后,我也渐渐意识到了单元测试的重要性,也想着趁脑子还有货和大家多分享一下,也请各位大牛多多指正!

祝大家五一快乐!羡慕国内的朋友已经到处游山玩水了。。。美帝疫情还是一天新增好几万。。。。

推荐阅读更多精彩内容