android mvp 单元测试实践

起因

最近摊上个大事儿。某次迭代发布中,某一个注册流程因为密码/验证码参数传反,又恰好因为种种原因黑盒测试没有覆盖到,最终只能通过重新发包解决。反思这个问题的原因,其一,这种参数类型一致的函数调用,编译器没办法判断对错,又难免由于手抖、神智不清、敲码过快等莫名其妙的原因输入错误;其二,不能过分依赖黑盒覆盖,特别是在测试资源有限的前提下。根据很久以前的一些经验,觉得重拾一下单元测试这个技能,应该可以作为一种测试的补充,来规避这些低级错误。于是,就针对现有代码,做了一次单元测试实践。

环境与平台

  • android studio 2.1.2

  • JRE 1.8.0

  • 单元测试工具 AndroidJUnitRunner

  • gradle 版本 2.10

  • 被测试代码架构 MVP

建立用例与重构

当初团队内部刚刚推行MVP的时候,就了解到这个架构对单元测试比较友好,这次可以体验一下究竟有多友好了。闲话少叙,书接上文,首先要明确一下我们的初始目标:避免某些函数调用中,同类型参数传反的笔误。那么从大的方向上来思考,针对MVP这种结构,我们应当伪造一个View,然后以测试Presenter为主,并且需要在Presenter与Model的数据传递中来判别参数是否传的正确;然而具体到具体的问题来具体分析的时候,就发现情况没那么简单。不是以测试驱动开发的代码,在添加单元测试用例的时候很难不去修改源码——小范围的重构是难免的,或许还要破坏一些原有类的封装。在写用例之前,你需要模拟好测试运行前的环境、解掉该解掉的依赖、定义需要注入断言程序运行的接口等等事项。

真实的代码环境

网上似乎所有的单元测试相关的博客都喜欢拿计算器来说事儿,MVP的则喜欢用登陆注册做示例,然而真实的登陆注册往往要复杂得多。手机、邮箱、社交账号几种方式,再根据界面设计上的相似性抽象复用,最终的要考虑的实际问题有很多。现在单以注册流程为例,被测试的 presenter 类,针对的是注册的最后一步界面。假设现在要实现一个邮箱账户注册的单元测试用例,那么针对邮箱账号的注册接口的调用,就是界面上确认按钮的点击响应,在被测试的 presenter中 实现是这个样子的:

public void confirm() {
    switch (mView.getMode()) {
        case AccountConfirmActivity.MODE_CHANGE_PASSWORD:
            resetPassword();               
            break;

        case AccountConfirmActivity.MODE_FORGET_PASSWORD:
            resetPassword();               
            break;

        case AccountConfirmActivity.MODE_REGISTER:
            register();   
            break;
    }
}

private void register() {
    switch (mView.getPlatform()) {
        case AccountConfirmActivity.PLATFORM_EMAIL:
            TaoXiaoQiUser.getInstance().registerAccountWithEmail(mCountryCode, mEmail, mView.getPassword(), mIRegisterCallback);
            break;

        case AccountConfirmActivity.PLATFORM_PHONE:
            TaoXiaoQiUser.getInstance().registerAccountWithPhone(mCountryCode,mPhoneNum,mView.getPassword(),mView.getValidateCode(),mIRegisterCallback);
            break;
    }
}

首先,界面的复用导致代码上对确定按钮的响应有不同的处理,具体是那种情况取决于View层的回调;其次,由于网络请求接口直接使用SDK封装过的,所以代码中并没有model这一层,而是直接调用SDK、传回调接口的方式实现。整个流程还没完,对于SDK的注册调用,传入的回调接口定义如下:

private IRegisterCallback mIRegisterCallback = new IRegisterCallback() {
    @Override
    public void onSuccess(User user) {
        mHandler.sendEmptyMessage(MSG_REGISTER_SUCC);
    }

    @Override
    public void onError(String errorCode, String errorMsg) {
        Message msg = MessageUtil.getCallFailMessage(MSG_REGISTER_FAIL, errorCode, errorMsg);
        mHandler.sendMessage(msg);
    }
};

