Android 测试 (四)-- 实战分析

2016/11/13 posted in  Android

本文通过分析一个完整的项目,来学习如果对一个完整的工程比较好的进行编写测试程序

项目地址 ,该项目采用的是 mvp 架构,(关于 mvp 的介绍可以看这里 ),mvp 对于测试的好处就是讲 view 逻辑和业务代码分离,我们可以很方便的对业务代码进行 local unit test 的测试。

项目结构

可以看到项目的整体结构比较清晰

  1. 项目代码
  2. Android 测试(Instrumentation test)
  3. Android 测试 相关 mock
  4. local unit test
  5. local unit test 相关 mock

local unit test mock

项目的 model 层采用的是 Repository 模式,在 mock 文件夹中,mock 测试中需要使用的数据源,并提供了注入的接口,这里采用的是手动注入,在后续依赖比较复杂的情况下可以使用 dagger 注入,减少大量冗余代码。

下面看下 mock 的数据源的实现

public class FakeTasksRemoteDataSource implements TasksDataSource {

    private static FakeTasksRemoteDataSource INSTANCE;

    private static final Map<String, Task> TASKS_SERVICE_DATA = new LinkedHashMap<>();

    // Prevent direct instantiation.
    private FakeTasksRemoteDataSource() {}

    public static FakeTasksRemoteDataSource getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new FakeTasksRemoteDataSource();
        }
        return INSTANCE;
    }

    @Override
    public void getTasks(@NonNull LoadTasksCallback callback) {
        callback.onTasksLoaded(Lists.newArrayList(TASKS_SERVICE_DATA.values()));
    }

    @Override
    public void getTask(@NonNull String taskId, @NonNull GetTaskCallback callback) {
        Task task = TASKS_SERVICE_DATA.get(taskId);
        callback.onTaskLoaded(task);
    }

    @Override
    public void saveTask(@NonNull Task task) {
        TASKS_SERVICE_DATA.put(task.getId(), task);
    }

    @Override
    public void completeTask(@NonNull Task task) {
        Task completedTask = new Task(task.getTitle(), task.getDescription(), task.getId(), true);
        TASKS_SERVICE_DATA.put(task.getId(), completedTask);
    }

    @Override
    public void completeTask(@NonNull String taskId) {
        // Not required for the remote data source.
    }

    @Override
    public void activateTask(@NonNull Task task) {
        Task activeTask = new Task(task.getTitle(), task.getDescription(), task.getId());
        TASKS_SERVICE_DATA.put(task.getId(), activeTask);
    }

    @Override
    public void activateTask(@NonNull String taskId) {
        // Not required for the remote data source.
    }

    @Override
    public void clearCompletedTasks() {
        Iterator<Map.Entry<String, Task>> it = TASKS_SERVICE_DATA.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<String, Task> entry = it.next();
            if (entry.getValue().isCompleted()) {
                it.remove();
            }
        }
    }

    public void refreshTasks() {
        // Not required because the {@link TasksRepository} handles the logic of refreshing the
        // tasks from all the available data sources.
    }

    @Override
    public void deleteTask(@NonNull String taskId) {
        TASKS_SERVICE_DATA.remove(taskId);
    }

    @Override
    public void deleteAllTasks() {
        TASKS_SERVICE_DATA.clear();
    }

    @VisibleForTesting
    public void addTasks(Task... tasks) {
        for (Task task : tasks) {
            TASKS_SERVICE_DATA.put(task.getId(), task);
        }
    }
}

mock 的数据线 实现了 TasksDataSource接口,将 mock 的数据都存储在了类的Map<String, Task>

local unit test

model 层测试

使用了 mockito 来 mock 数据,

再看测试代码之前 先回顾下TasksRepository里面逻辑,方法比较多,挑几个来分析一下




