android-MVP架构中Presenter的单元测试

一,为什么只对Presenter进行单元测试,而不测试Model和View呢?

原因1:

mvp中,全部业务逻辑都集中在这个类中,bug的高发区,只要这块测试好了,app稳定性可以大大提高。

原因2:

在mvp架构中model层主要进行负责存储、检索、操纵数据(包括网络请求),这些并不涉及业务逻辑的处理,没能想到可以怎么测试,如果读者有什么好建议可以留言给我;而view层主要进行ui操作,与用户进行交互,更加适合进行UI测试。

二,如何测试Presenter?

总共分为两个步骤,以welcome功能模块为例(检测是否来自其他平台用户登录)

步骤1:

编写契约类,实现mvp

契约类:

/**
 * 欢迎模块 处理其他平台登录用户
 */
public interface WelcomeContract {

    interface View extends BaseView {

        void handleError(String errorMsg);

        void outsideLoginSuccess(LoginBean loginBean);
    }

    interface Presenter extends BasePresenter {

        boolean handleData(Intent data);
    }

    interface Model extends BaseModel {

        void outsideLogin(String jsonData, SubscriberAction subscriberAction);

        void outsideLoginSuccess(LoginBean loginBean);
    }
}

presenter层:

public class WelcomePresenter implements WelcomeContract.Presenter {

    private static final String TAG = "WelcomePresenter";
    WelcomeContract.View mView;
    WelcomeContract.Model mModel;

    public WelcomePresenter(WelcomeContract.View view, WelcomeContract.Model model) {
        this.mView = view;
        this.mModel = model;
    }

    @Override
    public boolean handleData(Intent data) {

        if (data != null) {
            try {
                Uri uri = data.getData();
                if (uri != null) {
                    String json = uri.getQueryParameter("data");
                    JSONObject jsonObject = new JSONObject(json);
                    Logger.t("outsideLogin").e(jsonObject.toString());
                    if (jsonObject != null) {
                        String userId = null;
                        String userType = null;
                        String source = null;
                        try {
                            userId = jsonObject.getString("userId");
                            userType = jsonObject.getString("userType");
                            source = jsonObject.getString("source");
                        } catch (Exception e) {
                            Logger.e(e, TAG);
                        }

                        if (userId == null) {
                            String errorMsg = "userId为空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        if (userType == null) {
                            String errorMsg = "userType为空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        if (source == null) {
                            String errorMsg = "source为空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        mView.showProgressDialog();
                        //验证没有问题,请求服务器获取登录数据
                        mModel.outsideLogin(json, new SubscriberAction<LoginBean>(mView, loginBean -> {
                            mView.dismissProgressDialog();
                            if (loginBean == null) {
                                mView.handleError("返回数据为空");
                            } else {
                                mModel.outsideLoginSuccess(loginBean);
                                mView.outsideLoginSuccess(loginBean);
                            }
                        }, throwable -> {
                            throwable.printStackTrace();
                            if (mView.getVActivity() != null && mView.getVActivity().isFinishing()) {
                                mView.getVActivity().runOnUiThread(() -> {
                                    mView.handleError(throwable.getMessage());
                                    mView.dismissProgressDialog();
                                });
                            }
                        }));


                        return true;
                    } else {
                        mView.handleError("解析json出错");
                        return false;
                    }
                } else {
                    return false;
                }
            } catch (Exception e) {
                Logger.e(e, TAG);
                mView.handleError("解析登录数据出错");
                return true;
            }
        }
        return false;
    }
}

model层:

public class WelcomeModel implements WelcomeContract.Model {
    private StudentService mService = StudentRetrofitClient.INSTANCE().getService();

    public WelcomeModel() {

    }

    @Override
    public void outsideLogin(String jsonData, SubscriberAction subscriberAction) {
        StudentRetrofitClient.INSTANCE().toSubscribe(mService.outsideLogin(jsonData), subscriberAction);
    }