可以看出,回调经过了简单的包装之后交给了handler:

@Override
public boolean handleMessage(Message msg) {
    switch (msg.what) {
        case MSG_REGISTER_FAIL:
            mView.modelResult(msg.what, (Result) msg.obj);
            break;

        case MSG_REGISTER_SUCC:
            loginSuccess();
            break;
    }
    return super.handleMessage(msg);
}

成功的分支走登陆成功的逻辑,这里面耦合了登陆成功之后的统计、配置处理;失败的分支回调给了View层,一般是做显示出错提示处理。

差点漏掉了,要测试这个类的前提是要有这个类的对象实体,显然我们需要知道它的构造是什么:

public AccountConfirmPresenter(Activity activity, IAccountConfirmView validateCodeView){
        super(activity);
        mView = validateCodeView;
        mContext = activity;
        initData(activity);
    }

下手前的问题

要知道写一个单元测试用例跟把大象装进冰箱差不多——都要分三步,先模拟好运行环境;再输入用例数据;最后断言函数运行导致的变化。说起来简单,但看到上边整个注册流程,总有些无从下手,把能想到的问题都罗列下来。

  1. 要测试函数confirm,无参,却要运行注册-邮箱这个流程上,如何控制;
  2. 回到最初的问题,注册接口的参数传反的预判,可是最终调用到的私有函数register中直接调用了SDK的接口,如何断言参数的传递是正确的;
  3. 在哪里断言所有流程跑通的结果;
  4. loginSuccess涉及到应用配置、统计结果,这部分是不应当放入在单元测试中的,如何处理;
  5. 用例数据应当如何设计;
  6. 构造函数中,initData 根据Activity的Intent获取了几个初始数据,手机号/邮箱/默认国家码等,这些数据怎么处理。

破题

有问题自然有答案,没有答案的话我们可以修改问题,再弄出个答案。换句话说,实在搞不定,只能修改源码,让源码具有更强的可测试性。好在以上几个问题还不至于需要大规模的修改功能代码。

第一个与第六个问题其实就是模拟运行环境的问题,从confirm到register,可以看到分支的选区依赖于View的接口getMode、getPlatform,回到构造函数,本身我们也是要伪造一个View层传递进去的。所以我们需要伪造一个可以传递我们用例数据的View传递给被测试类,至于构造函数中的initData,这个方法我们不需要它执行,因为我们需要自己的测试数据来设定手机号/邮箱/默认国家码,这种情况下,就需要修改 initData 函数的权限为 protected ,新建一个 ForkAccountConfirmPresenter 类覆盖掉这个方法的运行。那么,现在我们的测试对象变成了 ForkAccountConfirmPresenter;

第二个问题是核心要解决的问题,乍一看也是一大难点。好在SDK函数TaoXiaoQiUser.getInstance()返回的是一个接口 ITaoXiaoQiUser,让问题找到了答案。可以把这个接口的获取抽象成一个protected函数,同样利用继承覆盖的方式,注入我们自己的mock User;

第三、四个问题需要一起来说明。loginSuccess本身是一个不需要执行的、需要解掉的依赖,跑个测试用例影响到数据统计和应用配置是不允许的,那么这里的处理跟 initData 函数一样就好。至于断言的位置,在本用例中传递参数是否正确是需要断言的,也就是说我们 mock 的 User 中需要有断言能力;再一点就是整个流程的走通,注册逻辑无非是成功与否两种情况,根据代码可以知道,注册失败的逻辑最后会返回到View层的 modelResult 函数、成功的逻辑会调用loginSuccess函数。也就是说,伪造的View的modelResult、继承覆盖的loginSuccess需要有直接或者间接的断言能力;

