Android單元測(cè)試—邏輯測(cè)試

前言

在之前的系列博客中平匈,主要圍繞的是測(cè)試工具的介紹與使用。經(jīng)過(guò)幾個(gè)月的沉寂,在項(xiàng)目中摸索與實(shí)踐單元測(cè)試增炭,曾經(jīng)踩坑無(wú)數(shù)忍燥,自己從中受益匪淺,確實(shí)是一段成長(zhǎng)的歷程隙姿!今天準(zhǔn)備一些干貨梅垄,給感興趣的同學(xué)借鑒一下,主要是分享在項(xiàng)目實(shí)踐過(guò)程中的經(jīng)驗(yàn)總結(jié)以及對(duì)Android單元測(cè)試的理解输玷,將以兩篇博客的篇幅進(jìn)行詳細(xì)介紹队丝,歡迎大家關(guān)注!

先上個(gè)圖壓壓驚

Precondition

需要明確的是欲鹏,單元測(cè)試分為兩部分机久,即UI測(cè)試和邏輯測(cè)試,其兩者的實(shí)現(xiàn)方式是有所不同的赔嚎,效率也是不一樣的”旄牵現(xiàn)在的項(xiàng)目中,大都使用MVP設(shè)計(jì)框架尤误,它通過(guò)面向接口編程的方式侠畔,借助于Presenter這個(gè)中間層從而實(shí)現(xiàn)View層和Model層的隔離,不僅方便項(xiàng)目維護(hù)擴(kuò)展损晤,因其把依賴于Android環(huán)境的View層和純Java的數(shù)據(jù)邏輯處理層分離软棺,還方便我們進(jìn)行單元測(cè)試。工欲善其事沉馆,必先利其器码党,在實(shí)踐之前德崭,我們要用MVP設(shè)計(jì)框架對(duì)項(xiàng)目進(jìn)行重構(gòu)斥黑,只有建立在良好的架構(gòu)和明確的層次,單元測(cè)試實(shí)施起來(lái)才能事半功倍眉厨。

MVP

先說(shuō)UI測(cè)試锌奴,對(duì)應(yīng)于MVP設(shè)計(jì)框架中的View層,所寫的Case代碼位于src/androidTest/java/憾股。既然是Android的UI鹿蜀,就依賴于Android環(huán)境,那么我們針對(duì)這個(gè)的單元測(cè)試覆蓋也就需要運(yùn)行在Android虛擬機(jī)和Android真機(jī)上服球,想必你也知道茴恰,每當(dāng)我們Run一次都需要好幾分鐘的等待時(shí)間,期間經(jīng)過(guò)編譯成apk斩熊,并把a(bǔ)pk安裝在Android環(huán)境上往枣。這就是為什么我們要把項(xiàng)目分為UI測(cè)試和邏輯測(cè)試,因?yàn)楹臅r(shí)。對(duì)Android UI測(cè)試分冈,想必你可能了解圾另,Google官方推出了Espresso,使用起來(lái)很方便雕沉,會(huì)在以后的博客中展開(kāi)來(lái)說(shuō)集乔。

而邏輯測(cè)試,對(duì)應(yīng)于MVP設(shè)計(jì)框架的Presenter層和Model層,所寫的Case代碼位于src/test/java/坡椒。指的是純Java代碼的單元覆蓋扰路,比如說(shuō)登錄時(shí)對(duì)賬戶密碼合法性的校驗(yàn)邏輯,再比如說(shuō)是數(shù)據(jù)的請(qǐng)求肠牲、存儲(chǔ)幼衰、封裝等處理邏輯,這部分的代碼往往不依賴于Android環(huán)境缀雳,可能會(huì)對(duì)Android Context上下文的依賴渡嚣,相對(duì)UI來(lái)說(shuō)要純粹一些》视。看過(guò)之前博客的同學(xué)可能會(huì)知道识椰,強(qiáng)烈推薦使用測(cè)試框架PowerMockito+Robolectric

(1)深碱、PowerMockito不僅可以mock Public數(shù)據(jù)對(duì)象腹鹉,還可以mock Private、Final敷硅、Static功咒、Singleton等數(shù)據(jù)對(duì)象,通過(guò)Mock數(shù)據(jù)對(duì)象的方式可以幫助我們隔離外部依賴绞蹦,讓我們只專注于目標(biāo)代碼輸入輸出等調(diào)用邏輯的測(cè)試力奋;
(2)、Robolectric通過(guò)實(shí)現(xiàn)一套能在JVM能運(yùn)行的Android代碼幽七,為我們提供Android Application和Context的支持景殷,因?yàn)樵贛odel層需要依賴于Android Context上下文,比如說(shuō)對(duì)Android數(shù)據(jù)庫(kù)Sqlite操作和SharedPreference等數(shù)據(jù)存儲(chǔ)操作澡屡。

邏輯測(cè)試

今天的重點(diǎn)是分享如何進(jìn)行邏輯代碼的單元覆蓋猿挚,終于說(shuō)到正題了。

