安卓自动化测试入门-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开发工程师工作,邮箱hengzhechenjay@163.com 电话:13580579413 陈捷尉 2016.11.22

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值