上面几个问题讲的是用例实现需要源码改动、新建辅助测试类等需要行的方便,第五个问题则是用例数据设计的本身。用例要覆盖到代码的所有分支,一般就要看输入数据是否覆盖到了所有的情况,通常是普通数据、边界数据和无效数据三种情况,再考虑到测试与断言使用的便捷性。先定义一个数据类包含输入参数、输出断言的数据等,再定义一个专用的数据类,描述所有可能的情况即可。在测试用例中,调用不同的数据断言对应的不同输出。

实现

这里贴部分代码,足以解释上面破题的思路就好。

被测试类

public class AccountConfirmPresenter extends BasePresenter{
        private void register() {
            switch (mView.getPlatform()) {
                case AccountConfirmActivity.PLATFORM_EMAIL:
                    getTaoXiaoQiUser().registerAccountWithEmail(mCountryCode, mEmail, mView.getPassword(), mIRegisterCallback);
                    break;

                case AccountConfirmActivity.PLATFORM_PHONE:
                    getTaoXiaoQiUser().registerAccountWithPhone(mCountryCode,mPhoneNum,mView.getPassword(),mView.getValidateCode(),mIRegisterCallback);
                    break;
            }
        }

        protected ITaoXiaoQiUser getTaoXiaoQiUser(){
            return TaoXiaoQiUser.getInstance();
        }

        protected void initData(){
            //...
        }

        protected void loginSuccess() {
            //...
        }

        //for test
        public void setCountryCode(String mCountryCode) {
            this.mCountryCode = mCountryCode;
        }
        //for test
        public void setPhoneNum(String mPhoneNum) {
            this.mPhoneNum = mPhoneNum;
        }
        //for test
        public void setEmail(String mEmail) {
            this.mEmail = mEmail;
        }
    }

原本的被测试类 AccountConfirmPresenter ,开放了三个私有变量的set函数供测试用例配置测试数据;两个函数权限由private降级到protected,方便Fork类覆盖;SDK调用接口被抽象为protected级函数调用,同样为了传入山寨货User。

山寨被测试类

public class ForkAccountConfirmPresenter extends AccountConfirmPresenter {

    private MockTaoXiaoQiUser mMockUser;
    private boolean mIsCallLoginSuccess = false;


public ForkAccountConfirmPresenter(Activity activity, IAccountConfirmView validateCodeView,MockTaoXiaoQiUser user) {
super(activity, validateCodeView);
mMockUser = user;
}

    @Override
    protected ITaoXiaoQiUser getTaoXiaoQiUser() {
        return mMockUser;
    }

    @Override
    protected void initData(Activity activity) {
        //do nothing
    }

    @Override
    protected void loginSuccess() {
        mIsCallLoginSuccess = true;
    }

    public boolean isIsCallLoginSuccess(){
        return mIsCallLoginSuccess;
    }
}

山寨类通过继承覆盖掉了众多不该调用的函数、接入了伪造的User、提供了loginSuccess被调用的断言标志。

伪造User

public class MockTaoXiaoQiUser implements ITaoXiaoQiUser {
    @Override
    public void registerAccountWithEmail(final String countryCode, final String email, final String passwd, final IRegisterCallback callback) {
        if(email.equals(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.userName)){
            Assert.assertSame(passwd, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.passWord);
            callback.onSuccess(null);
        }else if(email.equals(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.userName)){
            Assert.assertSame(passwd, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.passWord);
            callback.onError(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.errorCode, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.errorMsg);
        }else{
            Assert.fail();
        }
    }
}

伪造的User类断言了参数是否正确、要覆盖到所有测试数据的调用返回,实际上是个模拟服务端返回的家伙。

伪造的View

public class MockAccountConfirmView implements IAccountConfirmView {
    private int mMockMode = 0;
    private int mMockPlatform = 0;
    private String mMockCode;
    private String mMockPassword;
    private IAccountAssert mIAccountAssert;

    public interface IAccountAssert{
        void check(int what, Result result);
    }

    public MockAccountConfirmView(){

    }

    public void setmMockMode(int mMockMode) {
        this.mMockMode = mMockMode;
    }

    public void setmMockPlatform(int mMockPlatform) {
        this.mMockPlatform = mMockPlatform;
    }