/**
     * Gets tasks from local data source (sqlite) unless the table is new or empty. In that case it
     * uses the network data source. This is done to simplify the sample.
     * <p>
     * Note: {@link LoadTasksCallback#onDataNotAvailable()} is fired if both data sources fail to
     * get the data.
     */
    @Override
    public void getTask(@NonNull final String taskId, @NonNull final GetTaskCallback callback) {
        checkNotNull(taskId);
        checkNotNull(callback);

        Task cachedTask = getTaskWithId(taskId);

        // Respond immediately with cache if available
         //如果缓存中有数据的话从缓存中获取数据
        if (cachedTask != null) {
            callback.onTaskLoaded(cachedTask);
            return;
        }

        // Load from server/persisted if needed.
        //缓存没有数据则从服务端获取
        // Is the task in the local data source? If not, query the network.
        mTasksLocalDataSource.getTask(taskId, new GetTaskCallback() {
            @Override
            public void onTaskLoaded(Task task) {
                callback.onTaskLoaded(task);
            }

            @Override
            public void onDataNotAvailable() {
            //本地没有数据再从服务端获取
                mTasksRemoteDataSource.getTask(taskId, new GetTaskCallback() {
                    @Override
                    public void onTaskLoaded(Task task) {
                        callback.onTaskLoaded(task);
                    }

                    @Override
                    public void onDataNotAvailable() {
                        callback.onDataNotAvailable();
                    }
                });
            }
        });
    }
    
 
    @Override
    public void saveTask(@NonNull Task task) {
        checkNotNull(task);
        //将数据 储存的本地 和服务端
        mTasksRemoteDataSource.saveTask(task);
        mTasksLocalDataSource.saveTask(task);

        // Do in memory cache update to keep the app UI up to date
        //将数据存储在缓存中
        if (mCachedTasks == null) {
            mCachedTasks = new LinkedHashMap<>();
        }
        mCachedTasks.put(task.getId(), task);
    }

由此可以看出,数据的存储分为三层,缓存,本地存储,服务端存储。优先级递减

接着看 modle 的测试代码。

/**
 * Unit tests for the implementation of the in-memory repository with cache.
 */
public class TasksRepositoryTest {

    private final static String TASK_TITLE = "title";

    private final static String TASK_TITLE2 = "title2";

    private final static String TASK_TITLE3 = "title3";

    private static List<Task> TASKS = Lists.newArrayList(new Task("Title1", "Description1"),
            new Task("Title2", "Description2"));

    private TasksRepository mTasksRepository;

    @Mock
    private TasksDataSource mTasksRemoteDataSource;

    @Mock
    private TasksDataSource mTasksLocalDataSource;

    @Mock
    private Context mContext;

    @Mock
    private TasksDataSource.GetTaskCallback mGetTaskCallback;

    @Mock
    private TasksDataSource.LoadTasksCallback mLoadTasksCallback;

    /**
     * {@link ArgumentCaptor} is a powerful Mockito API to capture argument values and use them to
     * perform further actions or assertions on them.
     */
    @Captor
    private ArgumentCaptor<TasksDataSource.LoadTasksCallback> mTasksCallbackCaptor;

    /**
     * {@link ArgumentCaptor} is a powerful Mockito API to capture argument values and use them to
     * perform further actions or assertions on them.
     */
    @Captor
    private ArgumentCaptor<TasksDataSource.GetTaskCallback> mTaskCallbackCaptor;

    @Before
    public void setupTasksRepository() {
        // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
        // inject the mocks in the test the initMocks method needs to be called.
        MockitoAnnotations.initMocks(this);
      //首先在 @Before 中创建出Repository,可以看到在TasksRepository.getInstance 中的两个参数也都是 mock 出的
        // Get a reference to the class under test
        mTasksRepository = TasksRepository.getInstance(
                mTasksRemoteDataSource, mTasksLocalDataSource);
    }

    @After
    public void destroyRepositoryInstance() {
    //销毁创建出的实例
        TasksRepository.destroyInstance();
    }

    @Test
    public void getTasks_repositoryCachesAfterFirstApiCall() {
        // Given a setup Captor to capture callbacks
        // When two calls are issued to the tasks repository
        // 使用 mLoadTasksCallback 来记录 callback
        twoTasksLoadCallsToRepository(mLoadTasksCallback);
    
        // Then tasks were only requested once from Service API
        //第一次调用 gettask 时本地是没有缓存的,所以 verify调用了mTasksRemoteDataSource 的 getTasks,并且其中的参数是 TasksDataSource.LoadTasksCallback.class类型,在第二次调用中,本地已经数据已经有了,所以mTasksRemoteDataSource 的方法只调用了一次
        verify(mTasksRemoteDataSource).getTasks(any(TasksDataSource.LoadTasksCallback.class));
    }

