安卓单元测试(十一):异步代码怎么测试

作者: 邹小创 | 来源:发表于2016-08-10 08:57 被阅读1530次

    问题

    今天讲一个我们讨论群里面被问得最多的一个问题:怎么测试异步操作。问题很明显,测试方法跑完了的时候,被测代码可能还没跑完,这就有问题了。比如下面的类:

    public class RepoModel {
        private Handler mUiHandler = new Handler(Looper.getMainLooper());
    
        public void loadRepos(final RepoCallback callback) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1000);
                        final List<Repo> repos = new ArrayList<>();
                        repos.add(new Repo("android-unit-testing-tutorial",
                                           "A repo that demos how to do android unit testing"));
                        mUiHandler.post(new Runnable() {
                            @Override
                            public void run() {
                                callback.onSuccess(repos);
                            }
                        });
                    } catch (final InterruptedException e) {
                        e.printStackTrace();
                        mUiHandler.post(new Runnable() {
                            @Override
                            public void run() {
                                callback.onFailure(500, e.getMessage());
                            }
                        });
                    }
                }
            }).start();
        }
    
        interface RepoCallback {
            void onSuccess(List<Repo> repos);
    
            void onFailure(int code, String msg);
        }
    }
    

    在上面的例子中,loadRepos()方法里面new了一个线程来异步的加载repo。如果我们按正常的方式写对应的测试:

    @RunWith(RobolectricGradleTestRunner.class)
    @Config(constants = BuildConfig.class, sdk = 21)
    public class RepoModelTest {
    
        @Test
        public void testLoadRepos() throws Exception {
            RepoModel model = new RepoModel();
            final List<Repo> result = new ArrayList<>();
            model.loadRepos(new RepoCallback() {
                @Override
                public void onSuccess(List<Repo> repos) {
                    result.addAll(repos);
                }
    
                @Override
                public void onFailure(int code, String msg) {
                    fail();
                }
            });
            assertEquals(1, result.size());
        }
    }
    

    你会发现上面的测试方法永远会fail,这是因为在执行 assertEquals(1, result.size());的时候,loadRepos()里面启动的线程还没执行完毕呢,因此,callback里面的 result.addAll(repos);也没有得到执行,所以result.size()返回永远是0。

    要解决这个问题,或者更general的说,要测试异步代码,有两种思路,一是等异步代码执行完了再执行assert操作,二是将异步变成同步。
    接下来讲讲,具体怎么样用这两种思路来测试异步代码。

    思路1,等待异步代码执行完毕:快使用CountDownLatch!

    在上面的例子中,我们要做的,其实是等待Callback里面的代码执行完毕。要达到这个目的,有一个非常好用的神器,那就是CountDownLatchCountDownLatch是一个类,它有两对配套使用的方法,那就是countDown()await()await()方法会阻塞当前线程,直到countDown()被调用了一定的次数,这个次数就是在创建这个CountDownLatch对象时,传入的构造参数。比如:

    CountDownLatch latch = new CountDownLatch(3);
    
    //.....
    
    //下面这行代码会让当前线程一直停在这里
    //直到latch.countDown()被调用了3次(一般是在其它线程)
    latch.await();
    

    使用CountDownLatch来实现上面例子的单元测试,方法如下:

    @RunWith(RobolectricGradleTestRunner.class)
    @Config(constants = BuildConfig.class, sdk = 21)
    public class RepoModelTest {
    
        @Test
        public void testLoadRepos() throws Exception {
            RepoModel model = new RepoModel();
            final List<Repo> result = new ArrayList<>();
            final CountDownLatch latch = new CountDownLatch(1); //创建CountDownLatch
            model.loadRepos(new RepoCallback() {
                @Override
                public void onSuccess(List<Repo> repos) {
                    result.addAll(repos);
                    latch.countDown();  //这里countDown,外面的await()才能结束
                }
    
                @Override
                public void onFailure(int code, String msg) {
                    fail();
                }
            });
            latch.await();
            assertEquals(1, result.size());
        }
    }
    

    CountDownLatch的工作原理类似于倒序计数,刚开始设定了一个数字,每次countDown()这个数字减一,await()方法会一直等待,直到这个数字为0。await()还有一个重载方法,可以用来指定你要等待多久,因为很多时候你不想一直等下去。你想等待一会,如果没等到,那就做别的事情。这种时候你就可以使用这个重载方法:

    //等待2秒钟,如果2秒以后,计数是0了,则返回True,否则返回False。
    latch.await(2, TimeUnit.SECONDS);
    

    CountDownLatch的使用还是比较简单直观的。基本上,所有有Callback的异步,包括RxJava(Subscriber其实就相当于Callback的角色),都可以使用这种方式来做测试,不论内部是通过什么样的方式来实现异步的。不过,使用CountDownLatch来做单元测试,有一个很大的限制,那就是countDown()必须可以在测试代码里面写,换句话说,必需有Callback。如果被测的异步方法(比如上面的loadRepos())不是通过Callback的方式来通知结果,而是通过post EventBus的Event来通知外面方法运行的结果,那CountDownLatch是无法解决这个异步方法的单元测试问题的。
    此外,CountDownLatch还有一个缺点,那就是写起来有点罗嗦,创建对象、调用countDown()、调用await()都必须手动写,而且还没有通用性,你没有办法抽出一个类或方法来简化代码。

    思路2,将异步变成同步

    将异步变成同步也是解决异步代码测试问题的一种比较直观的思路。使用这种思路的主要手段是依赖注入,但是根据实现异步的方式不同,也有一些其它的手段。下面介绍几种常见的异步实现,以及相应的单元测试的方法。

    直接new Thread的情况

    呃,如果你直接在正式代码里面new Thread()来做异步,那么你的代码是没有办法变成同步的,换成Executor这种方式来做吧。

    Executor或ExecutorService的情况

    如果你的代码是通过ExecutorExecutorService来做异步的,那在测试中把异步变成同步的做法,跟在测试中使用mock对象的方法是一样的,那就是使用依赖注入。在测试代码里面将同步的Executor注入进去。创建同步的Executor对象很简单,以下就是一个同步的Executor

    Executor immediateExecutor = new Executor() {
        @Override
        public void execute(Runnable command) {
            command.run();
        }
    };
    

    当然,你可以使用一个辅助的factory方法来做这件事情。至于怎么样将这个同步的Executor在测试里面替换掉真实异步的那个Executor,就是依赖注入的问题了。具体的做法请参见系列第5篇:依赖注入,将mock方便的用起来,如果你使用了Dagger2的话,请看第六篇:使用dagger2来做依赖注入,以及在单元测试中的应用

    AsyncTask

    笔者建议是不要使用AsyncTask,这个东西有很多问题,其中之一是它的行为是很难预测的,之二是如果你在Activity里面使用的话,其实这部分代码往往是不应该放在Activity里面的。
    不过,如果你实在需要使用AsyncTask,同时又想对这些代码作单元测试的话,建议是使用 AsyncTask#executeOnExecutor()而不是直接使用AsyncTask#execute(),然后通过依赖注入的方式,在测试环境下将同步的Executor注入进去。

    RxJava

    这个是不得不提的一种方法,随着越来越多的人使用RxJava来做异步操作,RxJava代码的单元测试也是经常被问到的一个问题。通常,我们是用下面的方式来使用RxJava的。

    someMethodsThatReturnsAnObservable().subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
    

    这里的问题是,Schedulers.io()会让Observable的某些操作运行在另外一个线程中,从而导致本文开头说的那个问题。在这种情况下,要把RxJava的操作变成同步的,也有2种方式,第一种方式是使用依赖注入,将subscribeOn(也许还有observeOn)的scheduler从外面注入进来。第二种方式是使用RxJava提供的Util hook:RxJavaPlugins#registerSchedulersHook(),让Schedulers.io()返回当前测试运行所在的个线程,而不是另外的一个线程。具体做法请看一个例子:

    public class RepoModel {
        private Handler mUiHandler = new Handler(Looper.getMainLooper());
    
        public RepoModel() {
        }
    
        //待测方法
        public Observable<List<Repo>> loadRepos() {
            return Observable.create(new OnSubscribe<List<Repo>>() {
                @Override
                public void call(Subscriber<? super List<Repo>> subscriber) {
                    try {
                        //Imagine you're getting repos from network or database
                        Thread.sleep(2000);
                        final List<Repo> repos = new ArrayList<>();
                        repos.add(new Repo("android-unit-testing-tutorial",
                        "A repo that demos how to do android unit testing"));
                        if (!subscriber.isUnsubscribed()) {
                            subscriber.onNext(repos);
                            subscriber.onCompleted();
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        if (!subscriber.isUnsubscribed()) {
                            subscriber.onError(e);
                        }
                    }
    
                }
            }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
        }
    }
    
    @RunWith(RobolectricGradleTestRunner.class)
    @Config(constants = BuildConfig.class, sdk = 21)
    public class RepoModelTest {
    
        @Test
        public void testLoadReposInRx() {
            // 让Schedulers.io()返回当前线程
            RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
                @Override
                public Scheduler getIOScheduler() {
                    return Schedulers.immediate();
                }
            });
            RepoModel model = new RepoModel();
            final List<Repo> result = new ArrayList<>();
            model.loadRepos().subscribe(new Action1<List<Repo>>() {
                @Override
                public void call(List<Repo> repos) {
                    result.addAll(repos);
                }
            });
            assertEquals(1, result.size());
        }
    }
    

    怎么样,很简单吧?实事上,我们还可以使用

    RxAndroidPlugins.getInstance().registerSchedulersHook(new RxAndroidSchedulersHook() {
                @Override
                public Scheduler getMainThreadScheduler() {
                    return Schedulers.immediate();
                }
            });
    

    来让AndroidSchedulers.mainThread()返回当前线程,这样,如果其它地方没有用到Android的类,我们就可以摆脱Robolectric了。这种方式的好处是你可以不用对你的正式代码作依赖注入处理,同时是通用的,你可以在@Before里面或其它地方作一次性的初始化,然后这个测试类的所有测试方法都可以使用相同的效果。

    小结

    本文介绍了几种异步代码的单元测试方法,实际上,在Android上实现异步当然不止这几种方式,还有ThreadHandlerIntentServiceLoader等方式,但是笔者对于这些方式使用得较少,因此一时想不出很好的解释方式,但是思想应该都是一样的,那就是要么想办法等待异步线程结束,要么把异步变成同步。
    文中的代码在github的这个repo
    希望本文能帮助到你。

    获取最新文章或想加入安卓单元测试交流群,请关注下方公众号

    相关文章

      网友评论

      • 樊无救:RxJava不是有支持单元测试的API吗?为什么还要Hook?
        //测试观察者
        TestSubscriber<String> testSubscriber = new TestSubscriber<>();

        //测试用例
        this.useCase.preposeInteractor(mockUseCase, mockAction).execute(testSubscriber);
        //等待执行结果
        testSubscriber.awaitTerminalEvent();

        //断言执行结果
        testSubscriber.assertNoErrors();
        testSubscriber.assertCompleted();
        testSubscriber.assertValueCount(1);
        testSubscriber.assertValue(RESULT_PREFIX + RESULT_MIDFIX + inputNum);
      • 捡淑:马克
      • 五香鱼cc:小创你不是在日本吗,居然还写文章:cry:
        呵呵哒了嘛:@邹小创 公司 outting 吧。😄
        邹小创:@五香鱼cc 上周二就回来了 :smile:
      • 北方南山:最后一个我在data层测试了一下,从网络获取数据异步代码测试通过。但是同样的方法在测试presenter的时候
        单元测试代码
        ```
        @test
        public void testPayByCash() {
        given(view.getContext()).willReturn(mContext);
        payPresenter.payByCash("20", "0");
        verify(payByCash).execute();
        }
        ```
        payPresenter.payByCash 方法
        ```
        @Override
        public void payByCash(String payAmount, String chargeAmount) { payByCash.initParams("","","","");
        payByCashSubscriber = new PayByCashSubscriber();
        payByCash.execute().subscribe(payByCashSubscriber);
        }
        ```
        如下所示payByCash.execute() 返回Observable
        ```
        @Override
        public Observable<PayByResponse> execute() {
        return this.dataRepositoryDomain.payByCash(orderNo, amount, paymentId, finishOrder);
        }
        ```
        但是单元测试打断点,execute返回的是null
        payByCash.execute().subscribe(payByCashSubscriber); 会报 NullPointerException
        北方南山:@北方南山 通过了,单元测试这样写pass :sweat:
        ```
        public void testUserListPresenterInitialize() {
        given(mockUserListView.context()).willReturn(mockContext);
        given(mockGetUserList.execute()).willReturn(Observable.<List<User>>empty());
        userListPresenter.initialize();

        verify(mockUserListView).hideRetry();
        verify(mockUserListView).showLoading();
        verify(mockGetUserList).execute();
        }
        ```
        北方南山:@北方南山 如果不是我的代码写的不易于测试,就是我的测试代码写得不对。因为把https://github.com/android10/Android-CleanArchitecture项目按照我的写法改一下,也是报NullPointerException

      本文标题:安卓单元测试(十一):异步代码怎么测试

      本文链接:https://www.haomeiwen.com/subject/ruzrsttx.html