    public void setmMockCode(String mMockCode) {
        this.mMockCode = mMockCode;
    }

    public void setmMockPassword(String mMockPassword) {
        this.mMockPassword = mMockPassword;
    }

    public void setmIAccountAssert(IAccountAssert mIAccountAssert) {
        this.mIAccountAssert = mIAccountAssert;
    }

    @Override
    public String getValidateCode() {
        return mMockCode;
    }

    @Override
    public String getPassword() {
        return mMockPassword;
    }

    @Override
    public void modelResult(int what, Result result) {
        if(mIAccountAssert != null){
            mIAccountAssert.check(what,result);
        }
    }

    @Override
    public int getMode() {
        return mMockMode;
    }

    @Override
    public int getPlatform() {
        return mMockPlatform;
    }
}

伪造的View是对Activity的替代。它伪造了环境标志MODE&PLATFORM、伪造了密码/验证码供测试用例设置、建立了IAccountAssert断言接口供测试用例断言modelResult函数的回调情况。

测试用例

@Test
public void emailRegister() throws Exception {
    //配置环境
    mMockAccountConfirmView.setmMockCode(AccountConfirmActivity.EXTRA_ACCOUNT_CONFIRM_MODE);
    mMockAccountConfirmView.setmMockPlatform(AccountConfirmActivity.PLATFORM_EMAIL);

    mMockAccountConfirmView.setmMockPassword(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.passWord);
    mPresenter.setEmail(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.userName);
    mMockAccountConfirmView.setmIAccountAssert(new MockAccountConfirmView.IAccountAssert() {
        @Override
        public void check(int what, Result result) {
            Assert.assertEquals(what, AccountConfirmPresenter.MSG_REGISTER_SUCC);
        }
    });

    //调用被测试接口
    mPresenter.confirm();

    //结果断言
    Assert.assertTrue(mPresenter.isIsCallLoginSuccess());
}

严格来讲这只是小半个测试用例,因为它只包含了注册成功的部分流程。

一步一个脚印(kēng)

