解读Android官方MVP项目单元测试


Google在3月份推出了一个项目,用来介绍Android MVP架构的各种组合,可以认为是官方在这方面的最佳实践。令人称道的是除了MVP本身之外,这些工程配备了极其完善的单元测试用例,学习价值极高。本文着重针对todo-mvp的单元测试进行解读。官方MVP项目的Github地址是:
https://github.com/googlesamples/android-architecture

写在前面

  1. 关于MVP
    关于MVP的介绍很多,这不是本文的重点,这里列举近期一些比较好的文章。
  1. 关于单元测试
    对于单元测试,需要预先了解以下内容
  • Android Studio的test和AndroidTest
  • AndroidJUnitRunner:一个兼容Junit4的Andriod单元测试框架
  • Mockito:单元测试利器
  • Espresso:支持UI测试的单元测试框架
  1. 关于todo-mvp的功能
功能介绍

简而言之,这个工程包含了三个模块:待办事项列表模块,待办事项详情模块,统计模块。

MVP各层的单元测试选型

在该项目中,MVP各层所使用的单元测试框架如下图所示:

官方todo-mvp的UT选型
  • P层:不需要任何Android环境,因此使用Junit测试即可
  • V层:使用Google强大的Espresso进行UI的测试
  • M层:涉及到数据库相关操作,因此需要依赖Android环境,使用AndroidJUnitRunner进行测试

在此处,我们先大致了解一下MVP各层的UT选型,然后通过一个例子,看看各层之间如何配合测试,最后再对各层UT选型的原因进行分析,从而理解整体测试架构。

接下来我们以TO-DO List页面(TasksActivity/TaskFragment)中加载任务列表功能为例,此场景的功能界面如下图所示:

待办任务列表

Presenter层的测试

在这个功能里,Presenter只做了一件事情,就是loadTask(),时序图如下所示:

loadTask的时序图

从时序图上看,loadTask执行的逻辑是,1.调用View层开启进度条->2.从Model层获取待办任务列表->3.Model层以回调函数的形式返回数据->4.调用View层关闭进度条->5.调用View层显示任务列表。这5个步骤里,每个步骤的逻辑是否准确是View层和Model层该测试的事情,对于Presenter层来讲,他的测试任务是确保这5个步骤如期调用。为了达成此目的,我们会采用Mockito.verify()的api进行测试,这个测试类是TasksPresenterTest,代码如下:

@Test
public void loadAllTasksFromRepositoryAndLoadIntoView() {
    //确保当前视图是All视图
    mTasksPresenter.setFiltering(TasksFilterType.ALL_TASKS);
    //第0步:开始加载数据
    mTasksPresenter.loadTasks(true);

    //验证第2步:获取待办事项的逻辑有调用
    verify(mTasksRepository).getTasks(mLoadTasksCallbackCaptor.capture());
    //通过Mockito的Capture进行回调函数的测试,对应第3步
    mLoadTasksCallbackCaptor.getValue().onTasksLoaded(TASKS);

    //验证第1步:进度条显示
    verify(mTasksView).setLoadingIndicator(true);
    //验证第4步:进度条关闭
    verify(mTasksView).setLoadingIndicator(false);
    ArgumentCaptor<List> showTasksArgumentCaptor = ArgumentCaptor.forClass(List.class);
    //验证第5步:View层显示待办任务列表
    verify(mTasksView).showTasks(showTasksArgumentCaptor.capture());
    //在Before周期里,事先初始化了3条待办任务数据
    assertTrue(showTasksArgumentCaptor.getValue().size() == 3);
}

注:这里涉及到异步回调函数如何测试的问题,使用Mockito的Capture可以解决此问题。具体细节,三言两语说不清,后续考虑专门写篇文章。

总结:让Presenter充当个合格的皮条客,去调用其他两层的逻辑,在假设其他两层代码逻辑都是正确的前提下,做一些mock测试,尽可能覆盖所有逻辑路径。

View层的测试

这一层的测试其实很清晰,站在QA的角度,我们想要验证待办任务列表时候,会设计以下的测试用例:


验证待办任务列表的测试流程

通过Espresso可以模拟这些步骤,并进行验证,这个测试类是TasksScreenTest,代码如下:

@Test
public void showAllTasks() {
    //添加2个待办任务,对应第1、2、3步
    createTask(TITLE1, DESCRIPTION);
    createTask(TITLE2, DESCRIPTION);

    //切换为All视图,对应第4步
    viewAllTasks();
    
    //验证Title1和Title2对应的Item存在,对应第5步
    onView(withItemText(TITLE1)).check(matches(isDisplayed()));
    onView(withItemText(TITLE2)).check(matches(isDisplayed()));
}

其中,createTask()的实现如下:

