解讀 Android 官方 MVP 項目單元測試

Google在3月份推出了一個項目,用來介紹Android MVP架構的各種組合,可以認為是官方在這方面的最佳實踐。令人稱道的是除了MVP本身之外,這些工程配備了極其完善的單元測試用例,學習價值極高。本文著重針對todo-mvp的單元測試進行解讀。

寫在前面

  1. 關于MVP
    關于MVP的介紹很多,這不是本文的重點,這里列舉近期一些比較好的文章。
  2. 關于單元測試
    對于單元測試,需要預先了解以下內容
    • Android Studio的test和AndroidTest
    • AndroidJUnitRunner :一個兼容Junit4的Andriod單元測試框架
    • Mockito :單元測試利器
    • Espresso :支持UI測試的單元測試框架
  3. 關于todo-mvp的功能

功能介紹

簡而言之,這個工程包含了三個模塊:待辦事項列表模塊,待辦事項詳情模塊,統計模塊。

MVP各層的單元測試選型

在該項目中,MVP各層所使用的單元測試框架如下圖所示:

官方todo-mvp的UT選型

  • P層:不需要任何Android環境,因此使用Junit測試即可
  • V層:使用Google強大的Espresso進行UI的測試
  • M層:涉及到數據庫相關操作,因此需要依賴Android環境,使用AndroidJUnitRunner進行測試

在此處,我們先大致了解一下MVP各層的UT選型,然后通過一個例子,看看各層之間如何配合測試,最后再對各層UT選型的原因進行分析,從而理解整體測試架構。

接下來我們以TO-DO List頁面(TasksActivity/TaskFragment)中加載任務列表功能為例,此場景的功能界面如下圖所示:

待辦任務列表

Presenter層的測試

在這個功能里,Presenter只做了一件事情,就是loadTask(),時序圖如下所示:

loadTask的時序圖]

從時序圖上看,loadTask執行的邏輯是,1.調用View層開啟進度條->2.從Model層獲取待辦任務列表->3.Model層以回調函數的形式返回數據->4.調用View層關閉進度條->5.調用View層顯示任務列表。這5個步驟里,每個步驟的邏輯是否準確是View層和Model層該測試的事情,對于Presenter層來講,他的測試任務是確保這5個步驟如期調用。為了達成此目的,我們會采用 Mockito.verify() 的api進行測試,這個測試類是 TasksPresenterTest ,代碼如下:

@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);
}

注:這里涉及到異步回調函數如何測試的問題,使用Mockito的Capture可以解決此問題。具體細節,三言兩語說不清,后續考慮專門寫篇文章。

總結:讓Presenter充當個合格的皮條客,去調用其他兩層的邏輯,在假設其他兩層代碼邏輯都是正確的前提下,做一些mock測試,盡可能覆蓋所有邏輯路徑。

View層的測試

這一層的測試其實很清晰,站在QA的角度,我們想要驗證待辦任務列表時候,會設計以下的測試用例:

驗證待辦任務列表的測試流程

通過Espresso可以模擬這些步驟,并進行驗證,這個測試類是 TasksScreenTest ,代碼如下:

@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()));
}

其中,createTask()的實現如下:

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());
}

viewAllTasks()的實現如下:

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());
}

連上設備,跑起UT,會自動啟動相應的Activity界面,做相應的操作后進行測試。

總結:Espresso好強大,而且這一層的測試站在用戶的角度,所有邏輯是黑盒,在功能層面測試輸入(用戶操作)輸出(用戶得到的界面反饋),而技術層面,由于界面是所有層的入口,得到輸出后,除了測試View層本身的邏輯之外,其實已經粗糙的覆蓋了M和P的邏輯了。

Model層的測試

關于Model層的測試,首先要了解下該項目中,model層的設計,類層次如下圖所示:

Model層的類圖

  • TasksLocalDataSource:負責本地數據庫增刪改查操作
  • TasksRemoteDataSource:負責網絡請求(該項目中用 handler.postDelayed() 延時來模擬網絡請求)
  • TasksRepository:相當于整個Model層的門面,根據邏輯判斷決定數據來自于本地數據庫或是網絡。Presenter層只與它打交道。