    @Override
    public void outsideLoginSuccess(LoginBean loginBean) {
        LoginBiz.saveLoginData(loginBean);
    }

}

view层就不贴了,主要关注点在presenter层,model层代码有助于测试中参数捕抓的理解

步骤2:

编写针对presenter的测试类
功能写完后,验证业务逻辑是否能处理各种数据的输入。

特别注意:以前完成了功能后就一直等后台接口数据,接口调通了,心里才踏实;而现在,我不需要等后台接口,直接就能验证presenter的业务逻辑写得好不好,能不能处理各种突发意外情况,这是单元测试的一大好处。单元测试给我最大的感受:一个字: ,两个字:踏实 ,具体一点来说:对自己写的代码不会胆战心惊,不会害怕功能上线了惊呼:我擦,这什么情况?我写的时候完全就没想到会有这种情况发生的!写单元测试其实是意识到自己代码具有局限性的的过程,无论对自己,对项目都是大有裨益的。

测试内容:

1,验证handleData(Intent data)能否处理空数据
2,验证handleData(Intent data)能否处理异常数据
3,验证handleData(Intent data)能否处理正常数据

WelcomePresenterTest:


/**
 * Android单元测试示例
 * 使用框架简介:
 * junit(纯java代码可用该框架测试),
 * mockito(模拟数据),
 * robolectric(模拟Android运行环境,可以测试Android代码)
 * 纯java部分的可以通过Junit4来进行单元测试,
 * 而对于用到android自身代码的测试不能依靠Junit进行,
 * 对于这种情况解决方案之一就是使用Robolectric
 */

/**
 * 知识点1,runWith:RobolectricTestRunner
 * 表示测试时使用robolectric运行环境,可以测试Android代码,比如:textview.setText()这样的代码
 * 如果测试Presenter中没有涉及Android代码,则不要加,否则拖慢测试速度。
 */
@RunWith(RobolectricTestRunner.class)
/**
 * 知识点2,指定manifest文件,格式如下:
 * @Config(manifest = "../app/AndroidManifest.xml")
 *
 */
@Config(manifest = Config.NONE)

public class WelcomePresenterTest {
    WelcomeContract.Presenter mPresenter;
    /**
     * 知识点3,@mock 注解介绍:
     * 模拟某个类对象
     * 为什么要模拟?
     * 答:因为这是测试环境,view对象的获取很麻烦很困难,并且view并不是我们测试的对象。
     */
    @Mock
    WelcomeContract.View mView;
    @Mock
    WelcomeContract.Model mModel;
    /**
     * 知识点4:参数捕抓器
     * 用于捕抓model层方法中的参数
     */
    ArgumentCaptor<SubscriberAction> captor;

    /**
     * 在测试前的数据初始化
     */
    @Before
    public void setUp() {
        //Mockito的初始化
        MockitoAnnotations.initMocks(this);
        /**
         * 知识点5:Presenter的创建
         * 注意:在view层就需要创建model,将之作为presenter的构造方法参数。
         * 对比之前的写法:mPresenter = new WelcomePresenter(this)的写法
         * 这样的写法好处:model可以在测试中模拟,如果model完全隐藏在presenter的
         * 构造方法中,model还需要用参数捕抓出来,比较麻烦。
         */
        mPresenter = new WelcomePresenter(mView, mModel);
        captor = ArgumentCaptor.forClass(SubscriberAction.class);
        /**
         *知识点6: 把将Rxjava接口调用的异步操作变成同步,加快测试速度。
         */
        UnitTestHelper.openRxTools();

    }

    /**
     * 传递给presenter的参数异常的测试
     *
     * @throws Exception
     */
    @Test
    public void handleDataFail() throws Exception {
        Intent intent = mock(Intent.class);
        Uri uri = mock(Uri.class);
        intent.setData(uri);
        when(uri.getQueryParameter("data"))
                 //模拟数据为空情况
//                .thenReturn(null)

                 //模拟数据缺失情况,少了userId
                .thenReturn("{\"source\":\"xxxx\",\"userType\":\"xxxx\"}");
        when(intent.getData()).thenReturn(uri);

        mPresenter.handleData(intent);
//        assertFalse(mPresenter.handleData(intent));
        verify(mView).handleError(any(String.class));
    }

