用PowerMock進(jìn)行Android單元測(cè)試與BDD行為驅(qū)動(dòng)開發(fā)

很久之前就有聽說(shuō)過(guò)mockito和PowerMock的大名了,無(wú)奈我司寫單元測(cè)試的風(fēng)氣不濃,加上一直以來(lái)業(yè)務(wù)繁忙,惰性使我一直沒(méi)有寫單元測(cè)試的習(xí)慣斋荞。

正好現(xiàn)在手頭上的是一個(gè)全新的項(xiàng)目,可以在初期有時(shí)間也有沖動(dòng)將各種需要的東西都用上荞雏。于是這幾天就好好學(xué)習(xí)了一番,感覺PowerMock的確是無(wú)比強(qiáng)大。

什么是mock

維基百科上是這么寫的:

在面向?qū)ο蟪绦蛟O(shè)計(jì)中,模擬對(duì)象(英語(yǔ):mock object凤优,也譯作模仿對(duì)象)是以可控的方式模擬真實(shí)對(duì)象行為的假的對(duì)象悦陋。程序員通常創(chuàng)造模擬對(duì)象來(lái)測(cè)試其他對(duì)象的行為,很類似汽車設(shè)計(jì)者使用碰撞測(cè)試假人來(lái)模擬車輛碰撞中人的動(dòng)態(tài)行為筑辨。
在單元測(cè)試中俺驶,模擬對(duì)象可以模擬復(fù)雜的、真實(shí)的(非模擬)對(duì)象的行為挖垛, 如果真實(shí)的對(duì)象無(wú)法放入單元測(cè)試中痒钝,使用模擬對(duì)象就很有幫助秉颗。

如果我們使用依賴注入的方式編寫代碼,例如Context通常都是外部傳入的:

class ClassA{
    public static boolean staticFunc(Context context, int arg) {
        ...
    }
}

這個(gè)方法如果不使用mock object的方法,我們很難脫離安卓環(huán)境去編寫單元測(cè)試,因?yàn)镃ontext是系統(tǒng)生成的痢毒。

而使用mock技術(shù)去模擬一個(gè)Context出來(lái),就可以在android studio中編寫并且脫離安卓環(huán)境運(yùn)行單元測(cè)試了。

PowerMock

powermock是一個(gè)流行的java mock框架,通過(guò)它我們可以很方便的實(shí)現(xiàn)模擬對(duì)象蚕甥。它實(shí)際上是繼承并且拓展了EasyMock哪替、Mockito等其他的流行框架。

在android studio上導(dǎo)入powermock框架很簡(jiǎn)單,只需要在build.gradle中添加dependencies就好了:

dependencies {
    ...
    testCompile 'junit:junit:4.12'

    testCompile 'org.powermock:powermock-core:1.6.1'
    testCompile 'org.powermock:powermock-module-junit4:1.6.1'
    testCompile 'org.powermock:powermock-module-junit4-rule:1.6.1'
    testCompile 'org.powermock:powermock-api-mockito:1.6.1'
}

這里有個(gè)坑點(diǎn)菇怀,之前我用的是1.5.6版本的powermock,但是我的junit是4.12版本的,于是在使用@RunWith(PowerMockRunner.class)的時(shí)候會(huì)報(bào)錯(cuò):

org.powermock.reflect.exceptions.FieldNotFoundException: Field 'fTestClass' was not found in class org.junit.internal.runners.MethodValidator.

到stackoverflow上搜索到國(guó)外大神的回答是powermock小于1.6.1的版本在使用junit 4.12的一個(gè)bug,在1.6.1被修復(fù)凭舶。所以要么用junit 4.12 + powermock 1.6.1,要么使用junit 4.11 + powermock 1.5.6.

mock的簡(jiǎn)單用法

先說(shuō)一下最近我拿到的一個(gè)需求。我們的應(yīng)用的按鈕點(diǎn)擊啟動(dòng)其他應(yīng)用的響應(yīng)需要在服務(wù)器上配置爱沟。服務(wù)器上可能配的是包名啟動(dòng)應(yīng)用,也可能是action啟動(dòng)應(yīng)用,還有可能是Uri啟動(dòng)應(yīng)用帅霜。

所以我這樣寫了一個(gè)工具類:

public class AppUtils {
    public static boolean startApp(Context context, StartAppParam param) {
        ...
    }
    
     public static class StartAppParam {
        private String packageName;
        private String activity;
        private String action;
        private String uri;
        private List<String> categorys = new ArrayList<>();
        ...
    }
}

在服務(wù)器上配置一個(gè)json,傳到客戶端解析成StartAppParam,然后調(diào)用AppUtils. startApp方法。這樣就可以實(shí)現(xiàn)這個(gè)需求了呼伸。

我們使用TDD的方式開發(fā)這個(gè)功能身冀。首先考慮只配置Action的方式啟動(dòng):

@Test
    public void testOpenAppByAction() {
        Context context = Mockito.mock(Context.class);
        
        AppUtils.StartAppParam param = Mockito.mock(AppUtils.StartAppParam.class);
        PowerMockito.when(param.getAction()).thenReturn("package");
        
        assertTrue(AppUtils.startApp(context, param));
            
        Mockito.verify(context, Mockito.times(1)).startActivity(Matchers.any(Intent.class));
    }

首先,使用Mockito.mock方法可以創(chuàng)建一個(gè)模擬對(duì)象出來(lái)。我們這里使用模擬的Context就可以直接在android studio中運(yùn)行單元測(cè)試了括享。

同時(shí)param也用mock的方式創(chuàng)建了出來(lái),而且還模擬了它的getAction方法,讓該方法返回"package",表示配置了使用Action去啟動(dòng)應(yīng)用:

AppUtils.StartAppParam param = Mockito.mock(AppUtils.StartAppParam .class);
PowerMockito.when(param.getAction()).thenReturn("package");

然后Mockito.verify方法可以用來(lái)驗(yàn)證調(diào)用了方法的調(diào)用次數(shù),比如這里我們就驗(yàn)證了startActivity被調(diào)用了一次搂根。

mock 方法內(nèi)部創(chuàng)建的對(duì)象

當(dāng)然這個(gè)測(cè)試不充分,因?yàn)槲覀儧](méi)有驗(yàn)證到底是不是通過(guò)Action啟動(dòng)的。也就是說(shuō)我們還需要判斷是不是通過(guò)new Intent(param.getAction())的方式創(chuàng)建了一個(gè)Intent出來(lái)铃辖。

這就用到了PowerMock的一個(gè)很屌的功能了,它不僅可以在外部mock一個(gè)對(duì)象通過(guò)參數(shù)傳給需要測(cè)試的方法,更可以直接mock方法內(nèi)部創(chuàng)建的對(duì)象(比如這里的Intent)!

@RunWith(PowerMockRunner.class)
public class AppUtilsTest {

    @Test
    @PrepareForTest({AppUtils.class})
    public void testOpenAppByAction() throws Exception {
        Intent intent = Mockito.mock(Intent.class);
        PowerMockito.whenNew(Intent.class).withArguments("package").thenReturn(intent);

        Context context = Mockito.mock(Context.class);

        AppUtils.StartAppParam param = Mockito.mock(AppUtils.StartAppParam.class);
        PowerMockito.when(param.getAction()).thenReturn("package");

        assertTrue(AppUtils.startApp(context, param));
        
        Mockito.verify(context, Mockito.times(1)).startActivity(intent);
        Mockito.verify(intent, Mockito.times(0)).setData(Matchers.any(Uri.class));
        Mockito.verify(intent, Mockito.times(0)).addCategory(Matchers.anyString());
        Mockito.verify(intent, Mockito.times(0)).setClassName(Matchers.anyString(), Matchers.anyString());
    }
}

首先需要用@RunWith(PowerMockRunner.class)注解AppUtilsTest類,用@PrepareForTest({AppUtils.class})注解testOpenAppByAction方法,傳入的AppUtils.class表示需要在AppUtils類內(nèi)部實(shí)現(xiàn)mock操作剩愧。

然后mock一個(gè)Intent出來(lái),接著使用下面的方法使得使用new Intent("package")得到的Intent是我們mock出來(lái)的intent,注意這里連傳入的"package"參數(shù)也需要匹配才能得到我們mock出來(lái)的intent。否則只能得到null:

PowerMockito.whenNew(Intent.class).withArguments("package").thenReturn(intent);

所以我們?cè)诤竺嬷恍枰?yàn)證startActivity調(diào)用的intent是不是我們mock出來(lái)的對(duì)象,就可以驗(yàn)證是不是通過(guò)Action啟動(dòng)的應(yīng)用了:

Mockito.verify(context, Mockito.times(1)).startActivity(intent);

當(dāng)然,為了保險(xiǎn)我們可以順便確認(rèn)一下Intent的其他方法是不是沒(méi)有被調(diào)用到:

Mockito.verify(intent, Mockito.times(0)).setData(Matchers.any(Uri.class));
Mockito.verify(intent, Mockito.times(0)).addCategory(Matchers.anyString());
Mockito.verify(intent, Mockito.times(0)).setClassName(Matchers.anyString(), Matchers.anyString());

使用BDD的方式編寫單元測(cè)試

BDD (Behavior-driven development,行為驅(qū)動(dòng)開發(fā))通過(guò)用自然語(yǔ)言書寫非程序員可讀的測(cè)試用例擴(kuò)展了測(cè)試驅(qū)動(dòng)開發(fā)方法娇斩。也就是說(shuō)用bdd方式寫的代碼就連不是程序員的人也能看得懂,這種可讀性的重要性就不用我多費(fèi)口舌了吧仁卷。

其實(shí)Mockito的BDD方式的寫法我覺得并不是特別的像自然語(yǔ)言。所以我想用C++的單元測(cè)試框架Catch框架來(lái)舉例:

GIVEN("a enable stub publish server entry") {
    StubPublishServerEntry entry(true);
    entry.Start();

    WHEN("publish service") {
        entry.PublishService(service, on_result, on_success, on_error);

        THEN("publish successfully") {
            REQUIRE(service_entry != nullptr);
            REQUIRE(service_entry->IsPublished());
            REQUIRE(is_on_success);
            REQUIRE_FALSE(is_on_error);
        }
    }
}

這是我之前的半成品項(xiàng)目中的一個(gè)代碼片段犬第。如果將代碼部分去掉,只留下GIVEN锦积、WHEN、THEN三個(gè)宏里面的東西,基本只有是懂英語(yǔ)的人都能看得懂這段代碼想做什么:

GIVEN("a enable stub publish server entry") {
    ...
    WHEN("publish service") {
        ...
        THEN("publish successfully") {
            ...  
        }
    }
}

PowerMock也是支持BDD的(應(yīng)該說(shuō)Mockito是支持BDD的),我們可以將上面寫的測(cè)試用例改成BDD的寫法:

public void testOpenAppByAction() throws Exception {
    Intent intent = Mockito.mock(Intent.class);
    PowerMockito.whenNew(Intent.class).withArguments("package").thenReturn(intent);

    Context context = Mockito.mock(Context.class);

    AppUtils.StartAppParam param = Mockito.mock(AppUtils.StartAppParam .class);

    //given
    BDDMockito.given(param.getAction()).willReturn("package");

    //when
    assertTrue(AppUtils.startApp(context, param));

    //then
    BDDMockito.then(context).should().startActivity(intent);
    BDDMockito.then(intent).should(Mockito.never()).setData(Matchers.any(Uri.class));
    BDDMockito.then(intent).should(Mockito.never()).addCategory(Matchers.anyString());
    BDDMockito.then(intent).should(Mockito.never()).setClassName(Matchers.anyString(), Matchers.anyString());
}

感覺是不是和自然語(yǔ)言還是差別蠻大的,我們改造改造,將一些方法改成通過(guò)import static的方式import:

public void testOpenAppByAction() throws Exception {
    Intent intent = mock(Intent.class);
    whenNew(Intent.class).withArguments("package").thenReturn(intent);

    Context context = mock(Context.class);

    AppUtils.StartAppParam param = mock(AppUtils.StartAppParam .class);

    //given
    given(param.getAction()).willReturn("package");

    //when
    assertTrue(AppUtils.startApp(context, param));

    //then
    then(context).should().startActivity(intent);
    then(intent).should(never()).setData(any(Uri.class));
    then(intent).should(never()).addCategory(anyString());
    then(intent).should(never()).setClassName(anyString(), anyString());
}

這樣是不是好多了瓶殃?讓我們繼續(xù)改造:

public class AppUtilsTest {
    @Mock
    private Intent mIntent;

    @Mock
    private Context mContext;

    @Mock
    private AppUtils.StartAppParam mParam;

    @Before
    public void setUp() throws Exception {
        whenNew(Intent.class).withArguments("package").thenReturn(mIntent);
    }

    @Test
    @PrepareForTest({AppUtils.class})
    public void testOpenAppByAction() {
        given(mParam.getAction()).willReturn("package");

        //when
        assertTrue(AppUtils.startApp(mContext, mParam));

        then(mContext).should().startActivity(mIntent);
        then(mIntent).should(never()).setData(any(Uri.class));
        then(mIntent).should(never()).addCategory(anyString());
        then(mIntent).should(never()).setClassName(anyString(), anyString());
    }
}

因?yàn)镮ntent充包、Context、AppUtils.StartAppParam都是需要在不同測(cè)試用例中經(jīng)常被用到的,我們將它寫成成員變量并且用@Mock實(shí)現(xiàn)自動(dòng)mock,省去Mockito.mock()方法的調(diào)用。

然后將whenNew方法放到由@Before注解的setUp()方法中基矮。

現(xiàn)在看testOpenAppByAction是不是簡(jiǎn)潔多了淆储?只要有一點(diǎn)代碼功底的人都能很容易看明白這個(gè)用例到底是用來(lái)驗(yàn)證什么的。

當(dāng)然,這里的BDD寫法和上面Catch的寫法比起來(lái)在像自然語(yǔ)言方面還是有點(diǎn)差距的家浇。

現(xiàn)在我們已經(jīng)將測(cè)試用例寫出來(lái)了本砰,就可以開始寫代碼讓這個(gè)測(cè)試用例通過(guò)了。像這樣先寫行為測(cè)試用例再寫代碼的開發(fā)方式就叫做BDD钢悲。

mock 靜態(tài)方法

我們下一個(gè)需要實(shí)現(xiàn)的功能是什么呢点额?就實(shí)現(xiàn)通過(guò)包名啟動(dòng)應(yīng)用吧。將設(shè)只配置了包名,但沒(méi)有配置Activity名莺琳。我們就需要先找到這個(gè)應(yīng)用的Launch Activity,然后再去啟動(dòng)應(yīng)用还棱。

所以我們?cè)贏ppUtils中新增了一個(gè)方法,用于從包名獲取Activity名:

public class AppUtils {
    public static boolean startApp(Context context, StartAppParam param) {
        ...
    }
    
    public static String getLaunchActivityByPackage(Context context, String packageName) {
        return null;
    }
}

如果是正常的開發(fā)流程我們需要寫一個(gè)getLaunchActivityByPackage測(cè)試用例,再實(shí)現(xiàn)這個(gè)方法惭等。這里我就省略了這步,讓getLaunchActivityByPackage這個(gè)方法先不實(shí)現(xiàn),直接返回null,測(cè)試的時(shí)候直接mock就好了珍手。

之后我們?cè)偃憇tartAppByPackage的測(cè)試用例:

@Test
public void startAppByPackage() {
    mockStatic(AppUtils.class);

    given(AppUtils.startApp(any(Context.class), any(AppUtils.StartAppParam.class)))
            .willCallRealMethod();
    given(AppUtils.getLaunchActivityByPackage(any(Context.class), anyString()))
            .willReturn("LauncActivity");
    given(mParam.getPackageName()).willReturn("packageName");

    //when
    assertTrue(AppUtils.startApp(mContext, mParam));

    //then
    verifyStatic(); //開啟static方法的驗(yàn)證,需要開啟才能驗(yàn)證AppUtils.getLaunchActivityByPackage是否被調(diào)用
    AppUtils.getLaunchActivityByPackage(any(Context.class), eq("packageName"));
    then(mIntent).should().setClassName(mParam.getPackageName(), "LauncActivity");
    then(mContext).should().startActivity(mIntent);
}

首先我們使用mockStatic去模擬AppUtils,然后配置AppUtils.startApp調(diào)用實(shí)際的方法,而getLaunchActivityByPackage直接返回"LauncActivity"。

在驗(yàn)證getLaunchActivityByPackage是否被調(diào)用的時(shí)候要先調(diào)用verifyStatic()辞做。

之后再用下面的方式驗(yàn)證是不是調(diào)用了AppUtils.getLaunchActivityByPackage并且傳入了"packageName"

AppUtils.getLaunchActivityByPackage(any(Context.class), eq("packageName"));

這里多說(shuō)一點(diǎn),假設(shè)getLaunchActivityByPackage是一個(gè)private的方法,我們可以用下面的方式去mock它:

when(AppUtils.class, "getLaunchActivityByPackage", any(Context.class), anyString())
        .thenReturn("LauncActivity");

完整Demo

其他剩下的測(cè)試用例我就不一個(gè)一個(gè)去講了,基本上通過(guò)之前對(duì)PowerMock用法的介紹大家也應(yīng)該能自己實(shí)現(xiàn)了琳要。

完整的demo代碼可以從這里獲取

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市秤茅,隨后出現(xiàn)的幾起案子稚补,更是在濱河造成了極大的恐慌,老刑警劉巖框喳,帶你破解...
    沈念sama閱讀 217,657評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件课幕,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡帖努,警方通過(guò)查閱死者的電腦和手機(jī)撰豺,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,889評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)拼余,“玉大人污桦,你說(shuō)我怎么就攤上這事〕准啵” “怎么了凡橱?”我有些...
    開封第一講書人閱讀 164,057評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)亭姥。 經(jīng)常有香客問(wèn)我稼钩,道長(zhǎng),這世上最難降的妖魔是什么达罗? 我笑而不...
    開封第一講書人閱讀 58,509評(píng)論 1 293
  • 正文 為了忘掉前任坝撑,我火速辦了婚禮静秆,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘巡李。我一直安慰自己抚笔,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,562評(píng)論 6 392
  • 文/花漫 我一把揭開白布侨拦。 她就那樣靜靜地躺著殊橙,像睡著了一般。 火紅的嫁衣襯著肌膚如雪狱从。 梳的紋絲不亂的頭發(fā)上膨蛮,一...
    開封第一講書人閱讀 51,443評(píng)論 1 302
  • 那天,我揣著相機(jī)與錄音季研,去河邊找鬼敞葛。 笑死,一個(gè)胖子當(dāng)著我的面吹牛训貌,可吹牛的內(nèi)容都是我干的制肮。 我是一名探鬼主播冒窍,決...
    沈念sama閱讀 40,251評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼递沪,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了综液?” 一聲冷哼從身側(cè)響起款慨,我...
    開封第一講書人閱讀 39,129評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎谬莹,沒(méi)想到半個(gè)月后檩奠,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,561評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡附帽,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,779評(píng)論 3 335
  • 正文 我和宋清朗相戀三年埠戳,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蕉扮。...
    茶點(diǎn)故事閱讀 39,902評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡整胃,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出喳钟,到底是詐尸還是另有隱情屁使,我是刑警寧澤,帶...
    沈念sama閱讀 35,621評(píng)論 5 345
  • 正文 年R本政府宣布奔则,位于F島的核電站蛮寂,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏易茬。R本人自食惡果不足惜酬蹋,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,220評(píng)論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧范抓,春花似錦写半、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,838評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至年缎,卻和暖如春悔捶,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背单芜。 一陣腳步聲響...
    開封第一講書人閱讀 32,971評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工蜕该, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人洲鸠。 一個(gè)月前我還...
    沈念sama閱讀 48,025評(píng)論 2 370
  • 正文 我出身青樓堂淡,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親扒腕。 傳聞我的和親對(duì)象是個(gè)殘疾皇子绢淀,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,843評(píng)論 2 354

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