android-MVP架構(gòu)中Presenter的單元測試

一,為什么只對Presenter進(jìn)行單元測試,而不測試Model和View呢?

原因1:

mvp中,全部業(yè)務(wù)邏輯都集中在這個類中,bug的高發(fā)區(qū),只要這塊測試好了,app穩(wěn)定性可以大大提高。

原因2:

在mvp架構(gòu)中model層主要進(jìn)行負(fù)責(zé)存儲、檢索、操縱數(shù)據(jù)(包括網(wǎng)絡(luò)請求),這些并不涉及業(yè)務(wù)邏輯的處理,沒能想到可以怎么測試,如果讀者有什么好建議可以留言給我;而view層主要進(jìn)行ui操作,與用戶進(jìn)行交互,更加適合進(jìn)行UI測試。

二,如何測試Presenter?

總共分為兩個步驟,以welcome功能模塊為例(檢測是否來自其他平臺用戶登錄)

步驟1:

編寫契約類,實(shí)現(xiàn)mvp

契約類:

/**
 * 歡迎模塊 處理其他平臺登錄用戶
 */
public interface WelcomeContract {

    interface View extends BaseView {

        void handleError(String errorMsg);

        void outsideLoginSuccess(LoginBean loginBean);
    }

    interface Presenter extends BasePresenter {

        boolean handleData(Intent data);
    }

    interface Model extends BaseModel {

        void outsideLogin(String jsonData, SubscriberAction subscriberAction);

        void outsideLoginSuccess(LoginBean loginBean);
    }
}

presenter層:

public class WelcomePresenter implements WelcomeContract.Presenter {

    private static final String TAG = "WelcomePresenter";
    WelcomeContract.View mView;
    WelcomeContract.Model mModel;

    public WelcomePresenter(WelcomeContract.View view, WelcomeContract.Model model) {
        this.mView = view;
        this.mModel = model;
    }

    @Override
    public boolean handleData(Intent data) {

        if (data != null) {
            try {
                Uri uri = data.getData();
                if (uri != null) {
                    String json = uri.getQueryParameter("data");
                    JSONObject jsonObject = new JSONObject(json);
                    Logger.t("outsideLogin").e(jsonObject.toString());
                    if (jsonObject != null) {
                        String userId = null;
                        String userType = null;
                        String source = null;
                        try {
                            userId = jsonObject.getString("userId");
                            userType = jsonObject.getString("userType");
                            source = jsonObject.getString("source");
                        } catch (Exception e) {
                            Logger.e(e, TAG);
                        }

                        if (userId == null) {
                            String errorMsg = "userId為空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        if (userType == null) {
                            String errorMsg = "userType為空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        if (source == null) {
                            String errorMsg = "source為空";
                            mView.handleError(errorMsg);
                            return true;
                        }
                        mView.showProgressDialog();
                        //驗(yàn)證沒有問題,請求服務(wù)器獲取登錄數(shù)據(jù)
                        mModel.outsideLogin(json, new SubscriberAction<LoginBean>(mView, loginBean -> {
                            mView.dismissProgressDialog();
                            if (loginBean == null) {
                                mView.handleError("返回?cái)?shù)據(jù)為空");
                            } else {
                                mModel.outsideLoginSuccess(loginBean);
                                mView.outsideLoginSuccess(loginBean);
                            }
                        }, throwable -> {
                            throwable.printStackTrace();
                            if (mView.getVActivity() != null && mView.getVActivity().isFinishing()) {
                                mView.getVActivity().runOnUiThread(() -> {
                                    mView.handleError(throwable.getMessage());
                                    mView.dismissProgressDialog();
                                });
                            }
                        }));


                        return true;
                    } else {
                        mView.handleError("解析json出錯");
                        return false;
                    }
                } else {
                    return false;
                }
            } catch (Exception e) {
                Logger.e(e, TAG);
                mView.handleError("解析登錄數(shù)據(jù)出錯");
                return true;
            }
        }
        return false;
    }
}

model層:

public class WelcomeModel implements WelcomeContract.Model {
    private StudentService mService = StudentRetrofitClient.INSTANCE().getService();

    public WelcomeModel() {

    }

    @Override
    public void outsideLogin(String jsonData, SubscriberAction subscriberAction) {
        StudentRetrofitClient.INSTANCE().toSubscribe(mService.outsideLogin(jsonData), subscriberAction);
    }

    @Override
    public void outsideLoginSuccess(LoginBean loginBean) {
        LoginBiz.saveLoginData(loginBean);
    }

}

view層就不貼了,主要關(guān)注點(diǎn)在presenter層,model層代碼有助于測試中參數(shù)捕抓的理解

步驟2:

