(MVP+RxJava+Retrofit)解耦+Mockito单元测试 经验分享

前言

首先,对于MVP、RxJava还不了解的同学,请先阅读这几篇文章:

了解 Retrofit、okHttp,直接看Squre官网

之所以说解耦,很大程度是 MVP、Rxjava、Retrofitjava工程 就能使用,本身不依赖Android SDK。这一点对Android单元测试至关重要。

MVP & RxJava在2015年已经很火了,加上2016年发布正式版的 OkHttp3.0 & Retrofit2.0 火上浇油,全世界简直炸开了锅,Android开发有了质的飞跃(代码层面)。

国内Android开发者逐渐成熟,翻墙越来越方便,国外的技术在国内使用顺理成章。目前,国内状况是,Android开发者不缺,缺的是大量Android中级开发者。因此,学会使用MVP、RxJava、Retrofit、Mockito单元测试势在必行。

逆水行走,不进则退。


请求User数据,并在显示

User bean:

public class User {
    public int    uid;
    public String name;
}

UserView,网络加载完User数据,回调onUserLoaded(user)

public interface UserView {
    void onUserLoaded(User user);
}

UserServiceRetrofit代理的请求接口:

public interface UserService {

    @GET("user/{uid}.json")
    Observable<User> loadUser(@Path("uid") int uid);
}

View (Activity)交互的UserPresenter接口、以及实现UserPresenterImpl

public interface UserPresenter {

    void loadUser(int uid);
}
public class UserPresenterImpl implements UserPresenter {

    UserService userService;
    UserView    userView;

    public UserPresenterImpl(UserView userView) {
        this.userView = userView;

        userService = new Retrofit.Builder().baseUrl("http://**.com/")
                                            .addConverterFactory(GsonConverterFactory.create())
                                            .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                                            .build()
                                            .create(UserService.class);
    }

    @Override
    public void loadUser(int uid) {
        // 异步网络请求User数据,并在onNext(user)返回
        userService.loadUser(uid)
                   .subscribeOn(Schedulers.io())
                   .observeOn(AndroidSchedulers.mainThread())
                   .subscribe(new Subscriber<User>() {

                       @Override
                       public void onNext(User user) {
                           userView.onUserLoaded(user);
                       }
                       ......
                   });
    }
}

MainActivity

public class MainActivity extends Activity implements UserView {

    UserPresenter userPresenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        userPresenter = new UserPresenterImpl(this);
    }

    @Override
    public void onUserLoaded(User user) {
        textView.setText(user.toString());
    }
}

为何解耦?

前言中提及,RxJavaRetrofit是不依赖Android SDK的独立的第三方库,MVP模式通过接口编程,把依赖Android SDKView层(Activity)Presenter、Model隔离。这里说的Model是指retrofit和代理的Service网络请求接口,对于Model层依赖与Android SDKDAO、sqlite,之后会讨论。

当P层、网络M层不依赖Android SDK,我们就可以用JUnit写单元测试,并直接运行在JVM上了。


JUnit4+Mockito单元测试

很多Android开发的同学,不了解单元测试。对于不是测试专业出身、又没技术大牛调教过的程序猿,缺乏单元测试知识,比比皆是。要了解单元测试,推荐阅读:

美团点评技术团队《Android单元测试研究与实践》
邹小创 《Android单元测试(二):再来谈谈为什么》

老实说,我也是2016年初,才真正接触单元测试。2016年3月才正式对项目写单元测试。写了一个多月,越来越意识到单元测试的重要性。单元测试达到的目的,总结成两点:

  • 快速开发
  • 提高代码质量

你没看错,确实是“快速”

对于需求“请求User,并显示”,噼里啪啦写完Presenter、Service、Activity,需要 编译、运行真机or模拟器才能debug,如果写错了,修改代码后,还要编译、运行在真机....还写错,修改、编译、运行.... 小型项目怎么也要40s~1分钟吧,还要花几分钟时间手动操作界面...用Android Sutdio debug或Log.....这个过程太漫长了!!

如果你学会写Junit单元测试,可以直接对单个Presenter、Service编译运行,不需要关心是否受到其他类的代码or网络环境、服务器是否正常的影响。运行一下就几秒钟,JunitMockito的错误提示,还让你快速定位问题。

瞎逼逼了那么久,该上代码。

Presenter单元测试

打开UserPresenter,对着类名 右键 -> Go To -> Test

Image 2.png

创建OK之后,你会得到UserPresenterTest.java

Image 6.png

public class UserPresenterTest {

    @Before
    public void setUp() throws Exception {
    }

    @Test
    public void testLoadUser() throws Exception {
    }
}

要对UserPresenterImpl进行单元测试,还需要做一点点改进:

public class UserPresenterImpl implements UserPresenter {

    UserService userService;
    UserView    userView;

    // 让外部传入UserService & UserView
    public UserPresenterImpl(UserService userService, UserView userView) {
        this.userService = userService;
        this.userView = userView;
    }
    ...
}
import static org.mockito.Mockito.mock;

public class UserPresenterTest {

    UserPresenter userPresenter;
    UserView      userView;
    UserService   userService;

    @Before
    public void setUp() throws Exception {
        RxUnitTestTools.openRxTools();
    
        // 生成mock对象
        userView = mock(UserView.class); 
        userService = mock(UserService.class);

        userPresenter = new UserPresenterImpl(userService, userView);
    }
}

注意,这里import static org.mockito.Mockito.mock,静态引用org.mockito.Mockitomock()静态方法。我们不用自己敲这句import,通过代码补全提示就可以自动生成了,如图:

Image 7.png

这里有行RxUnitTestTools.openRxTools()到底是什么?