    /**
     * 传递给presenter的参数正常的测试
     * @throws Exception
     */
    @Test
    public void handleDataSuccess() throws Exception {
        /**
         * 模拟数据
         */
        Intent intent = mock(Intent.class);
        Uri uri = mock(Uri.class);
        when(uri.getQueryParameter("data")).thenReturn("{\"userId\":\"xxxx\",\"source\":\"xxxx\",\"userType\":\"xxxx\"}");

        when(intent.getData()).thenReturn(uri);

        mPresenter.handleData(intent);
        /**
         * mPresenter.handleData调用后
         * 1,验证(verify)model是否调用了outsideLogin方法,
         * 2,并且捕获outsideLogin方法参数subscriberAction对象
         */
        verify(mModel).outsideLogin(any(String.class), captor.capture());
        /**
         * 疑问:为什么要捕抓subscriberAction对象?
         * 答:因为模拟调用接口成功中需要用到subscriberAction这个订阅者对象。
         *
         */
        UnitTestHelper.mockCallBack(new LoginBean(), captor.getValue());
        /**
         * 接口数据LoginBean成功模拟返回后
         * 验证(verify)Presenter是否调用了model以及view中outsideLoginSuccess方法。
         */
        verify(mModel).outsideLoginSuccess(any(LoginBean.class));
        verify(mView).outsideLoginSuccess(any(LoginBean.class));
    }

}

UnitTestHelper单元测试工具类:


/**
 * 用于:
 *1,模拟model中网络请求返回的数据
 *2,把RXJava的异步变成同步,方便测试
 */
public class UnitTestHelper {

    public static void mockFailCallBack(SubscriberAction sub) {
        mockCallBack(99,"我错了",null,sub);
    }
    public static void mockFailCallBack(int resultCode,String msg,SubscriberAction sub) {
        mockCallBack(resultCode,msg,null,sub);
    }
    public static void mockEmptyCallBack(SubscriberAction sub) {
        mockCallBack(0,"模拟接口调用成功",null,sub);
    }
    public static void mockCallBack(Object data,SubscriberAction sub) {
        mockCallBack(0,"模拟接口调用成功",data,sub);
    }
    public static void mockCallBack(int resultCode,String msg,Object data,SubscriberAction sub) {
        BaseRetrofitClient.toSubscribe(Observable.just(new HttpResult<>(resultCode,msg,data)),sub);
    }
    private static boolean isInitRxTools = false;

    /**
     * 把RXJava的异步变成同步,方便测试
     */
    public static void openRxTools() {
        if (isInitRxTools) {
            return;
        }
        isInitRxTools = true;

        RxAndroidSchedulersHook rxAndroidSchedulersHook = new RxAndroidSchedulersHook() {
            @Override
            public Scheduler getMainThreadScheduler() {
                return Schedulers.immediate();
            }
        };

        RxJavaSchedulersHook rxJavaSchedulersHook = new RxJavaSchedulersHook() {
            @Override
            public Scheduler getIOScheduler() {
                return Schedulers.immediate();
            }
        };

        // reset()不是必要,实践中发现不写reset(),偶尔会出错,所以写上保险
        RxAndroidPlugins.getInstance().reset();
        RxAndroidPlugins.getInstance().registerSchedulersHook(rxAndroidSchedulersHook);
        RxJavaPlugins.getInstance().reset();
        RxJavaPlugins.getInstance().registerSchedulersHook(rxJavaSchedulersHook);
    }
}

这两个类是这篇博客的精华所在,耗费了我们Android组不少时间,不少精力探索出来的,有兴趣的读者可以慢慢读这段代码,收获会超乎想象。

三,Android测试填坑

1,选框架的坑

非常建议采用robolectric框架,工欲善其事必先利其器,一开始没有选择robolectric框架,就开始撸单元测试,摔得脸好疼,郁闷了一整天:明明我这样写单元测试没有错的呀,怎么就死活都没法通过测试呢?
原因在于mvp中测试presenter过程中无可避免会调用Android系统API,而junit不支持,mock也不可能面面俱到,有些方法中Android API藏得比较深,很难都mock到,而用了robolectric框架就完全没有问题。

robolectric原理:实现一套JVM能运行的Android代码,然后在unit test运行的时候去截取android相关的代码调用,然后转到他们的他们实现的Shadow代码去执行这个调用

1,创建单元测试类的小坑

有个同事不知道AS能自动生成测试类,然后说,单元测试好麻烦,创建一个类要写这么多东西。
贴上一个自动创建测试类的小教程:

自动创建测试类-步骤1.png
自动创建测试类-步骤2.png
自动创建测试类-步骤3.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容