Google官方Android MVP架构Demo之单元测试
Google在2016年推出了官方的Android MVP架构Demo,与此Demo相关的分析在网上有很多,但是关于单元测试的分析不是很多,而单元测试是我认为每一个应用开发中不可或缺的一部分,它不仅可以检测我们代码的健壮性,还能约束我们的开发习惯,让我们依循规范进行开发。
Android环境下的单元测试,与传统意义上的单元测试存在差异,传统意义上的单元测试一般不依赖设备环境,仅仅通过开发工具便能完成大部分测试。而在Android中,因为开发工具并不能模拟真实设备环境,因此,导致很多单元测试无法进行,这也是很多开发者头疼的问题。与其百思不得解,不如一起看一下官方是如何进行单元测试的。
Google官方MVP架构Demo
关于MVP架构
MVP架构已经推出很多年了,现在已经非常普及了,我在这里就不过多介绍,简单的说,它分为以下三个层次:
- Model:数据模型层,主要用来数据处理,获取数据;
- View:显示界面元素,和用户进行界面交互;
- Presenter: 是Model和View沟通的桥梁,不关心具体的View显示和Model的数据处理。View层中所有的逻辑操作都通过Presenter去通知Model层去完成,Model中获取的数据通过Presenter层去通知View层显示。
MVP架构最大的好处,就是把传统MVC架构中View层和Control层的复杂关系完全解耦,View层只关心界面显示相关的工作即可,Model层仅获取数据,处理逻辑运算即可,各司其职,而不用关心其他工作。
关于单元测试工具
MVP Demo中所使用的单元测试工具有以下几种:
1. Junit
Android自带的单元测试框架,主要用来测试不依赖Android环境,主要是用来测试逻辑操作的Presenter层和Model层。
2. Mockito
一个用来模拟数据的开源框。配合Junit框架测试Presenter的逻辑操作,用来模拟Model层的数据,目的是不让Model层的因缺乏真实数据阻塞测试。
3. AndroidJunitRunner
此框架也是Android自带的测试框架,包含了Android相关的环境。此框架配合Espresso用来测试View层的显示是否正确,需要在真机上运行。
4. Espresso
一个简洁高效的UI测试框架,可以用来很方便的模拟用户的真实操作,通用也需要在真机上运行。
以上基本上就是Demo中使用的主要测试框架,若不了解的,请先学习一下相关基础知识。
功能界面分析与测试
Demo中包含很多功能点,由于仅仅只是分析在MVP框架中单元测试是如何进行的,因此,这里仅仅选用某几个单独的功能点进行分析。
功能界面分析
在开始介绍单元测试之前,我们先介绍主页TasksActivity
和TasksFragment
相关的功能实现逻辑,以便更好的理解单元测试的使用。
首先,我们介绍一下加载任务列表此功能的逻辑。当用户点击加载任务列表时,各个模块的功能分别为:
- V: TasksFragment:开始加载时显示加载中图标,加载结束后隐藏加载中图标;
- P: TasksPresenter:开始加载时将数据加载工作交给Model层去处理,Model层加载完数据后将数据交给View层去显示;
- M: TasksRepository:处理数据并交给Presenter层。
功能逻辑时序图如下:
功能界面测试
不同层的逻辑实现和运行环境不同,因此需要采用不同的测试方法进行测试,下面我们就对每一层的测试进行单独介绍。
Presenter层
从时序图上可以看见,P层TasksPresenter
不关心V层具体的界面交互显示,也不关心M层是如何处理数据的,它仅仅关心是否正确将信息传达给了V层和M层。
P层并不依赖于Android环境,并且我们需要将M层的数据传递给V层,我们需要Mock一些数据数据。因此我们用Junit
和Mockito
测试即可。
这个功能测试写在TasksPresenterTest
类中,具体实现为:
@Test
public void loadAllTasksFromRepositoryAndLoadIntoView() {
// 设置获取数据模式为获取全部任务
mTasksPresenter.setFiltering(TasksFilterType.ALL_TASKS);
mTasksPresenter.loadTasks(true);
// mTasksRepository是mock出来的,因此它的回调数据用我们预先准备的TASKS即可
verify(mTasksRepository).getTasks(mLoadTasksCallbackCaptor.capture());
mLoadTasksCallbackCaptor.getValue().onTasksLoaded(TASKS);
// 验证View层的执行顺序是否是先执行显示加载中图标,后执行隐藏加载中图标
InOrder inOrder = inOrder(mTasksView);
inOrder.verify(mTasksView).setLoadingIndicator(true);
inOrder.verify(mTasksView).setLoadingIndicator(false);
// 验证View层中,showTasks的数据是否是Presenter层传递过去的
ArgumentCaptor showTasksArgumentCaptor = ArgumentCaptor.forClass(List.class);
verify(mTasksView).showTasks(showTasksArgumentCaptor.capture());
assertTrue(showTasksArgumentCaptor.getValue().size() == 3);
}
在此功能中,Presenter主要功能为:
- 通知View层显示和隐藏加载中图标;
- 通知M层去获取数据;
- 将M层的数据传递给V层显示。
相关测试结果验证我已经在代码的注解上写上。
总结:我们在测试Presenter层的时候,仅仅关心Preseneter本身的逻辑即可,其他两层的逻辑我们默认全部正确。
View层
从时序图上可以看见,View层的功能仅仅是显示和隐藏了加载中图标,然后将加载后的数据显示在界面上。由于View层的测试需要在真机环境下模拟,因此,我们使用AndroidJunitRunner
和Espresso
框架测试。
测试代码在TasksScreenTest
类中,具体实现为:
@Test
public void showAllTasks() {
// 首先先创建两个任务(测试条件)
createTask(TITLE1, DESCRIPTION);
createTask(TITLE2, DESCRIPTION);
// 加载所有任务
viewAllTasks();
// 验证我们的任务是否正确显示了
onView(withItemText(TITLE1)).check(matches(isDisplayed()));
onView(withItemText(TITLE2)).check(matches(isDisplayed()));
}
其中,createTask()
的代码实现为:
private void createTask(String title, String description) {
// 点击屏幕上添加任务的按钮
onView(withId(R.id.fab_add_task)).perform(click());
// 给任务名称EditText填充文本
onView(withId(R.id.add_task_title)).perform(typeText(title),
closeSoftKeyboard());
// 给任务详情EditText填充文本
onView(withId(R.id.add_task_description)).perform(typeText(description),
closeSoftKeyboard());
// 保存任务
onView(withId(R.id.fab_edit_task_done)).perform(click());
}
viewAllTasks()
代码实现为:
private void viewAllTasks() {
// 点击Toolbar上图标弹出PopWindow
onView(withId(R.id.menu_filter)).perform(click());
// 点击PopWindow上all按钮加载所有任务
onView(withText(R.string.nav_all)).perform(click());
}
View层的测试相对简单很多,使用Espresso
功能简单高效就能完成测试。
总结:View层的单元测试仅仅测试UI即可,不需要关心具体逻辑实现。
Model层
Model层的测试,是我认为整个单元测试中最复杂的,因为它可能会依赖于Android环境(比如从数据库中获取数据),因此关于它的测试可能即在Test
目录下,也在AndroidTest
目录下。
在此功能中,Model层获取数据就遇到了这样的问题,如果没有缓存数据并且数据过期,则会去获取网络数据,否则去数据库获取数据。
Model层的相关类:
- TasksRepository:Model层的门面,Presenter层只与它打交道;
- FakeTasksRemoteDataSource:获取网络数据的Model(项目中采用
Handle.post()
模拟); - TasksLocalDataSource:获取本地数据的Model。
Model层获取数据实现逻辑:
-
首先看一下,从本地数据库获取数据的单元测试,测试方法在
TasksRepositoryTest
类中:@Test public void getTasks_requestsAllTasksFromLocalDataSource() { // 直接调用getTasks即可调用本地数据库获取数据 mTasksRepository.getTasks(mLoadTasksCallback); // 判断本地数据库是否调用了getTasks verify(mTasksLocalDataSource).getTasks(any(TasksDataSource.LoadTasksCallback.class)); }
可以看见,Demo中关于Model的单元测试也并没有真正去做数据操作,而是判断它的Model是否执行了获取数据操作这个行为。可能Google认为,Model的单元测试也仅仅关心是否真正的调用了获取数据的方法,而不关心具体实现逻辑。
-
从网络中获取数据,测试方法也在
TasksRepositoryTest
类中:@Test public void getTasksWithDirtyCache_tasksAreRetrievedFromRemote() { // 将缓存数据清空 mTasksRepository.refreshTasks(); mTasksRepository.getTasks(mLoadTasksCallback); // 验证数据库是否去执行了获取任务,并且把TASKS当成是获取到的数据 setTasksAvailable(mTasksRemoteDataSource, TASKS); // 验证本地数据库是否没有执行获取数据的操作,验证回调后的结果是否和我们得到的结果相同 verify(mTasksLocalDataSource, never()).getTasks(mLoadTasksCallback); verify(mLoadTasksCallback).onTasksLoaded(TASKS); }
可以看见,这个测试用例也是仅仅关心是否执行了正确的获取数据的操作,而不关心具体的获取数据是否正确。
-
要判断数据是否真正正确的存储进了数据库,Demo中也给出了测试,这次测试在
TasksLocalDataSourceTest
中:@Test public void saveTask_retrievesTask() { // Given a new task final Task newTask = new Task(TITLE, ""); // When saved into the persistent repository mLocalDataSource.saveTask(newTask); // Then the task can be retrieved from the persistent repository mLocalDataSource.getTask(newTask.getId(), new TasksDataSource.GetTaskCallback() { @Override public void onTaskLoaded(Task task) { assertThat(task, is(newTask)); } @Override public void onDataNotAvailable() { fail("Callback error"); } }); }
可以看见,当真正需要执行存储进数据库这个行为的时候,是要依赖数据库的,因此也就要依赖Android环境,可以看见,数据库的初始化确实是依赖了android环境:
// using an in-memory database for testing, since it doesn't survive killing the process
private ToDoDatabase mDatabase = Room.inMemoryDatabaseBuilder(getApplicationContext(),
ToDoDatabase.class)
.build();
总结:关于Model的测试分为两部分——操作处理数据的逻辑测试和真正处理数据的测试,当我们仅测试逻辑的时候,通过JUnit
+Mockito
测试即可,当我们测需要依赖Android环境的数据的时候,可以采用AndroidJUnitRunner
进行测试。
MVP架构单元测试总结
采用MVP架构的App,在做单元测试的时候效率要远高于MVC架构的App,最主要的提升在于View层和Control层的测试界定不再那么模糊,逻辑层的操作可以不用依赖Android环境即可进行测试,View层也仅仅只需要显示正确的内容即可,不用关心具体逻辑实现。
关于MVP架构中,各层的测试方法,目前总结如下:
-
View层
- 测试环境:采用真机测试
- 测试框架:
AndroidJUnitRunner
+Espresso
- 文件路径:
app/src/androidTest/
-
Presenter层
- 测试环境:Java环境即可,数据采用Mock形式
- 测试框架:
JUnit
+Mockito
- 文件路径:
app/src/test/
-
Model层
- 测试环境:Android环境 + Java环境
- 测试框架:
JUnit
+Mockito
+AndroidJUnitRunner
+Espresso
- 文件路径:
app/src/test/
+app/src/androidTest/
写在最后
优化
Google官方的MVP架构Demo中,各个层级分层很清晰,单元测试起来也很流畅,给我们的单元测试方案提供了极为有效的参考。
但是,我觉得唯一美中不足的地方可能是在Model层的测试可能过于分散了,放在androidTest
目录下的测试用例,都是依赖android环境的,一般要测试都需要跑在真机上,若测试用例过多,则极为耗时影响效率。
这里,我考虑可以将Model层中依赖Android环境的测试用例,放在test
目录下,采用Robolectric
框架进行测试(Robolectric
框架是在本地运行的可以模拟android环境的框架)。
待学习内容
这次的单元测试学习,让我又发现了自己很多没有掌握的知识点,包括但不限于以下一些:
-
Mockito
框架的ArgumentCaptor
的使用; -
Espresso
框架中的IdlingResource
的使用;
这些都是很有用但是以前被忽略的学习内容,这些知识会给单元测试带来极大的帮助,希望大家都可以掌握。