安卓單元測試 (十一):非同步程式碼怎麼測試

小創發表於2017-03-27

問題

今天講一個我們討論群裡面被問得最多的一個問題:怎麼測試非同步操作。問題很明顯,測試方法跑完了的時候,被測程式碼可能還沒跑完,這就有問題了。比如下面的類:

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
希望本文能幫助到你。

獲取最新文章或想加入安卓單元測試交流群,請關注下方公眾號

安卓單元測試 (十一):非同步程式碼怎麼測試

相關文章