Android单元测试在复杂项目里的落地姿势(调研篇)

3,930 阅读14分钟

代码出处:colin-phang/AndroidUnitTest

下篇文章:Android单元测试在复杂项目里的落地姿势(PowerMock实践篇)

在Android项目中实施单元测试,是近几年来相关的讨论有很多,谷歌官方也提供了一些方案,但是网上很多文章往往都只有最简单的项目demo,对于复杂项目几乎无任何实践参考价值。因此本文总结了一下最近的调研,尝试找到一种能让单元测试在安卓项目中落地的姿势。

文章主要分成 调研、 实践 两篇。 本篇主要讲讲Android单元测试的调研情况。

0 收益

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。什么是最小可测单元——这是人为划分的,可以是一个类、函数或者可视化的某个功能。 很重要的一点是,单元测试强调 被测的独立单元要与程序的其他部分相隔离的情况下进行测试。

那么单元测试能为我们带来什么收益——或者说我们为什么费时费力要进行单元测试? 主要是以下3点:

1. 保证业务交付质量.

单元测试对项目进行函数级别的测试,一方面可以测试各种边界、极限条件下代码的健壮性,同时能够在迭代过程中检查出代码改动带来的不稳定因素,降低团队高速迭代的风险。

2. 单元测试迫使我们去做封装和改造,让项目可以更优雅地进行测试。

你会很自觉地 把每个类写的比较小, 你会把每个功能职责分配的很清楚,而不是把一堆代码都塞到一个类里面(比如Activity)。 你会自觉地更偏向于采用组合,而不是继承的方式去写代码。

3. 单元测试的case类似于技术文档,具备业务指导能力。

单元测试用例往往和某个页面的具体业务流程以及代码逻辑 紧密联系,所以单元测试用例可以像文档一样具备业务指导能力。

退一万步讲,单元测试提供了一种快速便捷的方式让我们可以去测试某个模块/函数的逻辑是否足够健壮,而不是在项目业务里侵入式地加一些测试代码。那么这个单元测试相关框架的引入的收益就已经足够大了。

1 框架调研

1.0 单元测试的范围

哪些函数需要进行单元测试?可以简单概括为以下三种:

  1. 有明确的返回值,只需调用这个函数,然后验证函数的返回值是否符合预期结果。
  2. 这个函数本身没有返回值,就验证它所改变的属性和状态。
  3. 一些函数没有返回值,也没有直接改变哪个值的状态,这就需要验证其行为,比如发生了页面跳转、拉起相机。
  4. 既没有返回值,也没有改变状态,又没有触发行为的函数是不可测试的,在项目中不应该存在。

**框架的调研也是为了尽可能便捷地在Android项目里覆盖上述3种函数。**下面说说在Android开发中的单元测试的情况。

在新建工程时,可以看到src目录下有androitTest和test两个目录,二者都是Android测试代码的目录,但有所不同:

  1. /src/androidTest的代码需要运行在真机/模拟器上,主要是测某个功能是否正常,类似于UI自动化测试。
  2. /src/test的代码可以直接运行在JVM上,可以验证函数级别的逻辑,就是我们一般所说的单元测试代码。

所以说Android的测试代码分为 运行在真机和JVM上两类,下面介绍下相关的几个框架:

  1. JUnit,Java单元测试的根基,基本上都是通过断言来验证函数返回值/对象的状态是否正确。
  2. Espresso,谷歌官方提供的UI自动化测试框架,需要运行在手机/模拟器上,类似于Appium。
  3. Robolectric,实现了一套可以在JVM上运行的Android代码。
  4. Mockito,如果被测的业务依赖比较复杂的上下文,就可以使用Mock来模拟被测代码依赖的对象来保证单元测试的进行。

下面讲讲几个框架的调研情况及取舍,赶时间的可以直接看文末结论。

1.1 JUnit