build.gradle配置:
    testCompile 'junit:junit:4.12'
    testCompile 'org.assertj:assertj-core:1.7.0'
    testCompile 'org.robolectric:robolectric:3.0'
    testCompile 'org.powermock:powermock-module-junit4:1.6.5'
    testCompile 'org.powermock:powermock-module-junit4-rule:1.6.5'
    testCompile 'org.powermock:powermock-api-mockito:1.6.5'
    testCompile 'org.powermock:powermock-classloading-xstream:1.6.5'

細(xì)心的同學(xué)會(huì)發(fā)現(xiàn)驶鹉,此處robolectric用的是老版本3.0绩蜻,并沒(méi)有用最新的版本3.3。前方高能室埋,從github的反饋中看出办绝,新版本有坑還不穩(wěn)定踏兜。如果項(xiàng)目中需要讀取配置信息(如HTTPS的證書、預(yù)置數(shù)據(jù))八秃,就得使用assets文件碱妆。默認(rèn)情況下,robolectric3.0版本無(wú)法讀取asset文件昔驱,還得自定義RobolectricTestRunner疹尾。

自定義Runner
public class CustomTestRunner extends RobolectricTestRunner {

    private static final String APP_MODULE_NAME = "app";

    /**
     * Creates a runner to run {@code testClass}. Looks in your working directory for your AndroidManifest.xml file
     * and res directory by default. Use the {@link org.robolectric.annotation.Config} annotation to configure.
     *
     * @param testClass the test class to be run
     * @throws org.junit.runners.model.InitializationError if junit says so
     */
    public CustomTestRunner(Class<?> testClass) throws InitializationError {
        super(testClass);
    }

    @Override
    protected AndroidManifest getAppManifest(Config config) {

        String userDir = System.getProperty("user.dir", "./");
        File current = new File(userDir);
        String prefix;
        if (new File(current, APP_MODULE_NAME).exists()) {
            System.out.println("Probably running on AndroidStudio");
            prefix = "./" + APP_MODULE_NAME;
        } else if (new File(current.getParentFile(), APP_MODULE_NAME).exists()) {
            System.out.println("Probably running on Console");
            prefix = "../" + APP_MODULE_NAME;
        } else {
            throw new IllegalStateException("Could not find app module, app module should be \"app\" directory in the project.");
        }
        System.setProperty("android.manifest", prefix + "/src/main/AndroidManifest.xml");
        System.setProperty("android.resources", prefix + "/src/main/res");
        System.setProperty("android.assets", prefix + "/src/main/assets");

        return new AndroidManifest(Fs.fileFromPath(prefix + "/src/main/AndroidManifest.xml"), Fs.fileFromPath(prefix + "/src/main/res"), Fs.fileFromPath(prefix + "/src/main/assets")) {
            @Override
            public int getTargetSdkVersion() {
                return 18;
            }
        };
    }

}

在代碼末尾處,你會(huì)發(fā)現(xiàn)下面代碼:

public int getTargetSdkVersion() {
     return 18;
}

一個(gè)非常重要的細(xì)節(jié)骤肛,若是不重寫指定Android版本的話纳本,就會(huì)報(bào)錯(cuò)java.lang.UnsupportedOperationException: Robolectric does not support API level 1, sorry!,然而在最新的robolectric版本沒(méi)有這個(gè)Exception腋颠。說(shuō)點(diǎn)題外話繁成,除了重寫getTargetSdkVersion方法這種方式,還可以在AndroidManifest.xml配置文件中指定compileSdkVersion淑玫,雖然可以解決這個(gè)Exception巾腕,但是你不覺(jué)得這種方式侵入性有點(diǎn)大嗎,在Android Studio中配置sdk版本是在gradle文件中配置絮蒿,所以不推薦這種方式尊搬。

BaseRoboTestCase

避免重復(fù)代碼,定義抽象類BaseRoboTestCase土涝,只要繼承重寫就可以開(kāi)始單元測(cè)試之旅佛寿,是不是很方便呀!

@Config( shadows = {ShadowLog.class})
@RunWith(CustomTestRunner.class)
@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "org.json.*", "sun.security.*", "javax.net.*"})
public abstract class BaseRoboTestCase {
    @Rule
    public PowerMockRule rule = new PowerMockRule();
    private static boolean hasInitRxJava = false;

    @Before
    public void setUp() {
        ShadowLog.stream = System.out;
        System.out.println("setUp now");
        Robolectric.getShadowApplication();
        if (!hasInitRxJava) {
            hasInitRxJava = true;
            initRxJava();
        }
        MockitoAnnotations.initMocks(this);
    }

    public Application getApplication() {
        return Robolectric.application;
    }

    public Context getContext() {
        return getApplication();
    }

    private void initRxJava() {
        RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
            @Override
            public Scheduler getIOScheduler() {
                return Schedulers.immediate();
            }
        });
        RxAndroidPlugins.getInstance().registerSchedulersHook(new RxAndroidSchedulersHook() {
            @Override
            public Scheduler getMainThreadScheduler() {
                return Schedulers.immediate();
            }
        });
    }

    @Test
    public void test() {
    }

}

上面代碼涉及的知識(shí)會(huì)有點(diǎn)多但壮,在這里我們只關(guān)注重點(diǎn)冀泻,更加詳細(xì)的可以參考我之前寫的博客

