一,為什麼只對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能自動生成測試類,然後說,單元測試好麻煩,建立一個類要寫這麼多東西。 貼上一個自動建立測試類的小教程:
作者:米豬研發一部-刁新強複製程式碼