    @Test
    public void getTasks_requestsAllTasksFromLocalDataSource() {
        // When tasks are requested from the tasks repository
        mTasksRepository.getTasks(mLoadTasksCallback);

        // Then tasks are loaded from the local data source
        verify(mTasksLocalDataSource).getTasks(any(TasksDataSource.LoadTasksCallback.class));
    }

    @Test
    public void saveTask_savesTaskToServiceAPI() {
        // Given a stub task with title and description
        Task newTask = new Task(TASK_TITLE, "Some Task Description");

        // When a task is saved to the tasks repository
        mTasksRepository.saveTask(newTask);

        // Then the service API and persistent repository are called and the cache is updated
        //save 时 ,本地和服务端都调用了saveTask,并且本地的缓存 list 会增加
        verify(mTasksRemoteDataSource).saveTask(newTask);
        verify(mTasksLocalDataSource).saveTask(newTask);
        assertThat(mTasksRepository.mCachedTasks.size(), is(1));
    }

     @Test
    public void getTask_requestsSingleTaskFromLocalDataSource() {
        // When a task is requested from the tasks repository
        mTasksRepository.getTask(TASK_TITLE, mGetTaskCallback);

        // Then the task is loaded from the database
        verify(mTasksLocalDataSource).getTask(eq(TASK_TITLE), any(
                TasksDataSource.GetTaskCallback.class));
    }

     @Test
    public void getTasksWithDirtyCache_tasksAreRetrievedFromRemote() {
        // When calling getTasks in the repository with dirty cache
        mTasksRepository.refreshTasks();
        mTasksRepository.getTasks(mLoadTasksCallback);

        // And the remote data source has data available
        setTasksAvailable(mTasksRemoteDataSource, TASKS);

        // Verify the tasks from the remote data source are returned, not the local
        verify(mTasksLocalDataSource, never()).getTasks(mLoadTasksCallback);
        verify(mLoadTasksCallback).onTasksLoaded(TASKS);
    }

    @Test
    public void getTasksWithLocalDataSourceUnavailable_tasksAreRetrievedFromRemote() {
        // When calling getTasks in the repository
        mTasksRepository.getTasks(mLoadTasksCallback);

        // And the local data source has no data available
        setTasksNotAvailable(mTasksLocalDataSource);

        // And the remote data source has data available
        setTasksAvailable(mTasksRemoteDataSource, TASKS);

        // Verify the tasks from the local data source are returned
        verify(mLoadTasksCallback).onTasksLoaded(TASKS);
    }

    @Test
    public void getTasksWithBothDataSourcesUnavailable_firesOnDataUnavailable() {
        // When calling getTasks in the repository
        mTasksRepository.getTasks(mLoadTasksCallback);

        // And the local data source has no data available
        setTasksNotAvailable(mTasksLocalDataSource);

        // And the remote data source has no data available
        setTasksNotAvailable(mTasksRemoteDataSource);

        // Verify no data is returned
        verify(mLoadTasksCallback).onDataNotAvailable();
    }

   
    /**
     * Convenience method that issues two calls to the tasks repository
     */
    private void twoTasksLoadCallsToRepository(TasksDataSource.LoadTasksCallback callback) {
        // When tasks are requested from repository
        mTasksRepository.getTasks(callback); // First call to API

        // Use the Mockito Captor to capture the callback
        // 由于是第一次调用 mTasksRepository.getTasks ,所以会先调用 mTasksLocalDataSource,并且用mTasksCallbackCaptor.capture 记录 callback
        verify(mTasksLocalDataSource).getTasks(mTasksCallbackCaptor.capture());

        // Local data source doesn't have data yet
        //由于第一次调用 get,所以本地数据是空的,会调用onDataNotAvailable 的回调
        mTasksCallbackCaptor.getValue().onDataNotAvailable();


        // Verify the remote data source is queried
        //本地为空,onDataNotAvailable回调中会调用,mTasksRemoteDataSource.getTasks
        verify(mTasksRemoteDataSource).getTasks(mTasksCallbackCaptor.capture());

        // Trigger callback so tasks are cached
        //加载完 task 的回调
        mTasksCallbackCaptor.getValue().onTasksLoaded(TASKS);

    //第二次调用
        mTasksRepository.getTasks(callback); // Second call to API
    }

    private void setTasksNotAvailable(TasksDataSource dataSource) {
        verify(dataSource).getTasks(mTasksCallbackCaptor.capture());
        mTasksCallbackCaptor.getValue().onDataNotAvailable();
    }