private void createTask(String title, String description) {
    //点击添加按钮,对应第1步
    onView(withId(R.id.fab_add_task)).perform(click());

    //打开软键盘,输入标题和描述,对应第2步
    onView(withId(R.id.add_task_title)).perform(typeText(title),
            closeSoftKeyboard());
    onView(withId(R.id.add_task_description)).perform(typeText(description),
            closeSoftKeyboard());

    //保存待办任务,对应第3步
    onView(withId(R.id.fab_edit_task_done)).perform(click());
}

viewAllTasks()的实现如下:

private void viewAllTasks() {
    //点击过滤按钮
    onView(withId(R.id.menu_filter)).perform(click());
    //点击ALL的选项
    onView(withText(R.string.nav_all)).perform(click());
}

连上设备,跑起UT,会自动启动相应的Activity界面,做相应的操作后进行测试。

总结:Espresso好强大,而且这一层的测试站在用户的角度,所有逻辑是黑盒,在功能层面测试输入(用户操作)输出(用户得到的界面反馈),而技术层面,由于界面是所有层的入口,得到输出后,除了测试View层本身的逻辑之外,其实已经粗糙的覆盖了M和P的逻辑了。

Model层的测试

关于Model层的测试,首先要了解下该项目中,model层的设计,类层次如下图所示:


Model层的类图
  • TasksLocalDataSource:负责本地数据库增删改查操作
  • TasksRemoteDataSource:负责网络请求(该项目中用handler.postDelayed()延时来模拟网络请求)
  • TasksRepository:相当于整个Model层的门面,根据逻辑判断决定数据来自于本地数据库或是网络。Presenter层只与它打交道。

根据以上分析,可见对Model层的测试要完整的覆盖这三个类。

  1. 我们先看门面TasksRepository的测试,先看看这个类中有关获取待办任务列表的流程图:
TasksRepository流程图

所以对于TasksRepository来讲,测试的内容主要是验证1,2,3的逻辑是否在相应的输入下覆盖到位,对于1,2,3的数据准确性无需关心,由各自DataSource去验证,因此它的测试与Android环境无关,用Junit+Mockito测试。要完整覆盖的话,需要多个测试case,篇幅有限,这里只讲第2种。这个测试类是TasksRepositoryTest,代码如下:

@Test
public void getTasksWithDirtyCache_tasksAreRetrievedFromRemote() {
    //将数据设置为脏数据
    mTasksRepository.refreshTasks();
    //数据为脏数据,因此此时需要从网络获取
    mTasksRepository.getTasks(mLoadTasksCallback);

    //验证第2种情况:用TasksRemoteDataSource调用getTasks()获取数据后返回
    setTasksAvailable(mTasksRemoteDataSource, TASKS);

    //验证第1种情况没有发生
    verify(mTasksLocalDataSource, never()).getTasks(mLoadTasksCallback);
    //验证TasksRemoteDataSource执行了回调函数
    verify(mLoadTasksCallback).onTasksLoaded(TASKS);
}

其中,setTasksAvailable()代码如下:

private void setTasksAvailable(TasksDataSource dataSource, List<Task> tasks) {
    //验证第2种情况:使用TasksRemoteDataSource调用getTasks()
    verify(dataSource).getTasks(mTasksCallbackCaptor.capture());
    //执行回调 函数
    mTasksCallbackCaptor.getValue().onTasksLoaded(tasks);
}
  1. 接下来是是TasksLocalDataSource的测试。该测试与数据库有关,因此依赖于Android环境,且要验证数据存取的准确性,因此需要做一些断言,使用AndroidJUnitRunner进行测试,这个类是TasksLocalDataSourceTest,代码如下:
@Test
public void getTasks_retrieveSavedTasks() {
    //事先往DB中插入两条数据
    final Task newTask1 = new Task(TITLE, "");
    mLocalDataSource.saveTask(newTask1);
    final Task newTask2 = new Task(TITLE, "");
    mLocalDataSource.saveTask(newTask2);

    //执行获取数据列表的方法,并在回调函数中进行断言
    mLocalDataSource.getTasks(new TasksDataSource.LoadTasksCallback() {
        @Override
        public void onTasksLoaded(List<Task> tasks) {
            //断言数据非空,且有>=2条的Task数据
            assertNotNull(tasks);
            assertTrue(tasks.size() >= 2);

            boolean newTask1IdFound = false;
            boolean newTask2IdFound = false;
            for (Task task: tasks) {
                if (task.getId().equals(newTask1.getId())) {
                    newTask1IdFound = true;
                }
                if (task.getId().equals(newTask2.getId())) {
                    newTask2IdFound = true;
                }
            }
            //验证查询出的数据包含事先插入的数据
            assertTrue(newTask1IdFound);
            assertTrue(newTask2IdFound);
        }

        @Override
        public void onDataNotAvailable() {
            fail();
        }
    });
}
  1. 最后来看看跟网络请求相关的TasksRemoteDataSource的测试
    Google并没有对这个类本身进行测试,但是对其他层依赖网络请求数据进行测试的场景做了支持。试想一下,通过上面的分析,我们知道View层是真刀真枪的在模拟用户的操作进行测试,如果某个测试case需要发起网络请求,此时我们不知道何时才能返回数据,且由于网络状况等原因可能导致请求失败,种种不确定因素下,是不可能完成一个测试的,解决的办法很简单,就是对网络请求进行Fake,这个类是FakeTasksRemoteDataSource,原理便是当需要用到TasksRemoteDataSource时,不会真正使用该类,而是注入FakeTasksRemoteDataSource,返回事先定义好的数据。