1蜡饵、通過(guò)@RunWith(CustomTestRunner.class)方式注入上面說(shuō)到的自定義Runner弹渔。
2、不知道你注意到了沒(méi)有验残,上面寫了一個(gè)空的test()測(cè)試方法捞附,方法名可以隨意定義巾乳,這是為啥呢您没?是因?yàn)樵诮K端上運(yùn)行./gradlew testDebugUnitTest --continue指令批量來(lái)跑src/test/java/目錄下所有的單元測(cè)試Case時(shí),會(huì)拋出異常java.lang.Exception: No runnable methods胆绊。
3氨鹏、公司項(xiàng)目使用的是RxJava+Retrofit+OKHttp框架來(lái)處理網(wǎng)絡(luò)請(qǐng)求和異步操作的,在對(duì)RxJava相關(guān)的代碼進(jìn)行單元測(cè)試時(shí)压状,線程切換是非常重要仆抵。RxJava官方考慮到單元測(cè)試跟继,為我們提供了Hook的方式來(lái)保證線程切換,通過(guò)RxAndroidPlugins.getInstance().registerSchedulersHook()方法可以將其他線程的處理統(tǒng)一切換到我們指定線程Schedulers.immediate()來(lái)處理镣丑,即當(dāng)前單元測(cè)試跑的這個(gè)線程舔糖,如此一來(lái)方便單元測(cè)試驗(yàn)證。

寫好Presenter

MVP設(shè)計(jì)框架中莺匠,如何寫好Presenter層金吗,是一個(gè)很有藝術(shù)的問(wèn)題。想當(dāng)初初學(xué)MVP時(shí)趣竣,還是會(huì)按照之前MVC的慣性思維摇庙,會(huì)把部分的數(shù)據(jù)邏輯(比如說(shuō)數(shù)據(jù)對(duì)象空、越界遥缕、合法性等判斷)處理放在Activity中卫袒,這樣導(dǎo)致的結(jié)果是,如果想單元測(cè)試這部分邏輯代碼单匣,就會(huì)顯得比較麻煩夕凝,必須得在Android測(cè)試環(huán)境下執(zhí)行。其實(shí)户秤,一個(gè)好的Presenter層應(yīng)該是迹冤,包含絕大部分的數(shù)據(jù)處理邏輯,而View層只執(zhí)行UI的更新工作(setText虎忌、setVisibile泡徙、setFocus等),如此一來(lái)就很方便我們進(jìn)行單元覆蓋Pressenter所有邏輯分支膜蠢。換句話說(shuō)堪藐,Presenter層直接影響到純Java代碼的覆蓋率了,進(jìn)而關(guān)系到bug率挑围。

隔離外部依賴

一個(gè)很普遍的問(wèn)題是礁竞,要測(cè)試的目標(biāo)類會(huì)有很多外部依賴,這些依賴的類/對(duì)象/資源又會(huì)有別的依賴杉辙,從而形成一個(gè)大的依賴樹(shù)模捂,要在單元測(cè)試的環(huán)境中完整地構(gòu)建這樣的依賴,是一件很困難的事情蜘矢。而通過(guò)Mock的方式狂男,對(duì)測(cè)試的類所依賴的其他類和對(duì)象,進(jìn)行mock構(gòu)建假對(duì)象品腹,并定義這些假對(duì)象上的行為岖食,然后提供給被測(cè)試對(duì)象使用。被測(cè)試對(duì)象像使用真的對(duì)象一樣使用它們舞吭。用這種方式泡垃,我們可以把測(cè)試的目標(biāo)限定于被測(cè)試對(duì)象本身析珊,就如同在被測(cè)試對(duì)象周圍做了一個(gè)劃斷,形成了一個(gè)盡量小的被測(cè)試目標(biāo)蔑穴。

但Mock的前提是你的代碼可以進(jìn)行外部依賴注入忠寻,可能我們?cè)诓恢X(jué)中,就會(huì)在類中構(gòu)造并定義私有變量存和,或者在用到的時(shí)候直接new锡溯,讓我們沒(méi)法方便進(jìn)行依賴注入,諸如此類都不是正確的姿勢(shì)哑姚。如下:

外部依賴錯(cuò)誤的使用姿勢(shì)

所以在coding時(shí)祭饭,對(duì)于外部依賴,盡量要提供接口可以注入依賴叙量,否則我們難以入手倡蝙。可以通過(guò)構(gòu)造函數(shù)的方式傳入外部依賴绞佩,也可以通過(guò)set方法寺鸥,要是項(xiàng)目使用Dagger2框架,可以通過(guò)依賴注解的方式解決品山。正確的姿勢(shì)如下:

外部依賴正確的使用姿勢(shì)

測(cè)試普通方法

當(dāng)我們要對(duì)一個(gè)方法進(jìn)行測(cè)試時(shí)胆建,該如何下手呢?

  1. 有明確的返回值肘交,做單元測(cè)試時(shí)笆载,只需調(diào)用這個(gè)函數(shù),驗(yàn)證其返回值是否符合預(yù)期結(jié)果涯呻,這個(gè)很簡(jiǎn)單凉驻。
  2. 對(duì)于無(wú)返回值的void方法,這個(gè)方法只改變其對(duì)象內(nèi)部的一些屬性或者狀態(tài)复罐,就驗(yàn)證它所改變的屬性和狀態(tài)涝登,可以通過(guò)ArgumentCaptor方式來(lái)捕獲并驗(yàn)證中間狀態(tài),也可以驗(yàn)證是否執(zhí)行外部依賴的方法效诅。

測(cè)試異步方法

深切體會(huì)到胀滚,測(cè)試異步方法,是整個(gè)單元測(cè)試的難點(diǎn)和重點(diǎn)乱投,為什么這么說(shuō)呢咽笼?問(wèn)題很明顯,當(dāng)測(cè)試方法跑完了的時(shí)候篡腌,被測(cè)的異步代碼可能還在執(zhí)行沒(méi)跑完褐荷,這就有問(wèn)題了勾效。再者就是實(shí)現(xiàn)異步操作的框架比較多樣嘹悼。下面有這么一個(gè)AyncModel類:

public class AyncModel {

    private Handler mUiHandler = new Handler(Looper.getMainLooper());

    public void loadAync(final Callback callback) {
        new Thread(new Runnable() {

            @Override
            public void run() {
                try {
                    // 模擬耗時(shí)操作
                    Thread.sleep(1000);
                    final List<String> results = new ArrayList<>();
                    results.add("test String");
                    mUiHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            callback.onSuccess(results);
                        }
                    });
                } catch (final InterruptedException e) {
                    e.printStackTrace();
                    mUiHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            callback.onFailure(500, e.getMessage());
                        }
                    });
                }
            }
        }).start();
    }

    interface Callback {

        void onSuccess(List<String> results);

        void onFailure(int code, String msg);
    }
}

在上面的例子中叛甫,AyncModel類的loadAync()方法里面新建了一個(gè)線程來(lái)異步加載results字符串列表。如果我們按正常的方式寫對(duì)應(yīng)的測(cè)試:

public class AyncModelTest extends BaseRoboTestCase {

    @Test
    public void loadAync() throws Exception {
        AyncModel model = new AyncModel();
        final List<String> result = new ArrayList<>();
        model.loadAync(new AyncModel.Callback() {
            @Override
            public void onSuccess(List<String> list) {
                result.addAll(list);
            }

            @Override
            public void onFailure(int code, String msg) {
                fail();
            }
        });
        assertEquals(1, result.size());
    }

}

你會(huì)發(fā)現(xiàn)上面的測(cè)試方法loadAync()永遠(yuǎn)會(huì)fail杨伙,這是因?yàn)樵趫?zhí)行 assertEquals(1, result.size());的時(shí)候其监,loadAync()里面啟動(dòng)的線程壓根還沒(méi)執(zhí)行完畢呢,因此限匣,callback里面的 result.addAll(list);也沒(méi)有得到執(zhí)行抖苦,所以result.size()返回永遠(yuǎn)是0。

Test Aync Fail

前方高能米死,重點(diǎn)來(lái)了锌历,要解決這個(gè)問(wèn)題:如何使用正確的姿勢(shì)來(lái)測(cè)試異步代碼。通常有兩種思路峦筒,一是等異步代碼執(zhí)行完了再執(zhí)行assert斷言操作究西,二是將異步變成同步。接下來(lái)物喷,具體講講用這兩種思路怎樣來(lái)測(cè)試我們的異步代碼:

等待異步代碼執(zhí)行完畢

在上面的例子中卤材,我們要做的其實(shí)就是是等待Callback里面的代碼執(zhí)行完畢后再執(zhí)行Asset斷言操作。要達(dá)到這個(gè)目的峦失,大致有兩種實(shí)現(xiàn)方式:

(1)扇丛、使用Thread.sleep
估計(jì)大家的第一反應(yīng)可能和我一樣,會(huì)使用這種休眠的方式來(lái)等待異步代碼執(zhí)行尉辑,可能是最簡(jiǎn)單的方式帆精,這種方式需要設(shè)置sleep的時(shí)間,所以不可控隧魄,建議不適用這種方式实幕。結(jié)合上面的例子,具體演示一下:

public class AyncModelTest extends BaseRoboTestCase {

    @Test
    public void loadAync() throws Exception {
        AyncModel model = new AyncModel();
        final List<String> result = new ArrayList<>();
        model.loadAync(new AyncModel.Callback() {
            @Override
            public void onSuccess(List<String> list) {
                result.addAll(list);
            }

            @Override
            public void onFailure(int code, String msg) {
                fail();
            }
        });
        // 使用sleep方式等待異步執(zhí)行
        Thread.sleep(4000);
        // 此處有坑堤器,如果不加這行代碼昆庇,就會(huì)出現(xiàn)Handler沒(méi)有執(zhí)行Runnable的問(wèn)題
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
        assertEquals(1, result.size());
    }

}

(2)、使用CountDownLatch
有一個(gè)非常好用的神器闸溃,那就是CountDownLatch整吆。CountDownLatch是一個(gè)類,它有兩對(duì)配套使用的方法辉川,那就是countDown()和await()表蝙。await()方法會(huì)阻塞當(dāng)前線程,直到countDown()被調(diào)用了一定的次數(shù)乓旗,這個(gè)次數(shù)就是在創(chuàng)建這個(gè)CountDownLatch對(duì)象時(shí)府蛇,傳入的構(gòu)造參數(shù)。結(jié)合上面的例子屿愚,具體如下:

public class AyncModelTest extends BaseRoboTestCase {

    @Test
    public void loadAync() throws Exception {
        // 使用CountDownLatch
        final CountDownLatch latch = new CountDownLatch(1);
        AyncModel model = new AyncModel();
        final List<String> result = new ArrayList<>();
        model.loadAync(new AyncModel.Callback() {
            @Override
            public void onSuccess(List<String> list) {
                result.addAll(list);
                latch.countDown();
            }

            @Override
            public void onFailure(int code, String msg) {
                fail();
                latch.countDown();
            }
        });
        latch.await(3, TimeUnit.SECONDS);
        // 此處有坑汇跨,如果不加這行代碼务荆,就會(huì)出現(xiàn)Handler沒(méi)有執(zhí)行Runnable的問(wèn)題
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
        assertEquals(1, result.size());
    }

}

使用CountDownLatch來(lái)做單元測(cè)試,有一個(gè)很大的限制穷遂,侵入性很高函匕,那就是countDown()必須在測(cè)試代碼里面寫。換句話說(shuō)蚪黑,異步操作必需提供Callback盅惜,在Callback中執(zhí)行countDown()方法。如果被測(cè)的異步方法(如上面例子的loadAync())不是通過(guò)Callback的方式來(lái)通知結(jié)果忌穿,而是通過(guò)EventBus來(lái)通知外面方法異步運(yùn)行的結(jié)果抒寂,那CountDownLatch是無(wú)法解決這個(gè)異步方法的單元測(cè)試問(wèn)題的。

將異步變成同步

將異步操作變成同步掠剑,是解決異步代碼測(cè)試問(wèn)題的一種比較直觀的思路蓬推。這種思路往往比較復(fù)雜,根據(jù)項(xiàng)目的實(shí)際情況來(lái)抉擇澡腾,大致的思想就是將異步操作轉(zhuǎn)換到自己事先準(zhǔn)備好的同步線程池來(lái)執(zhí)行沸伏。

(1)、通過(guò)Executor或ExecutorService方式
如果你的代碼是通過(guò)Executor或ExecutorService來(lái)做異步的动分,那在測(cè)試中把異步變成同步的做法毅糟,跟在測(cè)試中使用mock對(duì)象的方法是一樣的,那就是使用依賴注入澜公。在測(cè)試代碼里面姆另,將同步的Executor注入進(jìn)去。創(chuàng)建同步的Executor對(duì)象很簡(jiǎn)單坟乾,以下就是一個(gè)同步的Executor:

Executor immediateExecutor = new Executor() {
    @Override
    public void execute(Runnable command) {
        command.run();
    }
};

(2)迹辐、通過(guò)New Thread()方式
如果你在代碼里面直接通過(guò)new Thread()的方式來(lái)做異步,這種方式比較簡(jiǎn)單粗暴甚侣,估計(jì)你在coding時(shí)很爽明吩。但是不幸的告訴你,這樣的代碼是沒(méi)有辦法變成同步的殷费。那么要做單元測(cè)試的話印荔,就需要換成Executor這種方式來(lái)做異步操作。還是結(jié)合上面的例子详羡,我們來(lái)實(shí)踐一下仍律,修改之后的AyncModel類如下:

public class AyncModel {

    private Handler mUiHandler = new Handler(Looper.getMainLooper());

    private Executor executor;

    public AyncModel(Executor executor) {
        this.executor = executor;
    }

    public void loadAync(final Callback callback) {
        if (executor == null) {
            executor = Executors.newCachedThreadPool();
        }
        executor.execute(new Runnable() {

            @Override
            public void run() {
                final List<String> repos = new ArrayList<>();
                repos.add("test String");
                mUiHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onSuccess(repos);
                    }
                });
            }
        });
    }

    interface Callback {

        void onSuccess(List<String> results);

        void onFailure(int code, String msg);
    }
}

接著我們看一下修改之后的測(cè)試Case:

public class AyncModelTest extends BaseRoboTestCase {

    @Test
    public void loadAync() throws Exception {
        // Executor
        Executor immediateExecutor = new Executor() {
            @Override
            public void execute(Runnable command) {
                command.run();
            }
        };
        AyncModel model = new AyncModel(immediateExecutor);
        final List<String> result = new ArrayList<>();
        model.loadAync(new AyncModel.Callback() {
            @Override
            public void onSuccess(List<String> list) {
                result.addAll(list);
            }

            @Override
            public void onFailure(int code, String msg) {
                fail();
            }
        });
        // 此處有坑,如果不加這行代碼实柠,就會(huì)出現(xiàn)Handler沒(méi)有執(zhí)行Runnable的問(wèn)題
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
        assertEquals(1, result.size());
    }

}

不知你有沒(méi)有感覺(jué)到水泉,使用Executor方式之后,不管是源代碼還是測(cè)試代碼看起來(lái)都很清爽!

(3)草则、使用AsyncTask
Android提供AsyncTask類钢拧,很方便我們進(jìn)行異步操作,初學(xué)Android時(shí)畔师,很喜歡這種方式娶靡。進(jìn)行單元測(cè)試時(shí)牧牢,建議使用 AsyncTask.executeOnExecutor()看锉,而不是直接使用AsyncTask.execute(),通過(guò)依賴注入的方式塔鳍,在測(cè)試環(huán)境下將同步的Executor傳進(jìn)去進(jìn)去伯铣。

(4)、使用RxJava
這個(gè)是不得不提的一種方法轮纫,鑒于強(qiáng)大的線程切換功能腔寡,越來(lái)越多的人使用RxJava來(lái)做異步操作,RxJava代碼的單元測(cè)試也是經(jīng)常被問(wèn)到的一個(gè)問(wèn)題掌唾。不管你是否用到RxJava放前,反正我現(xiàn)在的項(xiàng)目就用到了。至于如何將異步操作切換到同步執(zhí)行糯彬,之前已經(jīng)詳細(xì)講到了凭语,可以回到上面再看看。

如何Mock網(wǎng)絡(luò)數(shù)據(jù)

當(dāng)我們要對(duì)Presenter或者測(cè)試UI撩扒,考慮到根據(jù)網(wǎng)絡(luò)返回的數(shù)據(jù)覆蓋所有的分支情況似扔,對(duì)于一個(gè)賬號(hào)在某一時(shí)刻,后端只會(huì)返回一種數(shù)據(jù)結(jié)果搓谆,這樣就限制了做其他情況的單元驗(yàn)證炒辉。所以這個(gè)時(shí)候就需要我們Mock數(shù)據(jù)來(lái)模擬。鑒于項(xiàng)目中使用OKHTTP框架泉手,只要自定義一個(gè)Interceptor黔寇,在這里進(jìn)行攔截并Mock你想要的數(shù)據(jù),相對(duì)來(lái)說(shuō)這種方式比較友好斩萌。

OkHttpMockInterceptor類如下:

public class OkHttpMockInterceptor implements Interceptor {

    public OkHttpMockInterceptor() {
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        Response response = null;
        HttpUrl url = chain.request().url();
        String sym = "";
        String query = url.encodedQuery() == null ? "" : url.encodedQuery();
        if (!query.equals("")) {
            sym = "?";
        }
        String assetPath = url.encodedPath() + sym + query;
        if (JsonStringHelper.isPathExist(assetPath)) {
            response = mock(chain, assetPath);
        }
        if (response == null) {
            response = chain.proceed(chain.request());
        }
        return response;

    }

    private Response mock(Chain chain, String assetPath) {
        if (assetPath == null || "".equals(assetPath)) {
            return null;
        }
        String jsonResult = JsonStringHelper.getMockJsonString(assetPath);
        HttpResponse httpResponse = (HttpResponse) GsonHelper.fromJson(jsonResult, HttpResponse.class);
        return new Response.Builder()
                .code(Integer.valueOf(httpResponse.code))
                .message(httpResponse.msg)
                .request(chain.request())
                .protocol(Protocol.HTTP_1_0)
                .body(ResponseBody.create(MediaType.parse("application/json"), jsonResult))
                .addHeader("content-type", "application/json")
                .build();
    }

}

涉及到的其它類啡氢,不是本博客的重點(diǎn),就不一一列舉了术裸。如果項(xiàng)目中不是使用OKHTTP網(wǎng)絡(luò)框架倘是,而是其他的網(wǎng)絡(luò)框架如Volley、android-async-http等袭艺,還沒(méi)來(lái)得及去探索搀崭,感興趣的同學(xué)自己可以深入探索一下。

具體例子

說(shuō)了這么多,所謂實(shí)踐是檢驗(yàn)真理的唯一標(biāo)準(zhǔn)瘤睹!下面我們針對(duì)具體的例子來(lái)實(shí)踐一把升敲,項(xiàng)目中的onUpdateOrders(OneClickOrderResult result)方法如下:

    public void onUpdateOrders(OneClickOrderResult result) {
        if (result == null || result.orderSource == null || !result.orderSource.equals(orderSource)) {
            return;
        }
        handleUpdateOrders(result);
        if (result.code == 200) {
            if (result.data == null) {
                if (curPage == ORDER_PAGE_INIT) {
                    // case1
                    iView.refreshNewestOrders(null);
                } else if (curPage > ORDER_PAGE_INIT) {
                    // case2
                    iView.refreshMoreOrders(null);
                }
                return;
            }
            // 只展示當(dāng)前要加載的頁(yè)碼的數(shù)據(jù),其他的過(guò)濾掉
            if (curPage != result.data.getCurrPage() && result.data.getCurrPage() > 0) {
                return;
            }
            pageCount = result.data.getPageCount();
            if (curPage == ORDER_PAGE_INIT) {
                // case3
                iView.refreshNewestOrders(filterHistoryOrders(result.data.getOrderList()));
            } else if (curPage > ORDER_PAGE_INIT) {
                // case4
                iView.refreshMoreOrders(filterHistoryOrders(result.data.getOrderList()));
            }
            return;
        }
        if (result.code == OneClickFragment.ERROR_ID_MEITUAN_VISIT_OUT_OF_LIMIT && iView.isFragmentVisible()) {
            // case5
            iView.showInputCaptchaDialog();
            return;
        }
        // case6
        iView.refreshError(result.code, curPage > ORDER_PAGE_INIT);
    }

onUpdateOrders()方法是一個(gè)沒(méi)有返回值的公有方法轰传,那么我們?cè)撊绾蜗率致康常渴紫纫蕾嚾雲(yún)?code>OneClickOrderResult,根據(jù)result狀態(tài)來(lái)執(zhí)行邏輯获茬,其次依賴iView對(duì)象港庄。因此,在進(jìn)行單元測(cè)試時(shí)恕曲,通過(guò)mock的方式可以解決這兩個(gè)數(shù)據(jù)對(duì)象的依賴關(guān)系鹏氧,mock出OneClickOrderResultiView后,其他的就迎刃而解了佩谣。分析代碼把还,可以分為6個(gè)單元測(cè)試Case,如上面的注釋茸俭,覆蓋了onUpdateOrders()方法所有的分支吊履。測(cè)試方法如下:

public class OneClickBasePresenterTest extends BaseModelTest {

    @Captor
    private ArgumentCaptor<ArrayList<OneClickOrder.OneClickOrderItem>> captorItems;

    @Test
    public void onUpdateOrdersCase1() throws Exception {
        // mock出IView對(duì)象,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        // 根據(jù)Case自己創(chuàng)建數(shù)據(jù)依賴
        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        orderResult.code = 200;
        orderResult.data = null;

        presenter.curPage = OneClickBasePresenter.ORDER_PAGE_INIT;
        // 調(diào)用被測(cè)方法
        presenter.onUpdateOrders(orderResult);
        // 借助Mockito工具调鬓,驗(yàn)證refreshNewestOrders方法是否被調(diào)用
        Mockito.verify(mockView).refreshNewestOrders(null);
    }

    @Test
    public void onUpdateOrdersCase2() throws Exception {
        // mock出IView對(duì)象艇炎,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        orderResult.code = 200;
        orderResult.data = null;

        presenter.curPage = OneClickBasePresenter.ORDER_PAGE_INIT + 1;
        // 調(diào)用被測(cè)方法
        presenter.onUpdateOrders(orderResult);
        // 借助Mockito工具,驗(yàn)證refreshMoreOrders方法是否被調(diào)用
        Mockito.verify(mockView).refreshMoreOrders(null);
    }

    @Test
    public void onUpdateOrdersCase3() throws Exception {
        // mock出IView對(duì)象袖迎,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        // mock數(shù)據(jù)依賴OneClickOrderResult
        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        presenter.curPage = OneClickBasePresenter.ORDER_PAGE_INIT;
        orderResult.code = 200;

        // mock數(shù)據(jù)依賴OneClickOrder
        OneClickOrder data = Mockito.mock(OneClickOrder.class);
        Mockito.when(data.getCurrPage()).thenReturn(presenter.curPage);
        Parcel in = Mockito.mock(Parcel.class);
        Mockito.when(in.readString()).thenReturn("1001");
        Mockito.when(in.readInt()).thenReturn(1001);
        OneClickOrder.OneClickOrderItem item = new OneClickOrder.OneClickOrderItem(in);
        ArrayList<OneClickOrder.OneClickOrderItem> items = new ArrayList<>();
        items.add(item);
        items.add(item);
        Mockito.when(data.getOrderList()).thenReturn(items);
        orderResult.data = data;

        presenter.onUpdateOrders(orderResult);
        // 通過(guò)ArgumentCaptor來(lái)捕獲refreshNewestOrders方法被調(diào)用時(shí)的入?yún)?        Mockito.verify(mockView).refreshNewestOrders(captorItems.capture());
        // 通過(guò)Assert斷言判斷ArgumentCaptor捕獲的入?yún)⒑蚷tems數(shù)據(jù)是否相等
        Assert.assertEquals(captorItems.getValue().size(), items.size());
    }

    @Test
    public void onUpdateOrdersCase4() throws Exception {
        // mock出IView對(duì)象冕臭,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        // mock數(shù)據(jù)依賴OneClickOrderResult
        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        orderResult.code = 200;
        presenter.curPage = OneClickBasePresenter.ORDER_PAGE_INIT + 1;

        // mock數(shù)據(jù)依賴OneClickOrder
        OneClickOrder data = Mockito.mock(OneClickOrder.class);
        Mockito.when(data.getCurrPage()).thenReturn(presenter.curPage);
        Parcel in = Mockito.mock(Parcel.class);
        Mockito.when(in.readString()).thenReturn("1001");
        Mockito.when(in.readInt()).thenReturn(1001);
        OneClickOrder.OneClickOrderItem item = new OneClickOrder.OneClickOrderItem(in);
        ArrayList<OneClickOrder.OneClickOrderItem> items = new ArrayList<>();
        items.add(item);
        items.add(item);
        Mockito.when(data.getOrderList()).thenReturn(items);
        orderResult.data = data;

        // 調(diào)用被測(cè)方法
        presenter.onUpdateOrders(orderResult);
        // 通過(guò)ArgumentCaptor來(lái)捕獲refreshMoreOrders方法被調(diào)用時(shí)的入?yún)?        Mockito.verify(mockView).refreshMoreOrders(captorItems.capture());
        Assert.assertEquals(captorItems.getValue().size(), items.size());
    }

    @Test
    public void onUpdateOrdersCase5() throws Exception {
        // mock出IView對(duì)象,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        orderResult.code = 200;

        orderResult.code = OneClickFragment.ERROR_ID_MEITUAN_VISIT_OUT_OF_LIMIT;
        // 通過(guò)mock方式隔離依賴燕锥,mockView.isFragmentVisible()返回true
        Mockito.when(mockView.isFragmentVisible()).thenReturn(true);
        // 調(diào)用被測(cè)方法
        presenter.onUpdateOrders(orderResult);
        // 借助Mockito工具辜贵,驗(yàn)證showInputCaptchaDialog方法是否被調(diào)用
        Mockito.verify(mockView).showInputCaptchaDialog();
    }

    @Test
    public void onUpdateOrdersCase6() throws Exception {
        // mock出IView對(duì)象,通過(guò)mock隔離外部依賴
        OneClickContract.IView mockView = Mockito.mock(OneClickContract.IView.class);
        // 創(chuàng)建目標(biāo)類
        OneClickPresenter presenter = new OneClickPresenter(mockView, OneClickOrder.ORDER_SOURCE_BAIDU);

        OneClickOrderResult orderResult = new OneClickOrderResult();
        orderResult.orderSource = OneClickOrder.ORDER_SOURCE_BAIDU;
        orderResult.code = 500;
        
        // 調(diào)用被測(cè)方法
        presenter.onUpdateOrders(orderResult);
        // 借助Mockito工具归形,驗(yàn)證refreshError方法是否被調(diào)用
        Mockito.verify(mockView).refreshError(orderResult.code, presenter.isLoadMoreOrders());
    }

}

最后

本博客主要圍繞的是Android單元測(cè)試中的邏輯測(cè)試托慨,自己對(duì)單元測(cè)試的理解,并結(jié)合實(shí)際代碼講解暇榴。如有不當(dāng)之處厚棵,歡迎指正!下一篇博客將圍繞Android單元測(cè)試的UI測(cè)試蔼紧。最后婆硬,非常感謝您對(duì)本篇博客的關(guān)注!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末奸例,一起剝皮案震驚了整個(gè)濱河市彬犯,隨后出現(xiàn)的幾起案子向楼,更是在濱河造成了極大的恐慌,老刑警劉巖谐区,帶你破解...
    沈念sama閱讀 211,290評(píng)論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件湖蜕,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡宋列,警方通過(guò)查閱死者的電腦和手機(jī)昭抒,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,107評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)炼杖,“玉大人灭返,你說(shuō)我怎么就攤上這事∴诮校” “怎么了婆殿?”我有些...
    開(kāi)封第一講書人閱讀 156,872評(píng)論 0 347
  • 文/不壞的土叔 我叫張陵诈乒,是天一觀的道長(zhǎng)罩扇。 經(jīng)常有香客問(wèn)我,道長(zhǎng)怕磨,這世上最難降的妖魔是什么喂饥? 我笑而不...
    開(kāi)封第一講書人閱讀 56,415評(píng)論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮肠鲫,結(jié)果婚禮上员帮,老公的妹妹穿的比我還像新娘。我一直安慰自己导饲,他們只是感情好捞高,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,453評(píng)論 6 385
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著渣锦,像睡著了一般硝岗。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上袋毙,一...
    開(kāi)封第一講書人閱讀 49,784評(píng)論 1 290
  • 那天型檀,我揣著相機(jī)與錄音,去河邊找鬼听盖。 笑死胀溺,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的皆看。 我是一名探鬼主播仓坞,決...
    沈念sama閱讀 38,927評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼腰吟!你這毒婦竟也來(lái)了无埃?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 37,691評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎录语,沒(méi)想到半個(gè)月后倍啥,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,137評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡澎埠,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,472評(píng)論 2 326
  • 正文 我和宋清朗相戀三年虽缕,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蒲稳。...
    茶點(diǎn)故事閱讀 38,622評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡氮趋,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出江耀,到底是詐尸還是另有隱情剩胁,我是刑警寧澤,帶...
    沈念sama閱讀 34,289評(píng)論 4 329
  • 正文 年R本政府宣布祥国,位于F島的核電站昵观,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏舌稀。R本人自食惡果不足惜啊犬,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,887評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望壁查。 院中可真熱鬧觉至,春花似錦、人聲如沸睡腿。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 30,741評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)席怪。三九已至应闯,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間何恶,已是汗流浹背孽锥。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 31,977評(píng)論 1 265
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留细层,地道東北人惜辑。 一個(gè)月前我還...
    沈念sama閱讀 46,316評(píng)論 2 360
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像疫赎,于是被迫代替她去往敵國(guó)和親盛撑。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,490評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容