Google官方Android MVP架构Demo之单元测试

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框架中单元测试是如何进行的,因此,这里仅仅选用某几个单独的功能点进行分析。

功能界面分析

在开始介绍单元测试之前,我们先介绍主页TasksActivityTasksFragment相关的功能实现逻辑,以便更好的理解单元测试的使用。

首先,我们介绍一下加载任务列表此功能的逻辑。当用户点击加载任务列表时,各个模块的功能分别为:

  • V: TasksFragment:开始加载时显示加载中图标,加载结束后隐藏加载中图标;
  • P: TasksPresenter:开始加载时将数据加载工作交给Model层去处理,Model层加载完数据后将数据交给View层去显示;
  • M: TasksRepository:处理数据并交给Presenter层。

功能逻辑时序图如下:

加载任务列表

功能界面测试

不同层的逻辑实现和运行环境不同,因此需要采用不同的测试方法进行测试,下面我们就对每一层的测试进行单独介绍。

Presenter层

从时序图上可以看见,P层TasksPresenter不关心V层具体的界面交互显示,也不关心M层是如何处理数据的,它仅仅关心是否正确将信息传达给了V层和M层。
P层并不依赖于Android环境,并且我们需要将M层的数据传递给V层,我们需要Mock一些数据数据。因此我们用JunitMockito测试即可。

这个功能测试写在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<List> showTasksArgumentCaptor = ArgumentCaptor.forClass(List.class);
    verify(mTasksView).showTasks(showTasksArgumentCaptor.capture());
    assertTrue(showTasksArgumentCaptor.getValue().size() == 3);
}

在此功能中,Presenter主要功能为:

  1. 通知View层显示和隐藏加载中图标;
  2. 通知M层去获取数据;
  3. 将M层的数据传递给V层显示。

相关测试结果验证我已经在代码的注解上写上。

总结:我们在测试Presenter层的时候,仅仅关心Preseneter本身的逻辑即可,其他两层的逻辑我们默认全部正确。

View层

从时序图上可以看见,View层的功能仅仅是显示和隐藏了加载中图标,然后将加载后的数据显示在界面上。由于View层的测试需要在真机环境下模拟,因此,我们使用AndroidJunitRunnerEspresso框架测试。

测试代码在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层获取数据实现逻辑:

Model获取数据

  1. 首先看一下,从本地数据库获取数据的单元测试,测试方法在TasksRepositoryTest类中:

     @Test
     public void getTasks_requestsAllTasksFromLocalDataSource() {
         // 直接调用getTasks即可调用本地数据库获取数据
         mTasksRepository.getTasks(mLoadTasksCallback);
     
         // 判断本地数据库是否调用了getTasks
         verify(mTasksLocalDataSource).getTasks(any(TasksDataSource.LoadTasksCallback.class));
     }
    

可以看见,Demo中关于Model的单元测试也并没有真正去做数据操作,而是判断它的Model是否执行了获取数据操作这个行为。可能Google认为,Model的单元测试也仅仅关心是否真正的调用了获取数据的方法,而不关心具体实现逻辑

  1. 从网络中获取数据,测试方法也在TasksRepositoryTest类中:

     @Test
     public void getTasksWithDirtyCache_tasksAreRetrievedFromRemote() {
         // 将缓存数据清空
         mTasksRepository.refreshTasks();
         mTasksRepository.getTasks(mLoadTasksCallback);
    
         // 验证数据库是否去执行了获取任务,并且把TASKS当成是获取到的数据
         setTasksAvailable(mTasksRemoteDataSource, TASKS);
    
         // 验证本地数据库是否没有执行获取数据的操作,验证回调后的结果是否和我们得到的结果相同
         verify(mTasksLocalDataSource, never()).getTasks(mLoadTasksCallback);
         verify(mLoadTasksCallback).onTasksLoaded(TASKS);
     }
    

可以看见,这个测试用例也是仅仅关心是否执行了正确的获取数据的操作,而不关心具体的获取数据是否正确。

  1. 要判断数据是否真正正确的存储进了数据库,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的使用;

这些都是很有用但是以前被忽略的学习内容,这些知识会给单元测试带来极大的帮助,希望大家都可以掌握。

推荐阅读更多精彩内容