实现上当然不是一气呵成的。android studio上第二次使用AndroidJUnitRunner依然是坑点多多,上一次是半年以前、也只是基本过了一下,记忆并非犹新、跟没有一样。加之android studio本身升级、工程各路依赖冲突,真是举步维艰。

  • 配置
    关于单元测试的配置都是在gradle配置中完成,首先AndroidJUnitRunner的配置:

    defaultConfig {
        //...
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    

    其次,依赖:

    dependencies {
        //...
        testCompile 'junit:junit:4.12'
        //ADD THESE LINES:
        androidTestCompile 'com.android.support.test:runner:0.2'
        androidTestCompile 'com.android.support.test:rules:0.2'
        androidTestCompile 'com.android.support.test.espresso:espresso-core:2.1'
    }
    

    这应该不是最新版本的依赖,也可能并非都是必须的,完全取决于用例的需求。不依赖于android特殊实例(Activity、Context等)的用例属于纯功能型的,只需要testCompile的依赖,它的代码位置在src目录下的test目录中;需要android界面支持的依赖是androidTestCompile,用例代码在src的androidTest目录下。测试用例的代码最好使用android studio自带的工具方法生成,它可以保证你的用例代码在正确的位置。

  • 异常: ‘testClasses’ not found

    正常的工程目录一般都有不少的module依赖,而不是像各种实例demo中只有一个干巴巴的app module。这个时候或许会遇到这个异常:

    Error:FAILURE: Build failed with an exception.

    * What went wrong:

    Task 'testClasses' not found in project ':TaoXiaoQiSdk'.
    

    * Try:

    Run gradle tasks to get a list of available tasks. Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output
    

    这个时候,需要在提示的model的gradle配置文件中添加以下配置,表示不要瞎管闲事,这里没有需要测试的类:

    task testClasses {
        doLast {
            println 'This is a dummy testClasses task'
        }
    }
    
  • 错误: 程序包android.support.test.rule不存在

    这个时候需要检查你的gradle配置,各种依赖是否配置到位,最重要的是AndroidJUnitRunner在defaultConfig中是否漏配。早期的android studio版本需要在Build Variants中选择对应的测试用例方式,2.1之后已经默认使能了,如果想回复之前的样子需要在setting中取消勾选:

    按目录找不是很靠谱,因为android studio在不断升级中各种功能似乎会换位置,直接搜关键字这招简单粗暴又实用。

  • 测试用例的位置

    如果不依赖android的实例,仅仅使用JUnit Test的话,自然是百无禁忌,哪个module的测试类,就放到哪个目录下就好;但如果需要android页面支持,则最好统一放到application module的代码目录下。我遇到的问题是在子module下运行子module的测试用例时,会报dex方法数超出65535的错误,关于多dex处理只有在application module的配置中处理时,最好把用例都放在此目录下,避免这种问题。

  • 依赖异常

    遇到了几种冲突,其一:

    Error:Conflict with dependency ‘com.google.code.findbugs:jsr305’. Resolved versions for app (3.0.0) and test app (2.0.1) differ. See http://g.co/androidstudio/app-test-app-conflict for details.

    在gradle文件中增加如下配置:

    android{
        //...
        configurations.all {
            resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.0'
        }
    }
    

    其二:

    Error:Conflict with dependency ‘com.android.support:support-annotations’. Resolved versions for app (23.1.1) and test app (22.0.0) differ. See http://g.co/androidstudio/app-test-app-conflict for details.

    在依赖配置中增加annotations的设置:

    androidTestCompile 'com.android.support:support-annotations:23.1.1'
    

    以上解决方案来源于 stackoverflow

奔跑吧,用例

把源码注册部分参数改反,之后运行用例,可以得到:

java.lang.AssertionError
at org.junit.Assert.fail(Assert.java:92)
at org.junit.Assert.fail(Assert.java:100)
at login.mock.MockTaoXiaoQiUser.registerAccountWithEmail(MockTaoXiaoQiUser.java:58)
at com.TaoXiaoQi.stencil.presenter.login.AccountConfirmPresenter.register(AccountConfirmPresenter.java:214)
at com.TaoXiaoQi.stencil.presenter.login.AccountConfirmPresenter.confirm(AccountConfirmPresenter.java:193)
at login.AccountConfirmPresenterTest.emailRegister(AccountConfirmPresenterTest.java:70)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:511)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
at ...

Tests ran to completion.

成功预防了参数传反的低级错误。

总结

  • 在类似MVP架构的代码中做单元测试确实十分便捷,无法想象如果是之前把控制逻辑糅合在Activity代码中的做法如何进行测试。并且在理论上,只对presenter/module进行测试的话无需依赖android环境,测试效率会大大提高。当然,为图省事儿在本例中还是使用了android环境,后续可以考虑采用mock库来模拟Activity的行为,来摆脱对Instrumentation的依赖。
  • 用例的添加,会对源码进行一定程度的“破坏”。测试需要,暂时无法避免,目前想到降低损失的方法,可以增加必要的文字注释;类似android源码的“@Test”注释,也是一个可以考虑的方向;一些属性开set接口的行为,可以使用反射代替。此外,新代码编写的过程中,要考虑到代码的可测试性也是必要的。

  • 时间损耗。以上编写一个接口函数之中一种流程的一种情况的测试用例,大概消耗了我4个工时的时间。当然这里包含了源码重构、用例设计、数据结构构思、开发工具单元测试部分熟悉、异常解决等等各方面的时间,实际上第一个用例跑通之后,同一个被测试类的其他用例和测试数据的建立速度是很快的。但用例的规模和有效性,是由业务的复杂程度决定的,并且用例也是需要维护的,这部分成本是需要衡量的。

  • 接口是个好东西,山寨伪造、接缝注入,单元测试必备良药。

参考

在Android Studio中进行单元测试和UI测试

Android单元测试初探

官方文档 Building Local Unit Tests

感谢您赏个荷包蛋~