根據以上分析,可見對Model層的測試要完整的覆蓋這三個類。

  1. 我們先看門面TasksRepository的測試,先看看這個類中有關獲取待辦任務列表的流程圖:

    TasksRepository流程圖

    所以對于TasksRepository來講,測試的內容主要是驗證1,2,3的邏輯是否在相應的輸入下覆蓋到位,對于1,2,3的數據準確性無需關心,由各自DataSource去驗證,因此它的測試與Android環境無關,用Junit+Mockito測試。要完整覆蓋的話,需要多個測試case,篇幅有限,這里只講第2種。這個測試類是 TasksRepositoryTest ,代碼如下:

    @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);
    }

    其中, setTasksAvailable() 代碼如下:

    private void setTasksAvailable(TasksDataSource dataSource, List<Task> tasks) {
     //驗證第2種情況:使用TasksRemoteDataSource調用getTasks()
     verify(dataSource).getTasks(mTasksCallbackCaptor.capture());
     //執行回調 函數
     mTasksCallbackCaptor.getValue().onTasksLoaded(tasks);
    }
  2. 接下來是是TasksLocalDataSource的測試。該測試與數據庫有關,因此依賴于Android環境,且要驗證數據存取的準確性,因此需要做一些斷言,使用AndroidJUnitRunner進行測試,這個類是 TasksLocalDataSourceTest ,代碼如下:

    @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();
        }
    });
    }
  3. 最后來看看跟網絡請求相關的TasksRemoteDataSource的測試

    Google并沒有對這個類本身進行測試,但是對其他層依賴網絡請求數據進行測試的場景做了支持。試想一下,通過上面的分析,我們知道View層是真刀真槍的在模擬用戶的操作進行測試,如果某個測試case需要發起網絡請求,此時我們不知道何時才能返回數據,且由于網絡狀況等原因可能導致請求失敗,種種不確定因素下,是不可能完成一個測試的,解決的辦法很簡單,就是對網絡請求進行Fake,這個類是 FakeTasksRemoteDataSource ,原理便是 當需要用到TasksRemoteDataSource時,不會真正使用該類,而是注入FakeTasksRemoteDataSource,返回事先定義好的數據。

    為此,這個項目在項目結構和代碼方面提供了很多支撐,體現在:

    • 提供了mock和prod兩種Flavors
    • 兩種Flavor分別提供了Injection,注入Fake類或真實類
    • 所有與網絡請求相關的測試代碼存放在androidTestMock下

總結:Model層的測試時而在androidTest寫UT,時而在test里寫,時而在androidTestMock里,有點精神分裂的感覺。但是,真的好清晰,看起測試的結構來非常舒服。

MVP的單元測試架構總結

通過這個例子,我們已經了解了MVP各層之間的職責以及對應的測試內容,接下來做個總結,首先看下MVP測試架構圖:

MVP測試架構圖

  1. View層
    • 職責:MVP模式下,View層終于揚眉吐氣了,View本身該做的事情都能做了,比如UI布局,數據渲染,點擊按鈕交互等等
    • 測試方式:以正常小QA的測試思維方法,就可以來定義這一層的測試方式,測試過程中需要真機或模擬器,并做真實的操作。
    • 測試選型:依賴于Android環境,用谷歌強大的Espresso+AndroidJUnitRunner,Espresso用于模擬和驗證各種各樣的UI操作,代碼存放于AndroidTest中。
  2. Presenter層:
    • 職責:這一層是拉皮條的,負責M和V層的對接,所以有較少的處理輸入輸出的機會,他只用來控制邏輯,去調用相應的Model和View的邏輯。
    • 測試選型:他的職責決定了他很少去斷言輸入輸出,測試邏輯覆蓋的路徑是否正確即可,因此他與Android環境無關,用Junit+Mockito測試即可,代碼存放于test中。
  3. Model層
    • 職責:負責數據的存取,數據可能來自于網絡、數據庫和內存
    • 數據庫增刪改查:需測試數據存取的準確性,依賴Android環境進行測試,因此使用AndroidJUnitRunner,代碼存放于androidTest中
    • 網絡請求:不測試真實的網絡請求,但提供了Fake供其他層調用測試。
    • 封裝的門面類:決定了數據的來源和去向是來自于本地數據庫 or 網絡 or 內存,此為真正對其他層暴露的Model類。此類不做數據準確性的驗證,只做mock測試,驗證覆蓋路徑。UT選型Junit+Mockito,代碼存放于test中。

最后

Android官方MVP架構示例項目在單元測試方面真是良心之作,分析測試用例遠比分析MVP本身得到的收獲多得多,感謝Google,感謝他粗壯的大腿,抱大腿的感覺真好。

此外,在做架構時,不能忽視在單元測試方面的架構,所以,好的架構是可以支撐代碼的可測試性的,Google給我們做了非常棒的最佳實踐,接下來就是各自的項目實踐,不妨從某個模塊開始,步步為營,寫好MVP,補齊單元測試用例。

喜歡此文,覺得此文有用,請打賞^_^!

附錄

『如何寫有價值的測試用例』也是非常重要的話題,在todo-mvp中大大小小的測試用例也有幾十個,所以耐心的看看測試代碼,可以給我們帶來很多思路和指導,由于這部分篇幅較長,且枯燥無味,因此另起一篇文章,有需要的請前往這里。

來自: http://www.jianshu.com/p/cf446be43ae8

 本文由用戶 yangyun123 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!