为此,这个项目在项目结构和代码方面提供了很多支撑,体现在:

  • 提供了mock和prod两种Flavors
  • 两种Flavor分别提供了Injection,注入Fake类或真实类
  • 所有与网络请求相关的测试代码存放在androidTestMock下

总结:Model层的测试时而在androidTest写UT,时而在test里写,时而在androidTestMock里,有点精神分裂的感觉。但是,真的好清晰,看起测试的结构来非常舒服。

MVP的单元测试架构总结

通过这个例子,我们已经了解了MVP各层之间的职责以及对应的测试内容,接下来做个总结,首先看下MVP测试架构图:

MVP测试架构图
  1. View层
  • 职责:MVP模式下,View层终于扬眉吐气了,View本身该做的事情都能做了,比如UI布局,数据渲染,点击按钮交互等等
  • 测试方式:以正常小QA的测试思维方法,就可以来定义这一层的测试方式,测试过程中需要真机或模拟器,并做真实的操作。
  • 测试选型:依赖于Android环境,用谷歌强大的Espresso+AndroidJUnitRunner,Espresso用于模拟和验证各种各样的UI操作,代码存放于AndroidTest中。
  1. Presenter层:
  • 职责:这一层是拉皮条的,负责M和V层的对接,所以有较少的处理输入输出的机会,他只用来控制逻辑,去调用相应的Model和View的逻辑。
  • 测试选型:他的职责决定了他很少去断言输入输出,测试逻辑覆盖的路径是否正确即可,因此他与Android环境无关,用Junit+Mockito测试即可,代码存放于test中。
  1. Model层
  • 职责:负责数据的存取,数据可能来自于网络、数据库和内存
  • 数据库增删改查:需测试数据存取的准确性,依赖Android环境进行测试,因此使用AndroidJUnitRunner,代码存放于androidTest中
  • 网络请求:不测试真实的网络请求,但提供了Fake供其他层调用测试。
  • 封装的门面类:决定了数据的来源和去向是来自于本地数据库 or 网络 or 内存,此为真正对其他层暴露的Model类。此类不做数据准确性的验证,只做mock测试,验证覆盖路径。UT选型Junit+Mockito,代码存放于test中。

最后

Android官方MVP架构示例项目在单元测试方面真是良心之作,分析测试用例远比分析MVP本身得到的收获多得多,感谢Google,感谢他粗壮的大腿,抱大腿的感觉真好。

此外,在做架构时,不能忽视在单元测试方面的架构,所以,好的架构是可以支撑代码的可测试性的,Google给我们做了非常棒的最佳实践,接下来就是各自的项目实践,不妨从某个模块开始,步步为营,写好MVP,补齐单元测试用例。

喜欢此文,觉得此文有用,请打赏_

附录

『如何写有价值的测试用例』也是非常重要的话题,在todo-mvp中大大小小的测试用例也有几十个,所以耐心的看看测试代码,可以给我们带来很多思路和指导,由于这部分篇幅较长,且枯燥无味,因此另起一篇文章,有需要的请前往这里

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,401评论 25 707
  • 作者:李旺成 时间:2016年4月3日 “Android MVP 详解(下)”已经发布,欢迎大家提建议。 MVP ...
    diygreen阅读 128,812评论 86 1,321
  • 转载至:http://www.jianshu.com/p/9a6845b26856 “Android MVP 详解...
    SnowDragonYY阅读 10,317评论 5 241
  • 01 看着在我眼里这个世界上最帅最可爱的小脸,听着他均匀的呼吸,久久舍不得移开眼镜。是的,这个萌萌哒水嫩嫩的小天使...
    鲁涵子阅读 832评论 0 0
  • 暗恋一个人是什么感觉?我想大概就是……认识你之前,从未有过结婚的想法,暗恋你以后,觉得你就是最好的。 1 我从未得...
    HsuChihPing一地金阅读 287评论 0 1