public class RxUnitTestTools {
    private static boolean isInitRxTools = false;

    /**
     * 把异步变成同步,方便测试
     */
    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);
    }
}

这个类是让RxJava&RxAndroidSchedulers.io()AndroidSchedulers.mainThread()转换成Schedulers.immediate(),从而让Obserable从异步变同步。

然后,写testLoadUser()

    @Test
    public void testLoadUser() throws Exception {
        User user = new User();
        user.uid = 1;
        user.name = "kkmike999";

        when(userService.loadUser(anyInt())).thenReturn(Observable.just(user));

        userPresenter.loadUser(1);

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);

        verify(userService).loadUser(1);
        verify(userView).onUserLoaded(captor.capture());

        User result = captor.getValue(); // 捕获的User

        Assert.assertEquals(result.uid, 1); 
        Assert.assertEquals(result.name, "kkmike999");
    }

让我解析一下:

when...thenReturn...

when(userService.loadUser(anyInt())).thenReturn(Observable.just(user));

当调用userService.loadUser(...),参数为任意int,返回Observable.just(user)对象。

verify

verify(userService).loadUser(1);,验证 userService.loadUser(...)是否被调用,并校验传入参数uid==1

这一步很重要,这个loadUser(uid)参数比较少,当方法参数多时(例如loadXXX(int,int,int,int...String,String....)),特别容易搞错。当后端接口修改了,service相应也要修改,这时多参数的方法很容易出问题。

verify(userView).onUserLoaded(captor.capture());,验证userView.onUserLoaded(...)是否被调用,并捕获传入的user参数

ArgumentCaptor

顾名思义参数捕获器,就是捕获传入参数。当userService.loadUser()执行完并返回Observable<User>,在onNext(user)回调User传给userView.onUserLoaded(...),但我们不确定回调的user是否正确。因此我们需要捕获user参数,并校验其正确性。

如果参数是List<T>类型,ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class)即可,不需要写List泛型参数。

assertEquals

这个不用说了吧.....


Service(Model层)单元测试

测试这一层的目的,是验证从服务器返回的数据,是否解析成正确的对象。单元测试时,应该模拟服务器返回json数据。由于UserServiceRetrofit代理过,所以单元测试需要一点技巧。

写一个MockRetrofitHelper

public class MockRetrofitHelper {

    public <T> T create(Class<T> clazz) {
        OkHttpClient client = new OkHttpClient.Builder()
                                              .addInterceptor(new MockInterceptor())
                                              .build();
                                                       
        Retrofit retrofit = new Retrofit.Builder().baseUrl("http://api.***.com")
                                                  .client(client)
                                                  .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                                                  .addConverterFactory(GsonConverterFactory.create())
                                                  .build();

        return retrofit.create(clazz);
    }

    private String path;

    public void setPath(String path) {
        this.path = path;
    }

    private class MockInterceptor implements Interceptor{

        @Override
        public Response intercept(Chain chain) throws IOException {
            // 模拟网络数据
            String content = AssestsReader.readFile(path);

            ResponseBody body = ResponseBody.create(MediaType.parse("application/x-www-form-urlencoded"), content);

            Response response = new Response.Builder().request(chain.request())
                                                      .protocol(Protocol.HTTP_1_1)
                                                      .code(200)
                                                      .body(body)
                                                      .build();
            return response;
        }
    }
}

解释一下,MockInterceptor的职责,读取本地数据,并直接返回。因此,OkHttpClient并没有真正请求网络数据,而是用了本地数据。

OkHttp Interceptor不熟悉的同学,参考:

学习OkHttp --Interceptors

然后,写UserServiceTest单元测试:

public class UserServiceTest {

    UserService        userService;
    MockRetrofitHelper retrofit;

    @Before
    public void setUp() throws Exception {
        retrofit = new MockRetrofitHelper();

        userService = retrofit.create(UserService.class);
    }

    @Test
    public void testLoadUser() throws Exception {
        retrofit.setPath(".../User.json");

        TestSubscriber<User> testSubscriber = new TestSubscriber<>();

        userService.loadUser(1)
                   .toBlocking()
                   .subscribe(testSubscriber);

        User user = testSubscriber.getOnNextEvents()
                                  .get(0);

        Assert.assertEquals(user.uid, 1);
        Assert.assertEquals(user.name, "kkmike999");
    }
}

Observalbe<User>调用subscribe(...)时,TestSubscriber 会捕获onNext(user)参数,并放进List<User>事件队列。我们通过testSubscriber.getOnNextEvents()获取事件队列,从这个队列获取User,并验证正确性。

不用TestSubscriber也可以这样:

User user =  userService.loadUser(1)
                        .toBlocking()
                        .first();

小结

文章已经到尾声。对于mockito、retrofit、okhttp intercepor熟悉的你,本文并没有太多难点。

为新功能写代码时,应该先写Presenter或者Service,不急着运行,再写PresenterTestServiceTest,在JVM上验证代码是否正确。写完单元测试后,再让Activity调用Presenter

Activity、Service单元测试感兴趣的同学,不妨了解Robolectric(发音比较坑爹,重音在l而不是R)。它可以让你在JVM运行Activity单元测试,比真机调试快多了。

各位同学,千万不要觉得 很麻烦、项目很赶 就不写单元测试,这些都是业界大牛的经验之谈,有益无害!当你发现代码无法单元测试,证明代码本身有问题,应该去改进,而不是放弃单元测试。


实际项目与Retrofit

一个中型的APP项目,可能由几位工程师一起编写,对于Retrofit等新技术并不是每个人都接受。希望《同事拒绝Retrofit,怎么办?》对你有帮助。


关于作者
我是键盘男。
在广州生活,在创业公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。希望成为独当一面的工程师。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容