安卓自動化測試入門-4-Presenter的單元測試
在這個系列的博客中,我們新建了一個叫做Github User Search的Android App範例。在前面的博客中,我們瞭解瞭如何爲了測試而配置項目,創建API調用併爲API數據轉換寫了第一個單元測試。查看Part 1, Part 2 和 Part 3。
這篇博客將會帶你瞭解如何創建一個Presenter,用來和repository通信並傳輸數據到View層。也同樣會爲Presenter編寫單元測試。源碼可從Github檢出,點擊這裏。
本文翻譯自Riggaroo的《Introduction to Android Testing – Part 4》
注意:以下的測試特指“程序員編寫的自動化代碼測試”
水平有限,歡迎指教。如有錯漏,多多包涵。
作者的項目地址:
https://github.com/riggaroo/GithubUsersSearchApp。
請注意:每個分支對應這一系列博客的每一篇文章。
創建Presenter
1 . 首先,在za.co.riggaroo.gus.presentation.base
包中創建基本接口MvpVIew
和MvpPresenter
。所有的MVP功能類都將繼承這兩個接口。
public interface MvpView {
}
public interface MvpPresenter<V extends MvpView> {
void attachView(V mvpView);
void detachView();
}
2 . 創建一個BasePresenter
。在這個類中,我們檢查當前的Presenter是否已經依附了一個View,並提供管理RxJava訂閱者的方法。
public class BasePresenter<T extends MvpView> implements MvpPresenter<T> {
private T view;
private CompositeSubscription compositeSubscription = new CompositeSubscription();
@Override
public void attachView(T mvpView) {
view = mvpView;
}
@Override
public void detachView() {
compositeSubscription.clear();
view = null;
}
public T getView() {
return view;
}
public void checkViewAttached() {
if (!isViewAttached()) {
throw new MvpViewNotAttachedException();
}
}
private boolean isViewAttached() {
return view != null;
}
protected void addSubscription(Subscription subscription) {
this.compositeSubscription.add(subscription);
}
protected static class MvpViewNotAttachedException extends RuntimeException {
public MvpViewNotAttachedException() {
super("Please call Presenter.attachView(MvpView) before" + " requesting data to the Presenter");
}
}
}
正如你在上面看到的,這個presenter定義了一個CompositeSubscription
。這個對象將會保存一組RxJava的Subscription(訂閱)。在detachView()
方法中調用了compositionSubscription.clear()
方法,這個方法將會取消所有的訂閱,從而防止內存泄露和View造成的崩潰(當View被銷燬,它就不會被訂閱,相關的代碼也不會運行)。當繼承於這個類的presenter中有subscription被創建時,我們調用addSubscription()
。
3 . 創建一個UserSearchContract
接口來表示View和Presenter之間的Contract(約定?交互關係?自己理解就好,翻譯不出來了)。在這個接口中,分別爲View和Presenter創建一個接口。
interface UserSearchContract {
interface View extends MvpView {
void showSearchResults(List<User> githubUserList);
void showError(String message);
void showLoading();
void hideLoading();
}
interface Presenter extends MvpPresenter<View> {
void search(String term);
}
}
在View接口中,有個四個方法:showSearchResults()
,showError()
,showLoading()
,hideLoading()
。在Presenter中,只有一個search()
方法。
一個Presenter既不在意一個View如何去展示獲得的數據,也不在意如何展示錯誤信息。相似的,一個View也不關心一個Presenter如何去搜索,只需要Presenter會調用回調方法,具體的實現無關緊要。
分離View和Presenter之間的邏輯是件簡單的事。從如何將Presenter重用到另一種類型的UI的角度考慮,你就會明白代碼應該放到哪裏。例如,當你必須使用Java Swing作爲UI實現工具,你的Presenter可以保持不變的話,就僅僅需要改變你的View實現了。這意味着當你考慮邏輯代碼應該放在哪裏時,僅僅需要問自己:當我有了另一套不同的UI時,Presenter裏面的邏輯還有意義嗎?
4 . 現在我們已經定義好View跟Presenter之間的約定。創建或導航到UserSearchPresenter
。在這裏,我們添加對UserRepository
的訂閱,這就是我們調用Github API的地方。
class UserSearchPresenter extends BasePresenter<UserSearchContract.View> implements UserSearchContract.Presenter {
private final Scheduler mainScheduler, ioScheduler;
private UserRepository userRepository;
UserSearchPresenter(UserRepository userRepository, Scheduler ioScheduler, Scheduler mainScheduler) {
this.userRepository = userRepository;
this.ioScheduler = ioScheduler;
this.mainScheduler = mainScheduler;
}
@Override
public void search(String term) {
}
}
這個Presenter繼承了BasePresenter
並且實現了第3步定義的UserSearchContract.Presenter
接口。我們將在這個類裏面實現Search()
方法的具體邏輯(先放一個空方法)。
使用Constructor injection(構造注入?)可以在需要做單元測試時輕鬆地仿造(mock) UserRepository。兩個Scheduler也是通過構造器注入,在單元測試時,我們會一直用Schedulers.immediate()
(即立即執行的策略),而在View層調用時,我們會使用不同的線程(即一個主線程,一個IO線程)。
5 . 以下是search()
的實現:
@Override
public void search(String term) {
checkViewAttached();
getView().showLoading();
addSubscription(userRepository.searchUsers(term).subscribeOn(ioScheduler).observeOn(mainScheduler).subscribe(new Subscriber<List<User>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
getView().hideLoading();
getView().showError(e.getMessage()); //TODO You probably don't want this error to show to users - Might want to show a friendlier message :)
}
@Override
public void onNext(List<User> users) {
getView().hideLoading();
getView().showSearchResults(users);
}
}));
}
首先,調用checkViewAttached()
,如果當前沒有View依附在Presenter上的話,會拋出異常。接着通過調用showLoading()
告訴View,它應該開始加載了。給userRepository.searchUsers()
創建一個Subscription(訂閱)。設置subscribeOn()
的參數爲ioScheduler
,因爲我們希望網絡調用發生在IO線程上。設置observeOn()
的參數爲mainScheduler
,因爲我們希望這個Subscription的結果可以在主線程觀察到(應該是在主線程運行的意思)。最後通過調用addSubscription()
,將Subscription添加到我們的Subscription組裏面。
在onNext()
裏面,通過調用hideLoading()
和showSearchResults()
方法處理API返回的用戶列表。在onError()
裏面,停止加載並調用showError()
顯示錯誤信息。
以下是UserSearchPresenter
的全部代碼:
package za.co.riggaroo.gus.presentation.search;
import java.util.List;
import rx.Scheduler;
import rx.Subscriber;
import za.co.riggaroo.gus.data.UserRepository;
import za.co.riggaroo.gus.data.remote.model.User;
import za.co.riggaroo.gus.presentation.base.BasePresenter;
class UserSearchPresenter extends BasePresenter<UserSearchContract.View> implements UserSearchContract.Presenter {
private final Scheduler mainScheduler, ioScheduler;
private UserRepository userRepository;
UserSearchPresenter(UserRepository userRepository, Scheduler ioScheduler, Scheduler mainScheduler) {
this.userRepository = userRepository;
this.ioScheduler = ioScheduler;
this.mainScheduler = mainScheduler;
}
@Override
public void search(String term) {
checkViewAttached();
getView().showLoading();
addSubscription(userRepository.searchUsers(term).subscribeOn(ioScheduler).observeOn(mainScheduler).subscribe(new Subscriber<List<User>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
getView().hideLoading();
getView().showError(e.getMessage()); //TODO You probably don't want this error to show to users - Might want to show a friendlier message :)
}
@Override
public void onNext(List<User> users) {
getView().hideLoading();
getView().showSearchResults(users);
}
}));
}
}
爲 UserSearchPresenter 編寫單元測試
現在我們已經定義好presenter了,開始爲它寫一些單元測試吧!
1 . 選中UserSearchPresenter
的類名,按下“ALT + Enter”鍵,選中“Create Test”。選擇“app/src/test/java”目錄,因爲這是不需要Android依賴的單元測試。測試代碼的最終存放路徑爲:app/src/test/java/za/co/riggaroo/gus/presentation/search
。
2 . 在UserSearchPresenterTest
裏面,創建setup方法以及定義我們在測試中需要用到的變量。
public class UserSearchPresenterTest {
@Mock
UserRepository userRepository;
@Mock
UserSearchContract.View view;
UserSearchPresenter userSearchPresenter;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
userSearchPresenter = new UserSearchPresenter(userRepository, Schedulers.immediate(), Schedulers.immediate());
userSearchPresenter.attachView(view);
}
}
通過仿造 UserRepository
和 UserSearchContract.View
,我們可以確保只測試 UserSearchPresenter
。在setup()
方法中,我們調用MockitoAnnotations.initMocks()
來初始化仿造的變量。接着用仿造的對象和即時計劃(immediate schedules)創建presenter。調用attachView()
將仿造的View依附到Presenter上面。
3 . 第一個測試的目標是一個有效的查詢條件會有正確的回調:
private static final String USER_LOGIN_RIGGAROO = "riggaroo";
private static final String USER_LOGIN_2_REBECCA = "rebecca";
@Test
public void search_ValidSearchTerm_ReturnsResults() {
UsersList userList = getDummyUserList();
when(userRepository.searchUsers(anyString())).thenReturn(Observable.<List<User>>just(userList.getItems()));
userSearchPresenter.search("riggaroo");
verify(view).showLoading();
verify(view).hideLoading();
verify(view).showSearchResults(userList.getItems());
verify(view, never()).showError(anyString());
}
UsersList getDummyUserList() {
List<User> githubUsers = new ArrayList<>();
githubUsers.add(user1FullDetails());
githubUsers.add(user2FullDetails());
return new UsersList(githubUsers);
}
User user1FullDetails() {
return new User(USER_LOGIN_RIGGAROO, "Rigs Franks", "avatar_url", "Bio1");
}
User user2FullDetails() {
return new User(USER_LOGIN_2_REBECCA, "Rebecca Franks", "avatar_url2", "Bio2");
}
這個測試斷定:設定 user repository 會返回一組用戶,當在presenter上調用 search()
,**最後**View的 showLoading()
,hideLoading()
和showSearchResult()
被調用。這個測試也斷定showError()
方法不會被調用。
4 . 第二個測試的目標是當UserRepository拋出異常後會出現錯誤頁面:
@Test
public void search_UserRepositoryError_ErrorMsg() {
String errorMsg = "No internet";
when(userRepository.searchUsers(anyString())).thenReturn(Observable.error(new IOException(errorMsg)));
userSearchPresenter.search("bookdash");
verify(view).showLoading();
verify(view).hideLoading();
verify(view, never()).showSearchResults(anyList());
verify(view).showError(errorMsg);
}
這個測試是這樣進行的:設定 userRepository 會返回一個異常,當調用 search()
時,最後會調用showError()
。
5 . 最後的測試的目標是在沒有View依附時,會拋出異常:
@Test(expected = BasePresenter.MvpViewNotAttachedException.class)
public void search_NotAttached_ThrowsMvpException() {
userSearchPresenter.detachView();
userSearchPresenter.search("test");
verify(view, never()).showLoading();
verify(view, never()).showSearchResults(anyList());
}
譯者注:如果MvpViewNotAttachedException報錯,將訪問限制改爲public。
6 . 讓我們運行這些測試吧!看看我們能有多少覆蓋率。右鍵點擊測試類名,選擇“Run tests with coverage”。
Yay!我們獲得了100%的覆蓋率。
下一篇博客將會涉及創建UI並編寫UI測試。
尋找廣州Android開發工程師工作,郵箱[email protected] 電話:13580579413 陳捷尉 2016.11.22