JUnit是Java单元测试的根基,测试用例的运行和验证都依赖于它来进行。Android使用Java语言开发,Android单元测试自然离不开JUnit。 JUnit的用途主要是:

  1. 提供了若干注解,轻松地组织和运行测试。
  2. 提供了各种断言api,用于验证代码运行是否符合预期。

断言的api不做介绍了,自行查阅官方wiki

简单介绍一下几个常用注解:

  1. @Test 标记该方法为测试方法。测试方法必须是public void,可以抛出异常。
  2. @RunWith 指定一个Runner来提供测试代码运行的上下文环境。(Runner的概念
  3. @Rule 定义测试类中的每个测试方法的行为,比如指定某个Acitivity作为测试运行的上下文。
  4. @Before 初始化方法,通常进行测试前置条件的设置。
  5. @After 释放资源,它会在每个测试方法执行完后都调用一次。
@RunWith(JUnit4.class)
public class JUnitSample {
    Object object;

    //初始化方法,通常进行用于测试的前置条件/依赖对象的初始化
    @Before
    public void setUp() throws Exception {
        object = new Object();
    }

    //测试方法,必须是public void
    @Test
    public void test() {
        Assert.assertNotNull(object);
    }
}

ps: 一个测试类单元测试的执行顺序为: @BeforeClass –> @Before –> @Test –> @After –> @AfterClass

结论:JUnit是单元测试的根基。

1.2 Espresso

谷歌官方的UI自动化测试框架,用Espresso写的测试代码,必须跑在emulator或者是device上面,并且在测试代码的运行过程中,也会真正的拉起页面、发生UI交互、文件读写、网络请求等等,最后通过各种断言检查UI状态。 框架提供了以下三类api:

  1. ViewMatchers,找出被测的View对象,相当于在测试代码中实现了findViewById。
  2. ViewActions,发送交互事件,即在测试代码中模拟UI触摸交互。
  3. ViewAssertions,验证UI状态,在测试代码运行完成后检查UI状态是否符合预期,可以看做是UI状态的断言。

话不多说,直接看简单demo:

//使用Espresso提供的AndroidJUnit4运行测试代码
@RunWith(AndroidJUnit4.class)
public class EspressoSample {

    // 利用Espresso提供的ActivityTestRule拉起MainActivity
    @Rule
    public ActivityTestRule<MainActivity> mIntentsRule = new IntentsTestRule<>(MainActivity.class);

    @Test
    public void testNoContentView() throws Exception {
        //withId函数会返回一个ViewMatchers对象,用于查找id为R.id.btn_get的view
        onView(withId(R.id.btn_get))
                //click函数会返回一个ViewActions对象,用于发出点击事件
                .perform(click());  

        //通过定时轮询loadingView是否展示中,来判断异步的网络请求是否完成
        View loadingView = mIntentsRule.getActivity().findViewById(R.id.loading_view);
        while (true) {
            Thread.sleep(1000);
            if (loadingView.getVisibility() == View.GONE) {
                break;
            }
        }

        //请求请求完成后,检查UI状态
        //找到R.id.img_result的view
        onView(withId(R.id.img_result))
                //matches函数会返回一个ViewAssertions对象,检查这个view的某个状态是否符合预期
                .check(matches(isDisplayed())); 
    }
}

以上测试代码需要运行在真机/模拟器上,运行过程中可以看到自动拉起MainActivity,并且自动点击了id为btn_get的按钮,然后loading结束后,检查到id为img_result正在展示中,符合预期,整个测试用例就执行成功了。

可以感觉到Espresso的确比较强大,通过其提供的api,常用的UI逻辑基本都可以进行测试。但在复杂项目中,Espreeso的缺点也非常明显:

1. 粒度粗。

Espresso本质上就是一种UI自动化测试方案,很难去验证函数级别的逻辑,如果仅仅是想验证某个功能是否正常的话,又受限于网络状况、设备条件甚至用户账户等等因素,测试结果不可控。

2. 逻辑复杂。

一般页面UI元素庞大且复杂,不可能每个View的交互逻辑都去写测试代码验证,只能选择性验证一些关键交互。

3. 运行速度慢。

用Espresso写测试代码,必须跑在emulator或者是device上面。运行测试用例就变成了一个漫长的过程,因为要打包、上传到机器、然后再一个一个地运行UI界面,这样做的好处是手机上的表现很直观,但是调试和运行速度是真的慢,效率和便捷性上肯定是不如人工测试。

结论:Espresso用例的编写就像是在做业务代码的逆向实现,在实际工作中还不如直接运行项目代码进行人工自测,所以个人感觉Espresso是一个强大的UI自动化测试工具,而非单元测试的解决方案。

1.3 Robolectric

Espresso的问题很明显,那么有没有可能让Android代码脱离手机/模拟器,直接运行在JVM上面呢? 我们需要一个能够隔离Android依赖,并且能够 直接在IDE里run一下就可以知道结果的单元测试方案。

这就牵涉到android.jar的问题,android.jar包含了Android Framework的所有类、函数、变量的声明,但它没有任何具体实现,android.jar仅仅用于JAVA代码编译时,并不会真正打包进APK,Android Framework的真正实现是在设备/模拟器上。在JVM上调用Android SDK里的函数会直接throw RuntimeException。

所以Android单元测试需要解决的一大痛点,就是如何隔离整个Android SDK的依赖。

谷歌官方推荐的开源测试框架 Robolectric就是这么一个工具,简单来说它实现了一套可以在JVM上运行的Android代码。 谷歌官方推荐的开源测试框架 Robolectric就是这么一个工具,它实现了一套可以在JVM上运行的Android代码。

Shadow是Robolectric的核心,这个框架针对Android SDK中的对象,提供了很多影子对象(如ActivityShadowActivityTextViewShadowTextView等),Robolectric的本质是在Java运行环境下,采用Shadow的方式对Android中的组件进行模拟测试,从而实现Android单元测试。对于一些Robolectirc暂不支持的组件,可以采用自定义Shadow的方式扩展Robolectric的功能。

由于Robolectric坑太多(可能是我道行不够),就不放简单demo介绍api了(主要是我跑不通demo),直接说说它的坑吧:

  1. Robolectric版本和Android SDK版本强依赖。Robolectric会shadow大部分Android的代码 版本分散且缺少说明
  2. 首次启动Robolectric会下载maven相关的依赖失败。这个依赖的文件较大,且下载逻辑是写在Robolectric框架里的,不能通过网络代理的方式解决,网上有一些解决方案,但在新版本的Robolectric里都已经失效了。
  3. 不兼容第三方库。大量的第三方库并没有对应的shadow类,会在启动时/运行时报错。
  4. 静态代码块易报错。我们经常在静态代码块里去加载so库或者执行一些初始化逻辑,基本上都会报错且很难解决。如果为了这个单元测试反过来去修改这些逻辑,就显得有点本末倒置、得不偿失了。

国外关于Robolectri也有不少讨论:www.philosophicalhacker.com/post/why-i-…

结论:当被测的代码(Presenter、Model层等)不可避免的依赖了Android SDK代码(比如TextUtils、Looper等),Robolectric可以轻松地让测试代码跑在JVM上,这应该是Robolectric的最大意义了。但是因为上述几点的情况,当连成功运行代码都成为了一种奢望,我不觉得这么一个单元测试框架能够在项目落地。

1.4 Mock

刚刚说到 Espresso需要跑在真机上,Robolectric问题太多在复杂项目中寸步难行。 所以可以考虑使用Mock框架来隔离整个Android SDK以及项目业务的依赖,将单元测试的重心放在函数级别的代码逻辑上。

Mock的定义是创造一些模拟对象/数据来测试程序的行为。 平时我们接触的最多的就是Mock Server,就是模拟接口返回数据提供给前端调试。

但在单元测试中,如果被测的业务依赖比较复杂的上下文,就可以使用Mock创造模拟代码里的对象来保证单元测试的进行。 类似汽车设计者使用碰撞测试假人来模拟车辆碰撞中人的受伤情况。

Mock框架基本上是以下2个:

  1. Mockito
    • 模拟对象并使其按照我们预期执行/返回(类似代码打桩)
    • 验证模拟对象是否按照预期执行/返回
  2. PowerMockito
    • 基于Mockito
    • 支持模拟静态函数、构造函数、私有函数、final 函数以及系统函数

PowerMockito非常强大,但PowerMock使用的越多,表示被测试的代码抽象层次越低,代码质量和结构也越差,没关系,有点历史的大型项目都是类似的情况。 因为PowerMockito是基于Mockito的扩展,所以二者的api都非常相似,常用api是以下两类:

  1. 模拟对象并指定某些函数的执行/返回值
when(...).thenReturn(...)
  1. 验证模拟对象是否按照预期执行/返回
verify(...).invoke(...)

下面讲讲单元测试中如何借助PowerMockito来隔离Android SDK和项目业务的依赖:

  1. Mock被依赖的复杂对象
  2. 执行被测代码
  3. 验证逻辑是否按照预期执行/返回
public class PowerMockitoSample {
    private MainActivity activity;
    private ImageView mockImg;
    private TextView mockTv;

    @Before
    public void setUp() {
        activity = new MainActivity();
        // 1. Mock被依赖的复杂对象。
        // MainActivity依赖了一些View,下面就是Mock出被依赖的复杂对象,并使之成为MainActivity的私有变量
        mockImg = PowerMockito.mock(ImageView.class);
        Whitebox.setInternalState(activity, "resultImg", mockImg);
        mockTv = PowerMockito.mock(TextView.class);
        Whitebox.setInternalState(activity, "resultTv", mockTv);
        Whitebox.setInternalState(activity, "loadingView", PowerMockito.mock(ProgressBar.class));
    }

    @Test
    public void test_onFail() throws Exception {
        // 2. 执行被测代码。
        // 这里要验证activity.onFail()函数
        String errorMessage = "test";
        activity.onFail(errorMessage);
        // 3. 验证逻辑是否按照预期执行/返回。
        // 这里需要验证resultImg 和 resultTv有没有按照预期进行UI状态的改变
        verify(mockImg).setImageResource(R.drawable.ic_error);
        verify(mockTv).setText(errorMessage);
    }
}

上面代码我们把MainActivity所依赖的各种View对象通过mock实现后,剩下的基本都是工作量的问题了。

可以看到,借助Mock框架可以很好的隔离复杂的依赖对象(比如View),从而保证被测的独立单元可以与程序的其他部分相隔离的情况下进行测试,然后专注于验证某个函数/模块的逻辑是否正确且健壮。

必须注意的是,在实际项目中会有很多常用但不影响业务逻辑的代码(Log以及其他统计代码等),部分静态代码块也直接调用Android SDK api。因为单元测试代码运行在JVM上,需要抑制/隔离这些代码的执行,PowerMockito都提供了不错的支持(下篇细说)。

结论:通过PowerMockito这种强大的Mock框架,将被测类所依赖的复杂对象直接代理掉,既不会要求侵入式地修改业务代码 也能够保证单元测试代码 快速有效地运行在JVM上,

2 结论

  1. JUnit是基础。
  2. Espresso需要跑在真机上,可用于依赖Android平台的功能测试而非单元测试。
  3. Roboelctric问题太多在复杂项目中寸步难行,弃了。
  4. Android单元测试主要是通过PowerMockito来隔离整个Android SDK以及项目业务的依赖,将单元测试的重心放在较细粒度(函数级别)的代码逻辑上。