編寫針對presenter的測試類
功能寫完后,驗(yàn)證業(yè)務(wù)邏輯是否能處理各種數(shù)據(jù)的輸入。

特別注意:以前完成了功能后就一直等后臺接口數(shù)據(jù),接口調(diào)通了,心里才踏實(shí);而現(xiàn)在,我不需要等后臺接口,直接就能驗(yàn)證presenter的業(yè)務(wù)邏輯寫得好不好,能不能處理各種突發(fā)意外情況,這是單元測試的一大好處。單元測試給我最大的感受:一個字:穩(wěn) ,兩個字:踏實(shí) ,具體一點(diǎn)來說:對自己寫的代碼不會膽戰(zhàn)心驚,不會害怕功能上線了驚呼:我擦,這什么情況?我寫的時候完全就沒想到會有這種情況發(fā)生的!寫單元測試其實(shí)是意識到自己代碼具有局限性的的過程,無論對自己,對項(xiàng)目都是大有裨益的。

測試內(nèi)容:

1,驗(yàn)證handleData(Intent data)能否處理空數(shù)據(jù)
2,驗(yàn)證handleData(Intent data)能否處理異常數(shù)據(jù)
3,驗(yàn)證handleData(Intent data)能否處理正常數(shù)據(jù)

WelcomePresenterTest:


/**
 * Android單元測試示例
 * 使用框架簡介:
 * junit(純java代碼可用該框架測試),
 * mockito(模擬數(shù)據(jù)),
 * robolectric(模擬Android運(yùn)行環(huán)境,可以測試Android代碼)
 * 純java部分的可以通過Junit4來進(jìn)行單元測試,
 * 而對于用到android自身代碼的測試不能依靠Junit進(jìn)行,
 * 對于這種情況解決方案之一就是使用Robolectric
 */

/**
 * 知識點(diǎn)1,runWith:RobolectricTestRunner
 * 表示測試時使用robolectric運(yùn)行環(huán)境,可以測試Android代碼,比如:textview.setText()這樣的代碼
 * 如果測試Presenter中沒有涉及Android代碼,則不要加,否則拖慢測試速度。
 */
@RunWith(RobolectricTestRunner.class)
/**
 * 知識點(diǎn)2,指定manifest文件,格式如下:
 * @Config(manifest = "../app/AndroidManifest.xml")
 *
 */
@Config(manifest = Config.NONE)

public class WelcomePresenterTest {
    WelcomeContract.Presenter mPresenter;
    /**
     * 知識點(diǎn)3,@mock 注解介紹:
     * 模擬某個類對象
     * 為什么要模擬?
     * 答:因?yàn)檫@是測試環(huán)境,view對象的獲取很麻煩很困難,并且view并不是我們測試的對象。
     */
    @Mock
    WelcomeContract.View mView;
    @Mock
    WelcomeContract.Model mModel;
    /**
     * 知識點(diǎn)4:參數(shù)捕抓器
     * 用于捕抓model層方法中的參數(shù)
     */
    ArgumentCaptor<SubscriberAction> captor;

    /**
     * 在測試前的數(shù)據(jù)初始化
     */
    @Before
    public void setUp() {
        //Mockito的初始化
        MockitoAnnotations.initMocks(this);
        /**
         * 知識點(diǎn)5:Presenter的創(chuàng)建
         * 注意:在view層就需要創(chuàng)建model,將之作為presenter的構(gòu)造方法參數(shù)。
         * 對比之前的寫法:mPresenter = new WelcomePresenter(this)的寫法
         * 這樣的寫法好處:model可以在測試中模擬,如果model完全隱藏在presenter的
         * 構(gòu)造方法中,model還需要用參數(shù)捕抓出來,比較麻煩。
         */
        mPresenter = new WelcomePresenter(mView, mModel);
        captor = ArgumentCaptor.forClass(SubscriberAction.class);
        /**
         *知識點(diǎn)6: 把將Rxjava接口調(diào)用的異步操作變成同步,加快測試速度。
         */
        UnitTestHelper.openRxTools();

    }

    /**
     * 傳遞給presenter的參數(shù)異常的測試
     *
     * @throws Exception
     */
    @Test
    public void handleDataFail() throws Exception {
        Intent intent = mock(Intent.class);
        Uri uri = mock(Uri.class);
        intent.setData(uri);
        when(uri.getQueryParameter("data"))
                 //模擬數(shù)據(jù)為空情況
//                .thenReturn(null)

                 //模擬數(shù)據(jù)缺失情況,少了userId
                .thenReturn("{\"source\":\"xxxx\",\"userType\":\"xxxx\"}");
        when(intent.getData()).thenReturn(uri);

        mPresenter.handleData(intent);
//        assertFalse(mPresenter.handleData(intent));
        verify(mView).handleError(any(String.class));
    }

