Android单元测试(七):MVP与单元测试

1,382 阅读5分钟

本篇介绍如何对MVP架构的项目进行单元测试。会用到之前六篇文章中所介绍的内容,算是学以致用了。本文中我没具体说明的地方前几篇文中一定会有的。希望大家可以循序渐进。

对于MVP网上也有很多变种,各有千秋,但是万变不离其宗。本篇采用的MVP是《Android源码设计模式解析与实战》这本书中介绍的一种,我也有写过相关的读书笔记,没看过这本书的可以简单了解下。我本人还是很喜欢这种MVP的。

1.MVP相关基类

View的接口 : MvpView

public interface MvpView {

    /***
     * 获取Context
     * @return Context
     */
    Context getContext();

    /***
     * 显示Progress
     */
    void showProgress();

    /***
     * 关闭Progress
     */
    void closeProgress();

    /***
     * @param string 消息内容
     */
    void showToast(String string);
}

扮演着view和model的中间层的角色 : BaseMVPPresenter

public abstract class BaseMVPPresenter<T extends MvpView> {

    /**
     * View接口类型的弱引用
     */
    private Reference<T> mViewRef;

    protected T mMvpView;

    /**
     * 建立关联
     */
    public void attachView(T view){
        mViewRef = new WeakReference<>(view);
        if(isViewAttached()) {
            mMvpView = getView();
        }
    }

    /**
     * 获取View
     * @return View
     */
    public T getView(){
        return mViewRef.get();
    }

    /**
     * UI展示相关的操作需要判断一下 Activity 是否已经 finish.
     * <p>
     * todo : 只有当 isActivityAlive 返回true时才可以执行与Activity相关的操作,
     * 比如 弹出Dialog、Window、跳转Activity等操作.
     *
     * @return boolean
     */
    public boolean isViewAttached(){
        return mViewRef != null && mViewRef.get() != null;
    }

    /**
     * 解除关联
     */
    public void detachView(){
        if( mViewRef != null){
            mViewRef.clear();
            mViewRef = null;
        }
    }
}

简单封装的view : BaseMVPActivity

public abstract class BaseMVPActivity<V extends MvpView, T extends BaseMVPPresenter<V>> extends AppCompatActivity implements MvpView{

    /**
     * Presenter对象
     */
    protected T mPresenter;
    public ProgressDialog mProgress;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mPresenter = createPresenter();
        mPresenter.attachView((V)this);
        mProgress = new ProgressDialog(this);
        mProgress.setMessage("加载中...");
    }

    @Override
    protected void onDestroy() {
        if (mPresenter != null){
            mPresenter.detachView();
        }
        super.onDestroy();
    }

    @Override
    protected void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
        if (mPresenter == null){
            mPresenter = createPresenter();
        }
    }

    /**
     * 创建Presenter对象
     * @return Presenter对象
     */
    protected abstract T createPresenter();

    @Override
    public Context getContext() {
        return this;
    }

    @Override
    public void showProgress() {
        if (mProgress != null && !mProgress.isShowing()){
            mProgress.show();
        }
    }

    @Override
    public void closeProgress() {
        if (mProgress != null && mProgress.isShowing()) {
            mProgress.dismiss();
        }
    }

    @Override
    public void showToast(String string) {
        Toast.makeText(this, string, Toast.LENGTH_SHORT).show();
    }
}

2.举栗子

这次我们还是采用上一篇的例子。一个简单的登录页面,其中有两个功能:

  • 获取验证码(点击获取验证码后,实现一个120s的倒计时)

  • 登录(验证输入的手机号码与验证码,请求登录接口)

代码很简单,我一一的贴出来:

public interface LoginMvpView extends MvpView{

    /**
     * 倒计时完成
     */
    void countdownComplete();

    /**
     * 倒计时中
     * @param time 剩余时间
     */
    void countdownNext(String time);

    /**
     * 登录成功
     */
    void loginSuccess();

}
public class LoginPresenter extends BaseMVPPresenter<LoginMvpView>{

    private CompositeDisposable mCompositeDisposable = new CompositeDisposable();

    public void getIdentify() {
        // interval隔一秒发一次,到120结束
        Disposable mDisposable = Observable
                .interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
                .take(120)
                .subscribeWith(new DisposableObserver<Long>() {
                    @Override
                    public void onComplete() {
                        mMvpView.countdownComplete();
                    }
                    @Override
                    public void onError(Throwable e) {
                        mMvpView.showToast("倒计时出现错误!");
                    }

                    @Override
                    public void onNext(Long aLong) {
                        mMvpView.countdownNext(String.valueOf(Math.abs(aLong - 120)));
                    }
                });
        mCompositeDisposable.add(mDisposable);
    }

