一,為什么只對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)建測試類的小教程:


