解读 Android 官方 MVP 项目单元测试
yangyun123
8年前
<p style="text-align: center;"><img src="https://simg.open-open.com/show/a30a4b986a62ed3daa2c06e99c1a70ab.jpg"></p> <p>Google在3月份推出了一个项目,用来介绍Android MVP架构的各种组合,可以认为是官方在这方面的最佳实践。令人称道的是除了MVP本身之外,这些工程配备了极其完善的单元测试用例,学习价值极高。本文着重针对todo-mvp的单元测试进行解读。</p> <h2>写在前面</h2> <ol> <li>关于MVP<br> 关于MVP的介绍很多,这不是本文的重点,这里列举近期一些比较好的文章。 <ul> <li><a href="http://www.open-open.com/lib/view/open1460535364435.html">Android官方MVP架构示例项目解析</a></li> <li><a href="http://www.open-open.com/lib/view/open1460587161628.html">选择恐惧症的福音!教你认清MVC,MVP和MVVM</a><br> 这篇文章对MVC/MVP/MVVM有很多自己的思考,为什么要有这样架构的演变,以及各自的优缺点的思考,很棒!</li> <li><a href="http://www.open-open.com/lib/view/open1456720373781.html">如何设计MVP中的Presentation层</a><br> 大部分业务场景一个View对应一个Presenter,但是如果一个界面需要多个View/Presenter或者同一个View有多个实现且使用同一个PresenterView时候,如何来设计Presenter,这篇文章是很好的延伸阅读。</li> </ul> </li> <li>关于单元测试<br> 对于单元测试,需要预先了解以下内容 <ul> <li>Android Studio的test和AndroidTest</li> <li><a href="/misc/goto?guid=4959671448561092776" rel="nofollow,noindex">AndroidJUnitRunner</a> :一个兼容Junit4的Andriod单元测试框架</li> <li><a href="/misc/goto?guid=4958864743859781664" rel="nofollow,noindex">Mockito</a> :单元测试利器</li> <li><a href="/misc/goto?guid=4959671448677537343" rel="nofollow,noindex">Espresso</a> :支持UI测试的单元测试框架</li> </ul> </li> <li>关于todo-mvp的功能</li> </ol> <p><img src="https://simg.open-open.com/show/ffc0bacd17de98bb57216071a89b667f.png"></p> <p>功能介绍</p> <p>简而言之,这个工程包含了三个模块:待办事项列表模块,待办事项详情模块,统计模块。</p> <h2>MVP各层的单元测试选型</h2> <p>在该项目中,MVP各层所使用的单元测试框架如下图所示:</p> <p><img src="https://simg.open-open.com/show/4a6e5a6eb8a5f388786c53e4d9f54fdf.png"></p> <p>官方todo-mvp的UT选型</p> <ul> <li>P层:不需要任何Android环境,因此使用Junit测试即可</li> <li>V层:使用Google强大的Espresso进行UI的测试</li> <li>M层:涉及到数据库相关操作,因此需要依赖Android环境,使用AndroidJUnitRunner进行测试</li> </ul> <p>在此处,我们先大致了解一下MVP各层的UT选型,然后通过一个例子,看看各层之间如何配合测试,最后再对各层UT选型的原因进行分析,从而理解整体测试架构。</p> <p>接下来我们以TO-DO List页面(TasksActivity/TaskFragment)中加载任务列表功能为例,此场景的功能界面如下图所示:</p> <p><img src="https://simg.open-open.com/show/c1f2be1adbde8c39a8a2313accc407c6.jpg"></p> <p>待办任务列表</p> <h2>Presenter层的测试</h2> <p>在这个功能里,Presenter只做了一件事情,就是loadTask(),时序图如下所示:</p> <p><img src="https://simg.open-open.com/show/2dedc4c5dce090b607c5decf249b1653.png"></p> <p>loadTask的时序图]</p> <p>从时序图上看,loadTask执行的逻辑是,1.调用View层开启进度条->2.从Model层获取待办任务列表->3.Model层以回调函数的形式返回数据->4.调用View层关闭进度条->5.调用View层显示任务列表。这5个步骤里,每个步骤的逻辑是否准确是View层和Model层该测试的事情,对于Presenter层来讲,他的测试任务是确保这5个步骤如期调用。为了达成此目的,我们会采用 Mockito.verify() 的api进行测试,这个测试类是 TasksPresenterTest ,代码如下:</p> <pre> <code class="language-java">@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); }</code></pre> <p>注:这里涉及到异步回调函数如何测试的问题,使用Mockito的Capture可以解决此问题。具体细节,三言两语说不清,后续考虑专门写篇文章。</p> <p>总结:让Presenter充当个合格的皮条客,去调用其他两层的逻辑,在假设其他两层代码逻辑都是正确的前提下,做一些mock测试,尽可能覆盖所有逻辑路径。</p> <h2>View层的测试</h2> <p>这一层的测试其实很清晰,站在QA的角度,我们想要验证待办任务列表时候,会设计以下的测试用例:</p> <p><img src="https://simg.open-open.com/show/c14d94ebc538191c0e805cd93c994729.png"></p> <p>验证待办任务列表的测试流程</p> <p>通过Espresso可以模拟这些步骤,并进行验证,这个测试类是 TasksScreenTest ,代码如下:</p> <pre> <code class="language-java">@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())); }</code></pre> <p>其中,createTask()的实现如下:</p> <pre> <code class="language-java">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()); }</code></pre> <p>viewAllTasks()的实现如下:</p> <pre> <code class="language-java">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()); }</code></pre> <p>连上设备,跑起UT,会自动启动相应的Activity界面,做相应的操作后进行测试。</p> <p>总结:Espresso好强大,而且这一层的测试站在用户的角度,所有逻辑是黑盒,在功能层面测试输入(用户操作)输出(用户得到的界面反馈),而技术层面,由于界面是所有层的入口,得到输出后,除了测试View层本身的逻辑之外,其实已经粗糙的覆盖了M和P的逻辑了。</p> <h2>Model层的测试</h2> <p>关于Model层的测试,首先要了解下该项目中,model层的设计,类层次如下图所示:</p> <p><img src="https://simg.open-open.com/show/e1d7c2c6d992c11b09bd5ec53a688e65.png"></p> <p>Model层的类图</p> <ul> <li>TasksLocalDataSource:负责本地数据库增删改查操作</li> <li>TasksRemoteDataSource:负责网络请求(该项目中用 handler.postDelayed() 延时来模拟网络请求)</li> <li>TasksRepository:相当于整个Model层的门面,根据逻辑判断决定数据来自于本地数据库或是网络。Presenter层只与它打交道。</li> </ul> <p>根据以上分析,可见对Model层的测试要完整的覆盖这三个类。</p> <ol> <li> <p>我们先看门面TasksRepository的测试,先看看这个类中有关获取待办任务列表的流程图:</p> <img src="https://simg.open-open.com/show/568d419229c9d76ba7b93e7a587f3895.png"> <p>TasksRepository流程图</p> <p>所以对于TasksRepository来讲,测试的内容主要是验证1,2,3的逻辑是否在相应的输入下覆盖到位,对于1,2,3的数据准确性无需关心,由各自DataSource去验证,因此它的测试与Android环境无关,用Junit+Mockito测试。要完整覆盖的话,需要多个测试case,篇幅有限,这里只讲第2种。这个测试类是 TasksRepositoryTest ,代码如下:</p> <pre> <code class="language-java">@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); }</code></pre> <p>其中, setTasksAvailable() 代码如下:</p> <pre> <code class="language-java">private void setTasksAvailable(TasksDataSource dataSource, List<Task> tasks) { //验证第2种情况:使用TasksRemoteDataSource调用getTasks() verify(dataSource).getTasks(mTasksCallbackCaptor.capture()); //执行回调 函数 mTasksCallbackCaptor.getValue().onTasksLoaded(tasks); }</code></pre> </li> <li> <p>接下来是是TasksLocalDataSource的测试。该测试与数据库有关,因此依赖于Android环境,且要验证数据存取的准确性,因此需要做一些断言,使用AndroidJUnitRunner进行测试,这个类是 TasksLocalDataSourceTest ,代码如下:</p> <pre> <code class="language-java">@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(); } }); }</code></pre> </li> <li> <p>最后来看看跟网络请求相关的TasksRemoteDataSource的测试</p> <p>Google并没有对这个类本身进行测试,但是对其他层依赖网络请求数据进行测试的场景做了支持。试想一下,通过上面的分析,我们知道View层是真刀真枪的在模拟用户的操作进行测试,如果某个测试case需要发起网络请求,此时我们不知道何时才能返回数据,且由于网络状况等原因可能导致请求失败,种种不确定因素下,是不可能完成一个测试的,解决的办法很简单,就是对网络请求进行Fake,这个类是 FakeTasksRemoteDataSource ,原理便是 当需要用到TasksRemoteDataSource时,不会真正使用该类,而是注入FakeTasksRemoteDataSource,返回事先定义好的数据。</p> <p>为此,这个项目在项目结构和代码方面提供了很多支撑,体现在:</p> <ul> <li>提供了mock和prod两种Flavors</li> <li>两种Flavor分别提供了Injection,注入Fake类或真实类</li> <li>所有与网络请求相关的测试代码存放在androidTestMock下</li> </ul> </li> </ol> <p>总结:Model层的测试时而在androidTest写UT,时而在test里写,时而在androidTestMock里,有点精神分裂的感觉。但是,真的好清晰,看起测试的结构来非常舒服。</p> <h2>MVP的单元测试架构总结</h2> <p>通过这个例子,我们已经了解了MVP各层之间的职责以及对应的测试内容,接下来做个总结,首先看下MVP测试架构图:</p> <p><img src="https://simg.open-open.com/show/f04c5985436fd6101076e9b294233087.png"></p> <p>MVP测试架构图</p> <ol> <li>View层 <ul> <li>职责:MVP模式下,View层终于扬眉吐气了,View本身该做的事情都能做了,比如UI布局,数据渲染,点击按钮交互等等</li> <li>测试方式:以正常小QA的测试思维方法,就可以来定义这一层的测试方式,测试过程中需要真机或模拟器,并做真实的操作。</li> <li>测试选型:依赖于Android环境,用谷歌强大的Espresso+AndroidJUnitRunner,Espresso用于模拟和验证各种各样的UI操作,代码存放于AndroidTest中。</li> </ul> </li> <li>Presenter层: <ul> <li>职责:这一层是拉皮条的,负责M和V层的对接,所以有较少的处理输入输出的机会,他只用来控制逻辑,去调用相应的Model和View的逻辑。</li> <li>测试选型:他的职责决定了他很少去断言输入输出,测试逻辑覆盖的路径是否正确即可,因此他与Android环境无关,用Junit+Mockito测试即可,代码存放于test中。</li> </ul> </li> <li>Model层 <ul> <li>职责:负责数据的存取,数据可能来自于网络、数据库和内存</li> <li>数据库增删改查:需测试数据存取的准确性,依赖Android环境进行测试,因此使用AndroidJUnitRunner,代码存放于androidTest中</li> <li>网络请求:不测试真实的网络请求,但提供了Fake供其他层调用测试。</li> <li>封装的门面类:决定了数据的来源和去向是来自于本地数据库 or 网络 or 内存,此为真正对其他层暴露的Model类。此类不做数据准确性的验证,只做mock测试,验证覆盖路径。UT选型Junit+Mockito,代码存放于test中。</li> </ul> </li> </ol> <h2>最后</h2> <p>Android官方MVP架构示例项目在单元测试方面真是良心之作,分析测试用例远比分析MVP本身得到的收获多得多,感谢Google,感谢他粗壮的大腿,抱大腿的感觉真好。</p> <p>此外,在做架构时,不能忽视在单元测试方面的架构,所以,好的架构是可以支撑代码的可测试性的,Google给我们做了非常棒的最佳实践,接下来就是各自的项目实践,不妨从某个模块开始,步步为营,写好MVP,补齐单元测试用例。</p> <p>喜欢此文,觉得此文有用,请打赏^_^!</p> <p>附录</p> <p>『如何写有价值的测试用例』也是非常重要的话题,在todo-mvp中大大小小的测试用例也有几十个,所以耐心的看看测试代码,可以给我们带来很多思路和指导,由于这部分篇幅较长,且枯燥无味,因此另起一篇文章,有需要的请前往这里。</p> <p>来自: <a href="/misc/goto?guid=4959671448766211025" rel="nofollow">http://www.jianshu.com/p/cf446be43ae8</a></p>