Google在2016年推出了官方的Android MVP架构Demo,与此Demo相关的分析在网上有很多,但是关于单元测试的分析不是很多,而单元测试是我认为每一个应用开发中不可或缺的一部分,它不仅可以检测我们代码的健壮性,还能约束我们的开发习惯,让我们依循规范进行开发。
Android环境下的单元测试,与传统意义上的单元测试存在差异,传统意义上的单元测试一般不依赖设备环境,仅仅通过开发工具便能完成大部分测试。而在Android中,因为开发工具并不能模拟真实设备环境,因此,导致很多单元测试无法进行,这也是很多开发者头疼的问题。与其百思不得解,不如一起看一下官方是如何进行单元测试的。
Google官方MVP架构Demo
MVP架构已经推出很多年了,现在已经非常普及了,我在这里就不过多介绍,简单的说,它分为以下三个层次:
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
相关的功能实现逻辑,以便更好的理解单元测试的使用。
首先,我们介绍一下加载任务列表此功能的逻辑。当用户点击加载任务列表时,各个模块的功能分别为:
功能逻辑时序图如下:
不同层的逻辑实现和运行环境不同,因此需要采用不同的测试方法进行测试,下面我们就对每一层的测试进行单独介绍。
从时序图上可以看见,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主要功能为:
相关测试结果验证我已经在代码的注解上写上。
总结:我们在测试Presenter层的时候,仅仅关心Preseneter本身的逻辑即可,其他两层的逻辑我们默认全部正确。
从时序图上可以看见,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层的测试,是我认为整个单元测试中最复杂的,因为它可能会依赖于Android环境(比如从数据库中获取数据),因此关于它的测试可能即在Test
目录下,也在AndroidTest
目录下。
在此功能中,Model层获取数据就遇到了这样的问题,如果没有缓存数据并且数据过期,则会去获取网络数据,否则去数据库获取数据。
Model层的相关类:
Handle.post()
模拟);首先看一下,从本地数据库获取数据的单元测试,测试方法在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架构的App,在做单元测试的时候效率要远高于MVC架构的App,最主要的提升在于View层和Control层的测试界定不再那么模糊,逻辑层的操作可以不用依赖Android环境即可进行测试,View层也仅仅只需要显示正确的内容即可,不用关心具体逻辑实现。
关于MVP架构中,各层的测试方法,目前总结如下:
View层
AndroidJUnitRunner
+ Espresso
app/src/androidTest/
Presenter层
JUnit
+ Mockito
app/src/test/
Model层
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
的使用;这些都是很有用但是以前被忽略的学习内容,这些知识会给单元测试带来极大的帮助,希望大家都可以掌握。