解讀Android官方MVP專案單元測試

joytoy發表於2021-09-09

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

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

  • 關於單元測試
    對於單元測試,需要預先了解以下內容

  • Android Studio的test和AndroidTest

  •  :一個相容Junit4的Andriod單元測試框架

  •  :單元測試利器

  •  :支援UI測試的單元測試框架

  • 關於todo-mvp的功能

圖片描述

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

MVP各層的單元測試選型

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

圖片描述

  • P層:不需要任何Android環境,因此使用Junit測試即可

  • V層:使用Google強大的Espresso進行UI的測試

  • M層:涉及到資料庫相關操作,因此需要依賴Android環境,使用AndroidJUnitRunner進行測試

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

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

圖片描述

Presenter層的測試

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

圖片描述

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

@Testpublic 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 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,程式碼如下:

@Testpublic 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 viewAllTasks() {    //點選過濾按鈕
    onView(withId(R.id.menu_filter)).perform(click());    //點選ALL的選項
    onView(withText(R.string.nav_all)).perform(click());
}

連上裝置,跑起UT,會自動啟動相應的Activity介面,做相應的操作後進行測試。

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

Model層的測試

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

圖片描述

  • TasksLocalDataSource:負責本地資料庫增刪改查操作

  • TasksRemoteDataSource:負責網路請求(該專案中用handler.postDelayed()延時來模擬網路請求)

  • TasksRepository:相當於整個Model層的門面,根據邏輯判斷決定資料來自於本地資料庫或是網路。Presenter層只與它打交道。

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

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

圖片描述

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

@Testpublic 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 tasks) {    //驗證第2種情況:使用TasksRemoteDataSource呼叫getTasks()
    verify(dataSource).getTasks(mTasksCallbackCaptor.capture());    //執行回撥 函式
    mTasksCallbackCaptor.getValue().onTasksLoaded(tasks);
}
  • 接下來是是TasksLocalDataSource的測試。該測試與資料庫有關,因此依賴於Android環境,且要驗證資料存取的準確性,因此需要做一些斷言,使用AndroidJUnitRunner進行測試,這個類是TasksLocalDataSourceTest,程式碼如下:

@Testpublic 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 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();
        }
    });
}
  • 最後來看看跟網路請求相關的TasksRemoteDataSource的測試
    Google並沒有對這個類本身進行測試,但是對其他層依賴網路請求資料進行測試的場景做了支援。試想一下,透過上面的分析,我們知道View層是真刀真槍的在模擬使用者的操作進行測試,如果某個測試case需要發起網路請求,此時我們不知道何時才能返回資料,且由於網路狀況等原因可能導致請求失敗,種種不確定因素下,是不可能完成一個測試的,解決的辦法很簡單,就是對網路請求進行Fake,這個類是FakeTasksRemoteDataSource,原理便是當需要用到TasksRemoteDataSource時,不會真正使用該類,而是注入FakeTasksRemoteDataSource,返回事先定義好的資料。

為此,這個專案在專案結構和程式碼方面提供了很多支撐,體現在:

  • 提供了mock和prod兩種Flavors

  • 兩種Flavor分別提供了Injection,注入Fake類或真實類

  • 所有與網路請求相關的測試程式碼存放在androidTestMock下

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

MVP的單元測試架構總結

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

圖片描述

  • View層

  • 職責:MVP模式下,View層終於揚眉吐氣了,View本身該做的事情都能做了,比如UI佈局,資料渲染,點選按鈕互動等等

  • 測試方式:以正常小QA的測試思維方法,就可以來定義這一層的測試方式,測試過程中需要真機或模擬器,並做真實的操作。

  • 測試選型:依賴於Android環境,用谷歌強大的Espresso+AndroidJUnitRunner,Espresso用於模擬和驗證各種各樣的UI操作,程式碼存放於AndroidTest中。

  • Presenter層:

  • 職責:這一層是拉皮條的,負責M和V層的對接,所以有較少的處理輸入輸出的機會,他只用來控制邏輯,去呼叫相應的Model和View的邏輯。

  • 測試選型:他的職責決定了他很少去斷言輸入輸出,測試邏輯覆蓋的路徑是否正確即可,因此他與Android環境無關,用Junit+Mockito測試即可,程式碼存放於test中。

  • Model層

  • 職責:負責資料的存取,資料可能來自於網路、資料庫和記憶體

  • 資料庫增刪改查:需測試資料存取的準確性,依賴Android環境進行測試,因此使用AndroidJUnitRunner,程式碼存放於androidTest中

  • 網路請求:不測試真實的網路請求,但提供了Fake供其他層呼叫測試。

  • 封裝的門面類:決定了資料的來源和去向是來自於本地資料庫 or 網路 or 記憶體,此為真正對其他層暴露的Model類。此類不做資料準確性的驗證,只做mock測試,驗證覆蓋路徑。UT選型Junit+Mockito,程式碼存放於test中。

最後

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

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

原文連結:http://www.apkbus.com/blog-856294-78240.html

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4650/viewspace-2806150/,如需轉載,請註明出處,否則將追究法律責任。

相關文章