Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android 進行單元測試難在哪-part2

Android 進行單元測試難在哪-part2

編輯:關於Android編程

 

在Android 進行單元測試難在哪-part1中,我用干貨告訴大家:即使是 Google 大牛寫出來的代碼也無法進行測試。確切地說,我真正告訴大家的是:根本沒辦法在 SessionDetailActivity 的 onStop() 方法裡進行單元測試,而且詳細地解釋了個中因果:由於無法改變預測試狀態,我們無法在 onStop() 方法裡完成斷言;在 onStop() 方法中進行測試時,獲得測試後狀態也是無法完成的。在上篇博文的結尾處,我跟大家說:正是 Android SDK 的某些特性,以及 Google 官方推薦的代碼模板使得單元測試處於如此尴尬的境地,而且我承諾會在這篇博文中詳盡地解釋各種因由,那現在就讓我來兌現我的諾言吧。

在我開始論述之前,我再說一次:正是標准的 Android 應用架構使測試 Android 應用變得如此困難,這句話是本系列博文的核心論點。這篇博文的意義在於:我們嘗試提出理由證明重構 Android 應用的必要性,使得這些 Android 應用不需要明確地依賴於 Android SDK,與此同時,我們也嘗試著提出一種健壯的應用架構,以增強 Android 應用的測試性,你會在這篇博文裡了解到相關的概述。因此,我接下來將嘗試去證明這篇博文的核心論點。

眾所周知,開發 Android 應用有一種標准的架構,在示例代碼和開源代碼裡很常見到應用的業務邏輯被放在 Android 應用的組件類,Activity,Service,Fragment 裡執行。而我接下來就要遵循這種架構進行開發。而這篇博文要論述的就是:如果我們遵循這種標准架構進行開發,極有可能寫下無法測試的代碼,我在上一篇博文裡也論證了這樣的問題並不是偶然,正是標准的 Android 應用架構讓測試變得支離破碎,單元測試幾乎不能進行。

傳統的 Android 應用架構讓單元測試變得不可能

為了開始論證為什麼標准開發架構讓應用組件變得無法測試,大家不妨和我一起簡要地復習下上篇博文的一些結論。單元測試包含三個步驟:准備,測試,斷言。為了完成准備步驟,需要改變測試代碼的預測試狀態,此外,為了完成單元測試的斷言步驟,我們需要獲得程序的測試後狀態。

復習了這些知識點後,可以開始進入正題了哈。在某些情況下,依賴注入是實現能夠改變預測試狀態代碼的唯一辦法,而且這些代碼的測試後狀態也是可訪問的。我寫了一個與 Android 完全無關的示例:

    public class MathNerd {

        private final mCalcCache;

        private final mCalculator;

        public MathNerd(CalculationCache calcCache, Calculator calculator) {
            mCalcCache = calcCache;
            mCalculator = calculator;
        }


        public void doIntenseCalculation(Calculation calculation, IntenseCalculationCompletedListener listener) {

            if (!mCalcCache.contains(calculation)) {

                mCalculator.doIntenseCalculationInBackground(listener);

            } else {

                Answer answer = mCalcCache.getAnswerFor(calculation);
                listener.onCalculationCompleted(answer);
            }
        }
    }

如上所示,依賴注入確實是對 doIntenseCalculation() 進行單元測試的唯一辦法,因為 doIntenseCalculation() 方法根本沒有返回值。除此以外,MathNerd 類裡也沒有判斷測試後狀態有效性的屬性。但通過依賴注入,我們可以通過 mCalcCache 獲得單元測試中的測試後狀態。

    public void testCacheUpdate() {

        //Arrange
        CalculationCache calcCache = new CalculationCache();

        Calculator calculator = new Calculator();

        MathNerd mathNerd = new MathNerd(calcCache, calculator);

        Calculation calcualation = new Calculation(e^2000);

        //Act
        mathNerd.doIntenseCalculationInBackground(calculation, null);

        //some smelly Thread.sleep() code...

        //Assert
        calcCache.contains(calculation);
    }

如果我們這樣做,很遺憾,恐怕是沒辦法為 MathNerd 類實現一個測試單元了。我們將會實現一個整合測試,用於檢查 MathNerd 實際行為以及類是否根據 doIntenseCalculationInBackground() 方法處理後的值更新 CalcCache。

此外,依賴注入實際上也是驗證測試單元測試後狀態的唯一辦法。我們通過注入驗證方法在正確的位置被調用:

    public void testCacheUpdate() {

       //Arrange
        CalculationCache calcCache = mock(CalculationCache.class);

        when(calcCache.contains()).thenReturn(false);

        Calculator calculator = mock(Calculator.class);

        MathNerd mathNerd = new MathNerd(calcCache, calculator);

        Calculation calculation = new Calculation(e^2000);

        //Act
        mathNerd.doIntenseCalculationInBackground(calculation, null);

        //Assert should use calculator to perform calcluation because cache was empty
        verify(calculator).doIntenseCalculationInBackground(any());
    }

在 Android 應用的相關類中進行單元測試涉及的許多測試實例都需要一個東西:依賴注入。但問題來了:核心 Android 類持有我們無法注入的依賴。例如我上次提到的通過 SessionDetailActivity 啟動的 SessionCalendarService 就是一個很好的例子:

    @Override
    protected void onHandleIntent(Intent intent) {

        final String action = intent.getAction();
        Log.d(TAG, Received intent:  + action);

        final ContentResolver resolver = getContentResolver();

        boolean isAddEvent = false;

        if (ACTION_ADD_SESSION_CALENDAR.equals(action)) {
            isAddEvent = true;

        } else if (ACTION_REMOVE_SESSION_CALENDAR.equals(action)) {
            isAddEvent = false;

        } else if (ACTION_UPDATE_ALL_SESSIONS_CALENDAR.equals(action) &&
                PrefUtils.shouldSyncCalendar(this)) {
            try {
                getContentResolver().applyBatch(CalendarContract.AUTHORITY,
                        processAllSessionsCalendar(resolver, getCalendarId(intent)));
                sendBroadcast(new Intent(
                        SessionCalendarService.ACTION_UPDATE_ALL_SESSIONS_CALENDAR_COMPLETED));
            } catch (RemoteException e) {
                LOGE(TAG, Error adding all sessions to Google Calendar, e);
            } catch (OperationApplicationException e) {
                LOGE(TAG, Error adding all sessions to Google Calendar, e);
            }

        } else if (ACTION_CLEAR_ALL_SESSIONS_CALENDAR.equals(action)) {
            try {
                getContentResolver().applyBatch(CalendarContract.AUTHORITY,
                        processClearAllSessions(resolver, getCalendarId(intent)));
            } catch (RemoteException e) {
                LOGE(TAG, Error clearing all sessions from Google Calendar, e);
            } catch (OperationApplicationException e) {
                LOGE(TAG, Error clearing all sessions from Google Calendar, e);
            }

        } else {
            return;
        }

       //...
    }

SessionCalendarService 的依賴是 ContentResolver,而且 ContentResolver 就是一個無法注入的依賴,所以如果我們並沒有辦法在 onHandleIntent() 方法裡進行注入。而 onHandleIntent() 方法沒有返回值,SessionCalendarService 類裡也沒有能讓我們檢查測試後狀態的可訪問的屬性。為了驗證測試後狀態,我們可以通過查詢 ContentProvider 檢查請求數據是否被插入,但我們不會這樣的方式為 SessionCalendarService 實現測試單元。相反,我們用的方法是實現一個整合測試,同時測試 SessionCalendarService 以及受 ContentProvider 操控的日歷會議數據。

所以如果你把業務邏輯放在 Android 類裡,而這個類的依賴又無法被注入,那這部分代碼鐵定沒辦法進行單元測試了。類似的無法被注入的依賴還有呢,例如:Activity 和 Fragment 的 FragmentManager。因此,至今為止 Google 官方一直鼓勵我們使用的標准 Android 應用架構模式,教導我們在開發應用的時候要把業務邏輯放在應用的組件類裡,信誓旦旦地說這是為我們好,而我們今天才知道真相竟然是:正是這樣的架構讓我們寫下無法測試的代碼。

標准開發模式讓單元測試變得困難重重

某些情況下,標准的開發模式使代碼的單元測試變得十分困難。如果我們回到上一篇博文提到的 SessionDetailActivity 裡的 onStop() 方法,可以看到:

    @Override
    public void onStop() {
        super.onStop();
        if (mInitStarred != mStarred) {
            if (UIUtils.getCurrentTime(this) < mSessionStart) {
                // Update Calendar event through the Calendar API on Android 4.0 or new versions.
                Intent intent = null;
                if (mStarred) {
                    // Set up intent to add session to Calendar, if it doesn't exist already.
                    intent = new Intent(SessionCalendarService.ACTION_ADD_SESSION_CALENDAR,
                            mSessionUri);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_START,
                            mSessionStart);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_END,
                            mSessionEnd);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_ROOM, mRoomName);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_TITLE, mTitleString);
                } else {
                    // Set up intent to remove session from Calendar, if exists.
                    intent = new Intent(SessionCalendarService.ACTION_REMOVE_SESSION_CALENDAR,
                            mSessionUri);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_START,
                            mSessionStart);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_END,
                            mSessionEnd);
                    intent.putExtra(SessionCalendarService.EXTRA_SESSION_TITLE, mTitleString);
                }
                intent.setClass(this, SessionCalendarService.class);
                startService(intent);

                if (mStarred) {
                    setupNotification();
                }
            }
        }
    }

就像你看到的那樣,onStop() 方法裡壓根沒有能讓我們知道 SessionCalendarService 是否通過正確的參數啟動的可訪問屬性,此外,onStop() 方法是一個受保護的方法,使其返回值是無法修改的。因此,我們訪問測試後狀態的唯一辦法就是檢查注入到 onStop() 方法內的注入的狀態。