    public void login(String mobile, String code) {
        if(mobile.length() != 11){
            mMvpView.showToast("手机号码不正确");
            return;
        }
        if(code.length() != 6){
            mMvpView.showToast("验证码不正确");
            return;
        }

        GithubService.createGithubService()
                .getUser("simplezhli")
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSubscribe(new Consumer<Disposable>() {
                    @Override
                    public void accept(Disposable disposable) throws Exception {
                        if (isViewAttached()){
                            mMvpView.showProgress();
                        }

                    }
                })
                .doAfterTerminate(new Action() {
                    @Override
                    public void run() throws Exception {
                        if (isViewAttached()){
                            mMvpView.closeProgress();
                        }
                    }
                })
                .subscribe(new Observer<User>() {
                    @Override
                    public void onSubscribe(Disposable d) {
                        mCompositeDisposable.add(d);
                    }

                    @Override
                    public void onNext(User user) {
                        mMvpView.showToast("登录成功");
                        mMvpView.loginSuccess();
                    }

                    @Override
                    public void onError(Throwable e) {
                        mMvpView.showToast("登录失败");
                    }

                    @Override
                    public void onComplete() {}
                });
    }

    @Override
    public void detachView(){
        super.detachView();
        mCompositeDisposable.clear();
    }

}
public class LoginMVPActivity extends BaseMVPActivity<LoginMvpView, LoginPresenter> implements LoginMvpView, View.OnClickListener{

    private TextView mTvSendIdentify;
    private EditText mEtMobile;
    private EditText mEtIdentify;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        mEtMobile = (EditText) this.findViewById(R.id.et_mobile);
        mEtIdentify = (EditText) this.findViewById(R.id.et_identify);
        mTvSendIdentify = (TextView) this.findViewById(R.id.tv_send_identify);

        this.findViewById(R.id.tv_login).setOnClickListener(this);
        mTvSendIdentify.setOnClickListener(this);
    }

    @Override
    protected LoginPresenter createPresenter() {
        return new LoginPresenter();
    }

    @Override
    public void countdownComplete() {
        mTvSendIdentify.setText(R.string.login_send_identify);
        mTvSendIdentify.setEnabled(true);
    }

    @Override
    public void countdownNext(String time) {
        mTvSendIdentify.setText(TextUtils.concat(time, "秒后重试"));
    }

    @Override
    public void loginSuccess() {
        showToast("登录成功");
    }

    @Override
    public void onClick(View view) {
        switch (view.getId()){
            case R.id.tv_send_identify:
                mTvSendIdentify.setEnabled(false);
                mPresenter.getIdentify();
                break;
            case R.id.tv_login:
                mPresenter.login(mEtMobile.getText().toString().trim(),
                        mEtIdentify.getText().toString().trim());
                break;
            default:
                break;
        }
    }
}

实现代码很简单,我就不具体说明了,主要说说单元测试部分。

3.单元测试

单元测试主要测试两部分:ActivityPresenter

  • Activity部分其实和上一篇大同小异,主要是测试界面上的View的状态变化和文字显示,ToastDialog的弹出与显示内容这些是否符合预期。

  • Presenter 部分测试数据处理的正确性,回调接口的次数与内容是否符合预期。

Activity测试部分代码:

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 23)
public class LoginMVPActivityTest {

    private LoginMVPActivity loginActivity;
    private TextView mTvSendIdentify;
    private TextView mTvLogin;
    private EditText mEtMobile;
    private EditText mEtIdentify;

    @Rule
    public RxJavaTestSchedulerRule rule = new RxJavaTestSchedulerRule();

    @Before
    public void setUp(){
        ShadowLog.stream = System.out;
        loginActivity = Robolectric.setupActivity(LoginMVPActivity.class);
        mTvSendIdentify = (TextView) loginActivity.findViewById(R.id.tv_send_identify);
        mTvLogin = (TextView) loginActivity.findViewById(R.id.tv_login);
        mEtMobile = (EditText) loginActivity.findViewById(R.id.et_mobile);
        mEtIdentify = (EditText) loginActivity.findViewById(R.id.et_identify);
    }