    private void setTasksAvailable(TasksDataSource dataSource, List<Task> tasks) {
        verify(dataSource).getTasks(mTasksCallbackCaptor.capture());
        mTasksCallbackCaptor.getValue().onTasksLoaded(tasks);
    }

    private void setTaskNotAvailable(TasksDataSource dataSource, String taskId) {
        verify(dataSource).getTask(eq(taskId), mTaskCallbackCaptor.capture());
        mTaskCallbackCaptor.getValue().onDataNotAvailable();
    }

    private void setTaskAvailable(TasksDataSource dataSource, Task task) {
        verify(dataSource).getTask(eq(task.getId()), mTaskCallbackCaptor.capture());
        mTaskCallbackCaptor.getValue().onTaskLoaded(task);
    }
 }

P层测试

选择AddEditTaskPresenter的测试来分析,这事增加 task 的业务

/**
 * Unit tests for the implementation of {@link AddEditTaskPresenter}.
 */
public class AddEditTaskPresenterTest {

    @Mock
    private TasksRepository mTasksRepository;

    @Mock
    private AddEditTaskContract.View mAddEditTaskView;

    /**
     * {@link ArgumentCaptor} is a powerful Mockito API to capture argument values and use them to
     * perform further actions or assertions on them.
     */
    @Captor
    private ArgumentCaptor<TasksDataSource.GetTaskCallback> mGetTaskCallbackCaptor;

    private AddEditTaskPresenter mAddEditTaskPresenter;

    @Before
    public void setupMocksAndView() {
        // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
        // inject the mocks in the test the initMocks method needs to be called.
        MockitoAnnotations.initMocks(this);

        // The presenter wont't update the view unless it's active.
        when(mAddEditTaskView.isActive()).thenReturn(true);
    }

    @Test
    public void saveNewTaskToRepository_showsSuccessMessageUi() {
        // Get a reference to the class under test
        //手动构造出AddEditTaskPresente r
        mAddEditTaskPresenter = new AddEditTaskPresenter("1", mTasksRepository, mAddEditTaskView);

        // When the presenter is asked to save a task
        mAddEditTaskPresenter.saveTask("New Task Title", "Some Task Description");

        // Then a task is saved in the repository and the view updated
        //确认调用了 mTasksRepository 的 saveTask 方法
        verify(mTasksRepository).saveTask(any(Task.class)); // saved to the model
        //确认 UI 对应的结构得到调用,具体效果不在这里的单元测试验证,在 UI 验证
        verify(mAddEditTaskView).showTasksList(); // shown in the UI
    }

    @Test
    public void saveTask_emptyTaskShowsErrorUi() {
        // Get a reference to the class under test
        mAddEditTaskPresenter = new AddEditTaskPresenter(null, mTasksRepository, mAddEditTaskView);

        // When the presenter is asked to save an empty task
        mAddEditTaskPresenter.saveTask("", "");

        // Then an empty not error is shown in the UI
        verify(mAddEditTaskView).showEmptyTaskError();
    }

    @Test
    public void saveExistingTaskToRepository_showsSuccessMessageUi() {
        // Get a reference to the class under test
        mAddEditTaskPresenter = new AddEditTaskPresenter("1", mTasksRepository, mAddEditTaskView);

        // When the presenter is asked to save an existing task
        mAddEditTaskPresenter.saveTask("New Task Title", "Some Task Description");

        // Then a task is saved in the repository and the view updated
        verify(mTasksRepository).saveTask(any(Task.class)); // saved to the model
        verify(mAddEditTaskView).showTasksList(); // shown in the UI
    }

    @Test
    public void populateTask_callsRepoAndUpdatesView() {
        Task testTask = new Task("TITLE", "DESCRIPTION");
        // Get a reference to the class under test
        mAddEditTaskPresenter = new AddEditTaskPresenter(testTask.getId(),
                mTasksRepository, mAddEditTaskView);

        // When the presenter is asked to populate an existing task
        mAddEditTaskPresenter.populateTask();

        // Then the task repository is queried and the view updated
        verify(mTasksRepository).getTask(eq(testTask.getId()), mGetTaskCallbackCaptor.capture());

        // Simulate callback
        mGetTaskCallbackCaptor.getValue().onTaskLoaded(testTask);

        verify(mAddEditTaskView).setTitle(testTask.getTitle());
        verify(mAddEditTaskView).setDescription(testTask.getDescription());
    }
}


android mock test

/**
 * Tests for the statistics screen.
 */
@RunWith(AndroidJUnit4.class)
@LargeTest
public class StatisticsScreenTest {

    /**
     * {@link ActivityTestRule} is a JUnit {@link Rule @Rule} to launch your activity under test.
     *
     * <p>
     * Rules are interceptors which are executed for each test method and are important building
     * blocks of Junit tests.
     */
    @Rule
    public ActivityTestRule<StatisticsActivity> mStatisticsActivityTestRule =
            new ActivityTestRule<>(StatisticsActivity.class, true, false);

    /**
     * Setup your test fixture with a fake task id. The {@link TaskDetailActivity} is started with
     * a particular task id, which is then loaded from the service API.
     *
     * <p>
     * Note that this test runs hermetically and is fully isolated using a fake implementation of
     * the service API. This is a great way to make your tests more reliable and faster at the same
     * time, since they are isolated from any outside dependencies.
     */
    @Before
    public void intentWithStubbedTaskId() {
        // Given some tasks
        TasksRepository.destroyInstance();
        //做了两个 mock 数据,一个已完成 一个未完成
        FakeTasksRemoteDataSource.getInstance().addTasks(new Task("Title1", "", false));
        FakeTasksRemoteDataSource.getInstance().addTasks(new Task("Title2", "", true));
        
        // Lazily start the Activity from the ActivityTestRule
        Intent startIntent = new Intent();
        mStatisticsActivityTestRule.launchActivity(startIntent);
    }

    @Test
    public void Tasks_ShowsNonEmptyMessage() throws Exception {
        // Check that the active and completed tasks text is displayed
        //检测这两种 view 是不是都显示了
        String expectedActiveTaskText = InstrumentationRegistry.getTargetContext()
                .getString(R.string.statistics_active_tasks);
        onView(withText(containsString(expectedActiveTaskText))).check(matches(isDisplayed()));
        String expectedCompletedTaskText = InstrumentationRegistry.getTargetContext()
                .getString(R.string.statistics_completed_tasks);
        onView(withText(containsString(expectedCompletedTaskText))).check(matches(isDisplayed()));
    }
}

Android test

模拟人工点击,并检测响应的页面显示,选择增加 task 的例子来分析

/**
 * Tests for the tasks screen, the main screen which contains a list of all tasks.
 */
@RunWith(AndroidJUnit4.class)
@LargeTest
public class TasksScreenTest {

    private final static String TITLE1 = "TITLE1";

    private final static String DESCRIPTION = "DESCR";

    private final static String TITLE2 = "TITLE2";

    /**
     * {@link ActivityTestRule} is a JUnit {@link Rule @Rule} to launch your activity under test.
     * <p>
     * Rules are interceptors which are executed for each test method and are important building
     * blocks of Junit tests.
     */
    @Rule
    public ActivityTestRule<TasksActivity> mTasksActivityTestRule =
            new ActivityTestRule<TasksActivity>(TasksActivity.class) {

                /**
                 * To avoid a long list of tasks and the need to scroll through the list to find a
                 * task, we call {@link TasksDataSource#deleteAllTasks()} before each test.
                 */
                @Override
                protected void beforeActivityLaunched() {
                    super.beforeActivityLaunched();
                    // Doing this in @Before generates a race condition.
                    //先删除所有 task
                    Injection.provideTasksRepository(InstrumentationRegistry.getTargetContext())
                        .deleteAllTasks();
                }
            };

}

//检测增加一个 task 到 list
    @Test
    public void addTaskToTasksList() throws Exception {
        createTask(TITLE1, DESCRIPTION);

        // Verify task is displayed on screen
        onView(withItemText(TITLE1)).check(matches(isDisplayed()));
    }
    
    //增加数据的操作都是点击并模拟输入的,不是 mock 的数据
        private void createTask(String title, String description) {
        // Click on the add task button
        onView(withId(R.id.fab_add_task)).perform(click());

        // Add task title and description
        onView(withId(R.id.add_task_title)).perform(typeText(title),
                closeSoftKeyboard()); // Type new task title
        onView(withId(R.id.add_task_description)).perform(typeText(description),
                closeSoftKeyboard()); // Type new task description and close the keyboard

        // Save the task
        onView(withId(R.id.fab_edit_task_done)).perform(click());
    }

    

测试系列博客目录