這樣一來,我們就會注意到 onStop() 方法中用於啟動 SessionCalendarService 的代碼並不屬於某一個類。換句話說,onStop() 方法中注入的依賴根本不存在用於檢查 SessionCalendarService 是否在正確的情況下通過正確的參數啟動的測試單元測試後狀態的屬性。為了提出能讓 onStop() 方法變為可測試的的第三種辦法,那我們需要一些這樣的東西:

    @Override
    public void onStop() {
        super.onStop();
        if (mInitStarred != mStarred) {
            if (UIUtils.getCurrentTime(this) < mSessionStart) {
                // Update Calendar event through the Calendar API on Android 4.0 or new versions.
                Intent intent = null;
                if (mStarred) {

                    // Service launcher sets up intent to add session to Calendar
                    mServiceLauncher.launchSessionCalendarService(SessionCalendarService.ACTION_ADD_SESSION_CALENDAR, mSessionUri, 
                                                                mSessionStart, mSessionEnd, mRoomName, mTitleString);

                } else {

                    // Set up intent to remove session from Calendar, if exists.
                    mServiceLauncher.launchSessionCalendarService(SessionCalendarService.ACTION_REMOVE_SESSION_CALENDAR, mSessionUri,
                                                                mSessionStart, mSessionEnd, mTitleString);
                }

                if (mStarred) {
                    setupNotification();
                }
            }
        }
    }

雖然這不是重構 onStop() 方法最簡潔的方式,但如果我們按照標准開發方法把業務邏輯寫在 Activity 裡,並讓寫下的代碼可以進行單元測試,類似的處理就變得必要了。現在不妨想想這種重構方式有多麼違反常理:我們沒有簡單地調用 startService() 方法(startService() 是 Context 的一個方法,我們甚至可以說調用的是 SessionDetailActivity 的方法),而是通過依賴於 Context 的 ServiceLauncher 對象去啟動該服務。SesionDetailActivity 作為 Context 的子類也將使用一個持有 Context 的對象去啟動 SessionCalendarService。

不幸的是,即使我們像上面說的那樣重構了 onStop() 方法,我們仍然不能保證能為 onStop() 方法實現測試單元。問題在於:ServiceLauncher 沒有被注入,使得我們不能對 ServiceLauncher 進行注入,使我們能驗證在測試過程中調用了正確的方法。

要對 ServiceLauncher進行注入,除了剛剛提到的以外,還會因為 ServiceLauncher 自身依賴於 Context 變得復雜,因為 Context 是一個非打包對象。因此,你並不能簡單地通過將其傳入用於啟動 SessionDetailActivity 的 Intent 注入 ServiceLauncher。所以為了注入 ServiceLauncher,你需要開動你的小腦筋,或者使用類似於 Dagger¹ 的注入庫。現在你應該也會發現,為了讓我們的代碼可以進行單元測試,我們確實需要完成許多復雜、繁瑣的工作,而且,正如我即將在下篇博文中的論述,就算我們為了進行依賴注入而使用 Dagger 這樣的庫,在 Activity 內進行單元測試仍然是令人備受煎熬的。

為了讓 onStop() 方法能進行單元測試,標准開發方式強迫我們使用反常理的重構方法,並要求我們在“根據以 Intent 為基礎的依賴注入機制想出更好的重構方法”或“使用第三方的依賴注入庫”。而標准開發方式為寫下可測試代碼帶來的困難,就像在鼓勵我們寫下無法進行測試的代碼,正是這種困難讓我認為:標准開發方式阻礙我們寫下可測試代碼。

結論

在整個系列博文中,我一直在提出這樣的觀點:通過反思為什麼在 Android 中進行單元測試如此困難,將幫助我們發現重構應用架構的各種好處,使我們的應用不必明確地依賴於 Android SDK。這篇博文論述到這裡,我相信大家有足夠理由相信完全擺脫 Android SDK 或許是個好提議了。

我剛剛把業務邏輯放在應用的組件類中,並向大家證明了對其進行單元測試有多麼困難,甚至我們可以說對其進行單元測試這是不可能的。在下一篇博文中,我將建議大家將業務邏輯委托給使用了正確的依賴注入姿勢的類。如果我們覺得定義這些類很麻煩的話,退而求其次,也能讓這些類的依賴成為與 Android 無關的接口。與增強程序測試性的第一步相比,這一步是至關重要的,而完成第二步使我們無需 Android 特有的測試工具(例如:Roboletric,Instrumented Tests)就能寫下更高效的測試單元。

毫無疑問,你在傳入 ServiceLauncher 時應該使他變為一個序列化對象。但這並不是一個特別健壯的解決辦法,因為只有在你不在乎序列化帶來的性能影響時才能使用這個辦法。
  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved