(MVP+RxJava+Retrofit)解耦+Mockito單元測試 經(jīng)驗分享

前言

首先祥诽,對于MVP枫吧、RxJava還不了解的同學宴倍,請先閱讀這幾篇文章:

了解 Retrofit痹束、okHttp检疫,直接看Squre官網(wǎng)

之所以說解耦,很大程度是 MVP参袱、Rxjava电谣、Retrofitjava工程 就能使用,本身不依賴Android SDK抹蚀。這一點對Android單元測試至關(guān)重要剿牺。

MVP & RxJava在2015年已經(jīng)很火了,加上2016年發(fā)布正式版的 OkHttp3.0 & Retrofit2.0 火上澆油环壤,全世界簡直炸開了鍋晒来,Android開發(fā)有了質(zhì)的飛躍(代碼層面)。

國內(nèi)Android開發(fā)者逐漸成熟郑现,翻墻越來越方便湃崩,國外的技術(shù)在國內(nèi)使用順理成章荧降。目前,國內(nèi)狀況是攒读,Android開發(fā)者不缺朵诫,缺的是大量Android中級開發(fā)者。因此薄扁,學會使用MVP剪返、RxJava、Retrofit邓梅、Mockito單元測試勢在必行脱盲。

逆水行走,不進則退日缨。


請求User數(shù)據(jù)钱反,并在顯示

User bean:

public class User {
    public int    uid;
    public String name;
}

UserView,網(wǎng)絡加載完User數(shù)據(jù)匣距,回調(diào)onUserLoaded(user)

public interface UserView {
    void onUserLoaded(User user);
}

UserService面哥,Retrofit代理的請求接口:

public interface UserService {

    @GET("user/{uid}.json")
    Observable<User> loadUser(@Path("uid") int uid);
}

View (Activity)交互的UserPresenter接口、以及實現(xiàn)UserPresenterImpl

public interface UserPresenter {

    void loadUser(int uid);
}
public class UserPresenterImpl implements UserPresenter {

    UserService userService;
    UserView    userView;

    public UserPresenterImpl(UserView userView) {
        this.userView = userView;

        userService = new Retrofit.Builder().baseUrl("http://**.com/")
                                            .addConverterFactory(GsonConverterFactory.create())
                                            .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                                            .build()
                                            .create(UserService.class);
    }

    @Override
    public void loadUser(int uid) {
        // 異步網(wǎng)絡請求User數(shù)據(jù)墨礁,并在onNext(user)返回
        userService.loadUser(uid)
                   .subscribeOn(Schedulers.io())
                   .observeOn(AndroidSchedulers.mainThread())
                   .subscribe(new Subscriber<User>() {

                       @Override
                       public void onNext(User user) {
                           userView.onUserLoaded(user);
                       }
                       ......
                   });
    }
}

MainActivity

public class MainActivity extends Activity implements UserView {

    UserPresenter userPresenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        userPresenter = new UserPresenterImpl(this);
    }

    @Override
    public void onUserLoaded(User user) {
        textView.setText(user.toString());
    }
}

為何解耦幢竹?

前言中提及耳峦,RxJavaRetrofit是不依賴Android SDK的獨立的第三方庫恩静,MVP模式通過接口編程,把依賴Android SDKView層(Activity)Presenter蹲坷、Model隔離驶乾。這里說的Model是指retrofit和代理的Service網(wǎng)絡請求接口,對于Model層依賴與Android SDKDAO循签、sqlite级乐,之后會討論。

當P層县匠、網(wǎng)絡M層不依賴Android SDK风科,我們就可以用JUnit寫單元測試,并直接運行在JVM上了乞旦。


JUnit4+Mockito單元測試

很多Android開發(fā)的同學贼穆,不了解單元測試。對于不是測試專業(yè)出身兰粉、又沒技術(shù)大牛調(diào)教過的程序猿故痊,缺乏單元測試知識,比比皆是玖姑。要了解單元測試愕秫,推薦閱讀:

美團點評技術(shù)團隊《Android單元測試研究與實踐》
鄒小創(chuàng) 《Android單元測試(二):再來談談為什么》

老實說慨菱,我也是2016年初,才真正接觸單元測試戴甩。2016年3月才正式對項目寫單元測試符喝。寫了一個多月,越來越意識到單元測試的重要性甜孤。單元測試達到的目的洲劣,總結(jié)成兩點:

  • 快速開發(fā)
  • 提高代碼質(zhì)量

你沒看錯,確實是“快速”课蔬!

對于需求“請求User囱稽,并顯示”,噼里啪啦寫完Presenter二跋、Service战惊、Activity,需要 編譯扎即、運行真機or模擬器才能debug吞获,如果寫錯了,修改代碼后谚鄙,還要編譯各拷、運行在真機....還寫錯,修改闷营、編譯烤黍、運行.... 小型項目怎么也要40s~1分鐘吧,還要花幾分鐘時間手動操作界面...用Android Sutdio debug或Log.....這個過程太漫長了!!

如果你學會寫Junit單元測試傻盟,可以直接對單個Presenter速蕊、Service編譯運行,不需要關(guān)心是否受到其他類的代碼or網(wǎng)絡環(huán)境娘赴、服務器是否正常的影響规哲。運行一下就幾秒鐘,JunitMockito的錯誤提示诽表,還讓你快速定位問題唉锌。

瞎逼逼了那么久释移,該上代碼眉踱。

Presenter單元測試

打開UserPresenter,對著類名 右鍵 -> Go To -> Test

Image 2.png

創(chuàng)建OK之后冷蚂,你會得到UserPresenterTest.java

Image 6.png

public class UserPresenterTest {

    @Before
    public void setUp() throws Exception {
    }

    @Test
    public void testLoadUser() throws Exception {
    }
}

要對UserPresenterImpl進行單元測試议双,還需要做一點點改進:

public class UserPresenterImpl implements UserPresenter {

    UserService userService;
    UserView    userView;

    // 讓外部傳入UserService & UserView
    public UserPresenterImpl(UserService userService, UserView userView) {
        this.userService = userService;
        this.userView = userView;
    }
    ...
}
import static org.mockito.Mockito.mock;

public class UserPresenterTest {

    UserPresenter userPresenter;
    UserView      userView;
    UserService   userService;

    @Before
    public void setUp() throws Exception {
        RxUnitTestTools.openRxTools();
    
        // 生成mock對象
        userView = mock(UserView.class); 
        userService = mock(UserService.class);

        userPresenter = new UserPresenterImpl(userService, userView);
    }
}

注意痘番,這里import static org.mockito.Mockito.mock,靜態(tài)引用org.mockito.Mockitomock()靜態(tài)方法。我們不用自己敲這句import汞舱,通過代碼補全提示就可以自動生成了伍纫,如圖:

Image 7.png

這里有行RxUnitTestTools.openRxTools()到底是什么?

public class RxUnitTestTools {
    private static boolean isInitRxTools = false;

    /**
     * 把異步變成同步昂芜,方便測試
     */
    public static void openRxTools() {
        if (isInitRxTools) {
            return;
        }
        isInitRxTools = true;

        RxAndroidSchedulersHook rxAndroidSchedulersHook = new RxAndroidSchedulersHook() {
            @Override
            public Scheduler getMainThreadScheduler() {
                return Schedulers.immediate();
            }
        };

        RxJavaSchedulersHook rxJavaSchedulersHook = new RxJavaSchedulersHook() {
            @Override
            public Scheduler getIOScheduler() {
                return Schedulers.immediate();
            }
        };

        // reset()不是必要莹规,實踐中發(fā)現(xiàn)不寫reset(),偶爾會出錯泌神,所以寫上保險^_^
        RxAndroidPlugins.getInstance().reset();
        RxAndroidPlugins.getInstance().registerSchedulersHook(rxAndroidSchedulersHook);
        RxJavaPlugins.getInstance().reset();
        RxJavaPlugins.getInstance().registerSchedulersHook(rxJavaSchedulersHook);
    }
}

這個類是讓RxJava&RxAndroidSchedulers.io()AndroidSchedulers.mainThread()轉(zhuǎn)換成Schedulers.immediate()良漱,從而讓Obserable從異步變同步。

然后欢际,寫testLoadUser()

    @Test
    public void testLoadUser() throws Exception {
        User user = new User();
        user.uid = 1;
        user.name = "kkmike999";

        when(userService.loadUser(anyInt())).thenReturn(Observable.just(user));

        userPresenter.loadUser(1);

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);

        verify(userService).loadUser(1);
        verify(userView).onUserLoaded(captor.capture());

        User result = captor.getValue(); // 捕獲的User

        Assert.assertEquals(result.uid, 1); 
        Assert.assertEquals(result.name, "kkmike999");
    }

讓我解析一下:

when...thenReturn...

when(userService.loadUser(anyInt())).thenReturn(Observable.just(user));

當調(diào)用userService.loadUser(...)母市,參數(shù)為任意int,返回Observable.just(user)對象损趋。

verify

verify(userService).loadUser(1);患久,驗證 userService.loadUser(...)是否被調(diào)用,并校驗傳入?yún)?shù)uid==1浑槽。

這一步很重要蒋失,這個loadUser(uid)參數(shù)比較少,當方法參數(shù)多時(例如loadXXX(int,int,int,int...String,String....))桐玻,特別容易搞錯篙挽。當后端接口修改了,service相應也要修改镊靴,這時多參數(shù)的方法很容易出問題铣卡。

verify(userView).onUserLoaded(captor.capture());,驗證userView.onUserLoaded(...)是否被調(diào)用邑闲,并捕獲傳入的user參數(shù)

ArgumentCaptor

顧名思義參數(shù)捕獲器算行,就是捕獲傳入?yún)?shù)。當userService.loadUser()執(zhí)行完并返回Observable<User>苫耸,在onNext(user)回調(diào)User傳給userView.onUserLoaded(...),但我們不確定回調(diào)的user是否正確儡陨。因此我們需要捕獲user參數(shù)褪子,并校驗其正確性。

如果參數(shù)是List<T>類型骗村,ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class)即可嫌褪,不需要寫List泛型參數(shù)。

assertEquals

這個不用說了吧.....


Service(Model層)單元測試

測試這一層的目的胚股,是驗證從服務器返回的數(shù)據(jù)笼痛,是否解析成正確的對象。單元測試時,應該模擬服務器返回json數(shù)據(jù)缨伊。由于UserServiceRetrofit代理過摘刑,所以單元測試需要一點技巧。

寫一個MockRetrofitHelper

public class MockRetrofitHelper {

    public <T> T create(Class<T> clazz) {
        OkHttpClient client = new OkHttpClient.Builder()
                                              .addInterceptor(new MockInterceptor())
                                              .build();
                                                       
        Retrofit retrofit = new Retrofit.Builder().baseUrl("http://api.***.com")
                                                  .client(client)
                                                  .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                                                  .addConverterFactory(GsonConverterFactory.create())
                                                  .build();

        return retrofit.create(clazz);
    }

    private String path;

    public void setPath(String path) {
        this.path = path;
    }

    private class MockInterceptor implements Interceptor{

        @Override
        public Response intercept(Chain chain) throws IOException {
            // 模擬網(wǎng)絡數(shù)據(jù)
            String content = AssestsReader.readFile(path);

            ResponseBody body = ResponseBody.create(MediaType.parse("application/x-www-form-urlencoded"), content);

            Response response = new Response.Builder().request(chain.request())
                                                      .protocol(Protocol.HTTP_1_1)
                                                      .code(200)
                                                      .body(body)
                                                      .build();
            return response;
        }
    }
}

解釋一下刻坊,MockInterceptor的職責枷恕,讀取本地數(shù)據(jù),并直接返回谭胚。因此徐块,OkHttpClient并沒有真正請求網(wǎng)絡數(shù)據(jù),而是用了本地數(shù)據(jù)灾而。

OkHttp Interceptor不熟悉的同學胡控,參考:

學習OkHttp --Interceptors

然后,寫UserServiceTest單元測試:

public class UserServiceTest {

    UserService        userService;
    MockRetrofitHelper retrofit;

    @Before
    public void setUp() throws Exception {
        retrofit = new MockRetrofitHelper();

        userService = retrofit.create(UserService.class);
    }

    @Test
    public void testLoadUser() throws Exception {
        retrofit.setPath(".../User.json");

        TestSubscriber<User> testSubscriber = new TestSubscriber<>();

        userService.loadUser(1)
                   .toBlocking()
                   .subscribe(testSubscriber);

        User user = testSubscriber.getOnNextEvents()
                                  .get(0);

        Assert.assertEquals(user.uid, 1);
        Assert.assertEquals(user.name, "kkmike999");
    }
}

Observalbe<User>調(diào)用subscribe(...)時旁趟,TestSubscriber 會捕獲onNext(user)參數(shù)铜犬,并放進List<User>事件隊列。我們通過testSubscriber.getOnNextEvents()獲取事件隊列轻庆,從這個隊列獲取User癣猾,并驗證正確性。

不用TestSubscriber也可以這樣:

User user =  userService.loadUser(1)
                        .toBlocking()
                        .first();

小結(jié)

文章已經(jīng)到尾聲余爆。對于mockito纷宇、retrofit、okhttp intercepor熟悉的你蛾方,本文并沒有太多難點像捶。

為新功能寫代碼時,應該先寫Presenter或者Service桩砰,不急著運行拓春,再寫PresenterTestServiceTest,在JVM上驗證代碼是否正確亚隅。寫完單元測試后硼莽,再讓Activity調(diào)用Presenter

Activity煮纵、Service單元測試感興趣的同學懂鸵,不妨了解Robolectric(發(fā)音比較坑爹,重音在l而不是R)行疏。它可以讓你在JVM運行Activity單元測試匆光,比真機調(diào)試快多了。

各位同學酿联,千萬不要覺得 很麻煩终息、項目很趕 就不寫單元測試夺巩,這些都是業(yè)界大牛的經(jīng)驗之談,有益無害周崭!當你發(fā)現(xiàn)代碼無法單元測試柳譬,證明代碼本身有問題,應該去改進休傍,而不是放棄單元測試征绎。


實際項目與Retrofit

一個中型的APP項目,可能由幾位工程師一起編寫磨取,對于Retrofit等新技術(shù)并不是每個人都接受人柿。希望《同事拒絕Retrofit,怎么辦忙厌?》對你有幫助凫岖。


關(guān)于作者
我是鍵盤男。
在廣州生活逢净,在創(chuàng)業(yè)公司上班哥放,猥瑣文藝碼農(nóng)。喜歡科學爹土、歷史甥雕,玩玩投資,偶爾獨自旅行胀茵。希望成為獨當一面的工程師社露。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市琼娘,隨后出現(xiàn)的幾起案子峭弟,更是在濱河造成了極大的恐慌,老刑警劉巖脱拼,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件瞒瘸,死亡現(xiàn)場離奇詭異,居然都是意外死亡熄浓,警方通過查閱死者的電腦和手機情臭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來玉组,“玉大人谎柄,你說我怎么就攤上這事」喏ǎ” “怎么了?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵鸿摇,是天一觀的道長石景。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么潮孽? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任揪荣,我火速辦了婚禮,結(jié)果婚禮上往史,老公的妹妹穿的比我還像新娘仗颈。我一直安慰自己,他們只是感情好椎例,可當我...
    茶點故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布挨决。 她就那樣靜靜地躺著,像睡著了一般订歪。 火紅的嫁衣襯著肌膚如雪脖祈。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天刷晋,我揣著相機與錄音盖高,去河邊找鬼。 笑死眼虱,一個胖子當著我的面吹牛喻奥,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播捏悬,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼撞蚕,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了邮破?” 一聲冷哼從身側(cè)響起诈豌,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎抒和,沒想到半個月后矫渔,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡摧莽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年庙洼,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片镊辕。...
    茶點故事閱讀 38,094評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡油够,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出征懈,到底是詐尸還是另有隱情石咬,我是刑警寧澤,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布卖哎,位于F島的核電站鬼悠,受9級特大地震影響删性,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜焕窝,卻給世界環(huán)境...
    茶點故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一蹬挺、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧它掂,春花似錦巴帮、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至熟妓,卻和暖如春雪猪,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背起愈。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工只恨, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人抬虽。 一個月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓官觅,卻偏偏與公主長得像,于是被迫代替她去往敵國和親阐污。 傳聞我的和親對象是個殘疾皇子休涤,可洞房花燭夜當晚...
    茶點故事閱讀 42,828評論 2 345

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