安卓自動化測試入門-4-Presenter的單元測試

安卓自動化測試入門-4-Presenter的單元測試

在這個系列的博客中,我們新建了一個叫做Github User Search的Android App範例。在前面的博客中,我們瞭解瞭如何爲了測試而配置項目,創建API調用併爲API數據轉換寫了第一個單元測試。查看Part 1Part 2Part 3

原文Part 1Part 2Part 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包中創建基本接口MvpVIewMvpPresenter。所有的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);
    }
}

通過仿造 UserRepositoryUserSearchContract.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”。

test_result

Yay!我們獲得了100%的覆蓋率。

下一篇博客將會涉及創建UI並編寫UI測試。

尋找廣州Android開發工程師工作,郵箱[email protected] 電話:13580579413 陳捷尉 2016.11.22

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章