    @Test
    public void testGetIdentify() throws Exception {
        Application application = RuntimeEnvironment.application;
        assertEquals(mTvSendIdentify.getText().toString(),
                application.getString(R.string.login_send_identify));

        // 触发按钮点击
        mTvSendIdentify.performClick();
        // 时间到10秒
        rule.getTestScheduler().advanceTimeTo(10, TimeUnit.SECONDS);
        assertEquals(mTvSendIdentify.isEnabled(), false);
        assertEquals(mTvSendIdentify.getText().toString(), "111秒后重试");

        // 时间到120秒
        rule.getTestScheduler().advanceTimeTo(120, TimeUnit.SECONDS);

        assertEquals(mTvSendIdentify.getText().toString(),
                application.getString(R.string.login_send_identify));
        assertEquals(mTvSendIdentify.isEnabled(), true);
    }

    @Test
    public void testLogin() throws Exception {

        mEtMobile.setText("123");
        mEtIdentify.setText("123");
        mTvLogin.performClick();
        assertEquals("手机号码不正确", ShadowToast.getTextOfLatestToast());

        mEtMobile.setText("13000000000");
        mEtIdentify.setText("123");
        mTvLogin.performClick();
        assertEquals("验证码不正确", ShadowToast.getTextOfLatestToast());

        initRxJava();

        mEtMobile.setText("13000000000");
        mEtIdentify.setText("123456");
        mTvLogin.performClick();

        // 判断ProgressDialog弹出
        assertNotNull(ShadowProgressDialog.getLatestDialog());
        assertEquals("登录成功", ShadowToast.getTextOfLatestToast());
    }

    private void initRxJava() {
        RxJavaPlugins.reset();
        RxJavaPlugins.setIoSchedulerHandler(new Function<Scheduler, Scheduler>() {
            @Override
            public Scheduler apply(Scheduler scheduler) throws Exception {
                return Schedulers.trampoline();
            }
        });
        RxAndroidPlugins.reset();
        RxAndroidPlugins.setMainThreadSchedulerHandler(new Function<Scheduler, Scheduler>() {
            @Override
            public Scheduler apply(Scheduler scheduler) throws Exception {
                return Schedulers.trampoline();
            }
        });
    }

}

Presenter测试部分代码:

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 23)
public class LoginPresenterTest{

    private LoginPresenter mPresenter;

    @Mock
    private LoginMvpView mvpView;

    @Rule
    public MockitoRule mockitoRule = MockitoJUnit.rule();

    @Rule
    public RxJavaTestSchedulerRule rule = new RxJavaTestSchedulerRule();

    @Before
    public void setUp(){
        //输出日志
        ShadowLog.stream = System.out;

        mPresenter = new LoginPresenter();
        mPresenter.attachView(mvpView);
    }

    @Test
    public void testGetIdentify() throws Exception {
        mPresenter.getIdentify();
        // 时间到10秒
        rule.getTestScheduler().advanceTimeTo(10, TimeUnit.SECONDS);
        // 验证方法被调用10次
        verify(mvpView, times(10)).countdownNext(anyString());
        // 时间到120秒
        rule.getTestScheduler().advanceTimeTo(120, TimeUnit.SECONDS);
        verify(mvpView, times(120)).countdownNext(anyString());
        // 验证倒计时完成方法被调用
        verify(mvpView).countdownComplete();
    }

    @Test
    public void testLogin() throws Exception {

        initRxJava();

        mPresenter.login("123", "123");
        verify(mvpView).showToast("手机号码不正确");

        mPresenter.login("13000000000", "123");
        verify(mvpView).showToast("验证码不正确");

        mPresenter.login("13000000000", "123456");

        verify(mvpView).showProgress();

        verify(mvpView).loginSuccess();

        verify(mvpView).closeProgress();

    }

    private void initRxJava() {
        RxJavaPlugins.reset();
        RxJavaPlugins.setIoSchedulerHandler(new Function<Scheduler, Scheduler>() {
            @Override
            public Scheduler apply(Scheduler scheduler) throws Exception {
                return Schedulers.trampoline();
            }
        });
        RxAndroidPlugins.reset();
        RxAndroidPlugins.setMainThreadSchedulerHandler(new Function<Scheduler, Scheduler>() {
            @Override
            public Scheduler apply(Scheduler scheduler) throws Exception {
                return Schedulers.trampoline();
            }
        });
    }
}

本篇内容不多,主要是前面讲解内容的一个整合,下一篇会说说MVP结合Dagger的单元测试。所有代码已上传至Github。希望大家多多点赞支持!