    /**
     * 傳遞給presenter的參數(shù)正常的測試
     * @throws Exception
     */
    @Test
    public void handleDataSuccess() throws Exception {
        /**
         * 模擬數(shù)據(jù)
         */
        Intent intent = mock(Intent.class);
        Uri uri = mock(Uri.class);
        when(uri.getQueryParameter("data")).thenReturn("{\"userId\":\"xxxx\",\"source\":\"xxxx\",\"userType\":\"xxxx\"}");

        when(intent.getData()).thenReturn(uri);

        mPresenter.handleData(intent);
        /**
         * mPresenter.handleData調(diào)用后
         * 1,驗(yàn)證(verify)model是否調(diào)用了outsideLogin方法,
         * 2,并且捕獲outsideLogin方法參數(shù)subscriberAction對象
         */
        verify(mModel).outsideLogin(any(String.class), captor.capture());
        /**
         * 疑問:為什么要捕抓subscriberAction對象?
         * 答:因?yàn)槟M調(diào)用接口成功中需要用到subscriberAction這個訂閱者對象。
         *
         */
        UnitTestHelper.mockCallBack(new LoginBean(), captor.getValue());
        /**
         * 接口數(shù)據(jù)LoginBean成功模擬返回后
         * 驗(yàn)證(verify)Presenter是否調(diào)用了model以及view中outsideLoginSuccess方法。
         */
        verify(mModel).outsideLoginSuccess(any(LoginBean.class));
        verify(mView).outsideLoginSuccess(any(LoginBean.class));
    }

}

UnitTestHelper單元測試工具類:


/**
 * 用于:
 *1,模擬model中網(wǎng)絡(luò)請求返回的數(shù)據(jù)
 *2,把RXJava的異步變成同步,方便測試
 */
public class UnitTestHelper {

    public static void mockFailCallBack(SubscriberAction sub) {
        mockCallBack(99,"我錯了",null,sub);
    }
    public static void mockFailCallBack(int resultCode,String msg,SubscriberAction sub) {
        mockCallBack(resultCode,msg,null,sub);
    }
    public static void mockEmptyCallBack(SubscriberAction sub) {
        mockCallBack(0,"模擬接口調(diào)用成功",null,sub);
    }
    public static void mockCallBack(Object data,SubscriberAction sub) {
        mockCallBack(0,"模擬接口調(diào)用成功",data,sub);
    }
    public static void mockCallBack(int resultCode,String msg,Object data,SubscriberAction sub) {
        BaseRetrofitClient.toSubscribe(Observable.just(new HttpResult<>(resultCode,msg,data)),sub);
    }
    private static boolean isInitRxTools = false;

    /**
     * 把RXJava的異步變成同步,方便測試
     */
    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()不是必要,實(shí)踐中發(fā)現(xiàn)不寫reset(),偶爾會出錯,所以寫上保險(xiǎn)
        RxAndroidPlugins.getInstance().reset();
        RxAndroidPlugins.getInstance().registerSchedulersHook(rxAndroidSchedulersHook);
        RxJavaPlugins.getInstance().reset();
        RxJavaPlugins.getInstance().registerSchedulersHook(rxJavaSchedulersHook);
    }
}

這兩個類是這篇博客的精華所在,耗費(fèi)了我們Android組不少時間,不少精力探索出來的,有興趣的讀者可以慢慢讀這段代碼,收獲會超乎想象。

三,Android測試填坑

1,選框架的坑

非常建議采用robolectric框架,工欲善其事必先利其器,一開始沒有選擇robolectric框架,就開始擼單元測試,摔得臉好疼,郁悶了一整天:明明我這樣寫單元測試沒有錯的呀,怎么就死活都沒法通過測試呢?
原因在于mvp中測試presenter過程中無可避免會調(diào)用Android系統(tǒng)API,而junit不支持,mock也不可能面面俱到,有些方法中Android API藏得比較深,很難都mock到,而用了robolectric框架就完全沒有問題。

robolectric原理:實(shí)現(xiàn)一套JVM能運(yùn)行的Android代碼,然后在unit test運(yùn)行的時候去截取android相關(guān)的代碼調(diào)用,然后轉(zhuǎn)到他們的他們實(shí)現(xiàn)的Shadow代碼去執(zhí)行這個調(diào)用

1,創(chuàng)建單元測試類的小坑

有個同事不知道AS能自動生成測試類,然后說,單元測試好麻煩,創(chuàng)建一個類要寫這么多東西。
貼上一個自動創(chuàng)建測試類的小教程:

自動創(chuàng)建測試類-步驟1.png
自動創(chuàng)建測試類-步驟2.png
自動創(chuàng)建測試類-步驟3.png
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容