android-MVP架構中Presenter的單元測試

米豬控股研發一部發表於2018-03-27

一,為什麼只對Presenter進行單元測試,而不測試Model和View呢?

原因1:

mvp中,全部業務邏輯都集中在這個類中,bug的高發區,只要這塊測試好了,app穩定性可以大大提高。

原因2:

在mvp架構中model層主要進行負責儲存、檢索、操縱資料(包括網路請求),這些並不涉及業務邏輯的處理,沒能想到可以怎麼測試,如果讀者有什麼好建議可以留言給我;而view層主要進行ui操作,與使用者進行互動,更加適合進行UI測試。

二,如何測試Presenter?

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

步驟1:

編寫契約類,實現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();
                        //驗證沒有問題,請求伺服器獲取登入資料
                        mModel.outsideLogin(json, new SubscriberAction<LoginBean>(mView, loginBean -> {
                            mView.dismissProgressDialog();
                            if (loginBean == null) {
                                mView.handleError("返回資料為空");
                            } 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("解析登入資料出錯");
                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層就不貼了,主要關注點在presenter層,model層程式碼有助於測試中引數捕抓的理解

步驟2:

編寫針對presenter的測試類 功能寫完後,驗證業務邏輯是否能處理各種資料的輸入。

特別注意: 以前完成了功能後就一直等後臺介面資料,介面調通了,心裡才踏實;而現在,我不需要等後臺介面,直接就能驗證presenter的業務邏輯寫得好不好,能不能處理各種突發意外情況,這是單元測試的一大好處。單元測試給我最大的感受:一個字: ,兩個字:踏實 ,具體一點來說:對自己寫的程式碼不會膽戰心驚,不會害怕功能上線了驚呼:我擦,這什麼情況?我寫的時候完全就沒想到會有這種情況發生的!寫單元測試其實是意識到自己程式碼具有侷限性的的過程,無論對自己,對專案都是大有裨益的。

測試內容:

1,驗證handleData(Intent data)能否處理 空資料

2,驗證handleData(Intent data)能否處理 異常資料

3,驗證handleData(Intent data)能否處理 正常資料

WelcomePresenterTest:


/**
 * Android單元測試示例
 * 使用框架簡介:
 * junit(純java程式碼可用該框架測試),
 * mockito(模擬資料),
 * robolectric(模擬Android執行環境,可以測試Android程式碼)
 * 純java部分的可以通過Junit4來進行單元測試,
 * 而對於用到android自身程式碼的測試不能依靠Junit進行,
 * 對於這種情況解決方案之一就是使用Robolectric
 */

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

public class WelcomePresenterTest {
    WelcomeContract.Presenter mPresenter;
    /**
     * 知識點3,@mock 註解介紹:
     * 模擬某個類物件
     * 為什麼要模擬?
     * 答:因為這是測試環境,view物件的獲取很麻煩很困難,並且view並不是我們測試的物件。
     */
    @Mock
    WelcomeContract.View mView;
    @Mock
    WelcomeContract.Model mModel;
    /**
     * 知識點4:引數捕抓器
     * 用於捕抓model層方法中的引數
     */
    ArgumentCaptor<SubscriberAction> captor;

    /**
     * 在測試前的資料初始化
     */
    @Before
    public void setUp() {
        //Mockito的初始化
        MockitoAnnotations.initMocks(this);
        /**
         * 知識點5:Presenter的建立
         * 注意:在view層就需要建立model,將之作為presenter的構造方法引數。
         * 對比之前的寫法:mPresenter = new WelcomePresenter(this)的寫法
         * 這樣的寫法好處:model可以在測試中模擬,如果model完全隱藏在presenter的
         * 構造方法中,model還需要用引數捕抓出來,比較麻煩。
         */
        mPresenter = new WelcomePresenter(mView, mModel);
        captor = ArgumentCaptor.forClass(SubscriberAction.class);
        /**
         *知識點6: 把將Rxjava介面呼叫的非同步操作變成同步,加快測試速度。
         */
        UnitTestHelper.openRxTools();

    }

    /**
     * 傳遞給presenter的引數異常的測試
     *
     * @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"))
                 //模擬資料為空情況
//                .thenReturn(null)

                 //模擬資料缺失情況,少了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的引數正常的測試
     * @throws Exception
     */
    @Test
    public void handleDataSuccess() throws Exception {
        /**
         * 模擬資料
         */
        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呼叫後
         * 1,驗證(verify)model是否呼叫了outsideLogin方法,
         * 2,並且捕獲outsideLogin方法引數subscriberAction物件
         */
        verify(mModel).outsideLogin(any(String.class), captor.capture());
        /**
         * 疑問:為什麼要捕抓subscriberAction物件?
         * 答:因為模擬呼叫介面成功中需要用到subscriberAction這個訂閱者物件。
         *
         */
        UnitTestHelper.mockCallBack(new LoginBean(), captor.getValue());
        /**
         * 介面資料LoginBean成功模擬返回後
         * 驗證(verify)Presenter是否呼叫了model以及view中outsideLoginSuccess方法。
         */
        verify(mModel).outsideLoginSuccess(any(LoginBean.class));
        verify(mView).outsideLoginSuccess(any(LoginBean.class));
    }

}
複製程式碼

UnitTestHelper單元測試工具類:


/**
 * 用於:
 *1,模擬model中網路請求返回的資料
 *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,"模擬介面呼叫成功",null,sub);
    }
    public static void mockCallBack(Object data,SubscriberAction sub) {
        mockCallBack(0,"模擬介面呼叫成功",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()不是必要,實踐中發現不寫reset(),偶爾會出錯,所以寫上保險
        RxAndroidPlugins.getInstance().reset();
        RxAndroidPlugins.getInstance().registerSchedulersHook(rxAndroidSchedulersHook);
        RxJavaPlugins.getInstance().reset();
        RxJavaPlugins.getInstance().registerSchedulersHook(rxJavaSchedulersHook);
    }
}
複製程式碼

這兩個類是這篇部落格的精華所在,耗費了我們Android組不少時間,不少精力探索出來的,有興趣的讀者可以慢慢讀這段程式碼,收穫會超乎想象。

三,Android測試填坑

1,選框架的坑

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

robolectric原理:實現一套JVM能執行的Android程式碼,然後在unit test執行的時候去擷取android相關的程式碼呼叫,然後轉到他們的他們實現的Shadow程式碼去執行這個呼叫

1,建立單元測試類的小坑

有個同事不知道AS能自動生成測試類,然後說,單元測試好麻煩,建立一個類要寫這麼多東西。 貼上一個自動建立測試類的小教程:

自動建立測試類-步驟1.png

自動建立測試類-步驟2.png

自動建立測試類-步驟3.png

                                                                                            作者:米豬研發一部-刁新強複製程式碼

相關文章