Android单元测试 - 几个重要问题

前言

已经一个月没写文章了,由于9月份在plan国庆旅行计划,国庆前前后后去了14天旅行,所以没时间写,哈哈。

言归正传,上一篇文章《Android单元测试 - 如何开始?》介绍了几款单元测试框架、Junit & Mockito基本用法、依赖隔离 & Mock概念,本篇主要解答单元测试中几个重要问题。

在单元测试交流微信群,很多新进来的小伙伴,都会几个大同小异的问题。我们几个老鸟们答完一次又一次(厚颜无耻地把自己算上_),笔者是有点不耐烦了,后来就等其他同学回答他们.....其实大家提的问题,归根到底就是“依赖问题”,jvm依赖还是android依赖?用到native方法报错怎么办?静态方法怎么解决?

于是呢,笔者决定专门写一篇文章,来讲解这几个问题。

  • 如何解决Android依赖?
  • 隔离Native方法
  • 解决内部new对象
  • 静态方法
  • RxJava异步转同步

1.如何解决Android依赖?

小白:“Presenter中用到TextUtils,运行junit时报'java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked'错误... 是不是要用robolectric?”

含TextUtils Presenter单元测试

别急,还未到robolectric出场的时候呢!

由于junit运行在jvm上,而jdk没有android源码,所以TextUtils这些在android sdk中的类,运行junit时就引用不上了。既然jdk没有,我们就自己加呗!

test/java目录下,创建android.text.TextUtils

package android.text;

public class TextUtils {

    public static boolean isEmpty(CharSequence str) {
        if (str == null || str.equals("")) {
            return true;
        }
        return false;
    }
}
目录结构

关键是要个TextUtils同包名、同类名、同方法名。注意不是main/java下创建,不然会提示Duplicate class found in the file...。单元测试运行妥妥的:

含TextUtils Presenter单元测试通过

原理很简单,jvm运行时会找android.text.TextUtils类,然后找isEmpty方法执行。学过java反射的同学都知道,只要知道包名类名,就可以拿到Class,知道该类某方法名,就可以获取Method并执行。jvm也是类似的机制,只要我们给一个包名类名与android sdk相同的类,写上方法名&参数&返回值相同的方法,jvm就能编译并执行。

(提示:android的View之类也能这么搞噢)


2.隔离Native方法

小白:“我用到native方法,junit运行失败,robolectric也不支持加载so文件,怎么办?”

Model类:

package com.test.unit;

public class Model {
    public native boolean nativeMethod();
}

单元测试:

public class ModelTest {

    Model model;

    @Before
    public void setUp() throws Exception {
        model = new Model();
    }

    @Test
    public void testNativeMethod() throws Exception {
        Assert.assertTrue(model.nativeMethod());
    }
}

run ModelTest... 报错java.lang.UnsatisfiedLinkError: com.test.unit.Model.nativeMethod()

Native Error

上篇文章《Android单元测试 - 如何开始?》讲述的“依赖隔离”,这里要用到了!

改进单元测试:

public class ModelTest {

    Model model;

    @Before
    public void setUp() throws Exception {
        model = mock(Model.class);
    }

    @Test
    public void testNativeMethod() throws Exception {
        when(model.nativeMethod()).thenReturn(true);

        Assert.assertTrue(model.nativeMethod());
    }
}

run一下,pass了:

Native Pass.png

这里稍微讲讲java查找native方法的过程:
1.Model.java全名是com.test.unit.Model.java
2.调用native方法nativeMethod()后, jvm会去找C++层com_test_unit_Model.cpp,再找com_test_unit_Model_nativeMethod()方法,并调用。

在APP运行过程,我们会把cpp编译成so文件,然后让APP加载到dalvik虚拟机。但在单元测试中,没有加载对应的so文件,也没有编译cpp呀!大牛们可能会尝试单元测试时加载so文件,但完全没有必要,也不符合单元测试的原则。

所以,我们可以直接用Mockito框架mock native方法就行啦。实际上,不仅仅是native方法需要mock,很多依赖的方法、类都要mock,下面会讲到更常用的场景。

(参考《Android JNI原理分析》


3.解决内部new对象

小白:“我在Presenter里new Model,Model依赖比较多,会做sql操作,等等.....Presenter依赖Model返回结果,导致Presenter没法单元测试啦!求大神指点!”

小白C的例子:
Model:

public class Model {
    public boolean getBoolean() {
        boolean bo = ....... // 一堆依赖,代码很复杂
        return bo;
    }
}

Presenter:

public class Presenter {

    Model model;

    public Presenter() {
        model = new Model();
    }

    public boolean getBoolean() {
        return model.getBoolean());
    }
}

错误的单元测试:

public class PresenterTest {

    Presenter presenter;

    @Before
    public void setUp() throws Exception {
        presenter = new Presenter();
    }

    @Test
    public void testGetBoolean() throws Exception {
        Assert.assertTrue(presenter.getBoolean());
    }
}

还是那句话:依赖隔离。我们隔离Model依赖,即mock Model对象,而不是new Model()

找找以上PresenterTest的问题吧:PresenterTest完全不知道Model的存在,意思是无法mock Model。那么,我们就想办法把mock Model传给Presenter——在Presenter构造函数传参!

改进Presenter

public class Presenter {

    Model model;

    public Presenter(Model model) {
        this.model = model;
    }

    public boolean getBoolean() {
        return model.getBoolean();
    }
}

正确的单元测试:

public class PresenterTest {
    Model     model;
    Presenter presenter;

    @Before
    public void setUp() throws Exception {
        model = mock(Model.class);// mock Model对象

        presenter = new Presenter(model);
    }

    @Test
    public void testGetBoolean() throws Exception {
        when(model.getBoolean()).thenReturn(true);

        Assert.assertTrue(presenter.getBoolean());
    }
}

事情就这么解决了。如果你觉得在Activity直接用默认Presenter构造函数,在构造函数new Model()比较方便,那就保留默认构造函数呗。当然使用dagger2就不存在多个构造函数了,都是构造传参。


4.静态方法

小白:“大神,我在Presenter用到静态方法....”
笔者:“行了,知道你要说什么。”

Presenter:

public class Presenter {

    public String getSignParams(int uid, String name, String token) {
        return SignatureUtils.sign(uid, name, token);
    }
}

解决方法跟上面【解决内部new对象】大同小异,核心思想还是依赖隔离

方案1,改成非静态

1.把sign(...)改成非静态方法
2.把SignatureUtils作为成员变量;
3.构造方法传入SignatureUtils
4.单元测试时,把mock SignatureUtils传给Presenter

改进后Presenter

public class Presenter {
    SignatureUtils mSignUtils;

    public Presenter(SignatureUtils signatureUtils) {
        this.mSignUtils= signatureUtils;
    }

    public String getSignParams(int uid, String name, String token) {
        return mSignUtils.sign(uid, name, token);
    }
}

方案2,Spy

(2017.4.20补充)

public class PresenterTest {
    
    Presenter presenter;

    @Before
    public void setUp() throws Exception {
        presenter = spy(new Presenter(...));
    }

    @Test
    public void testXXX() throws Exception {
                // 注意不是用when().thenReturn()
        doReturn("...").when(presenter).getSignParams(...);

        ...
    }
}

关于Spy用法,请自行脑补_.


5.RxJava异步转同步

小白:“大神...”
笔者:“为师掐指一算,料汝会遇此劫难。”
小白:(传说中从入门到出家?)

public class RxPresenter {

    public void testRxJava(String msg) {
        Observable.just(msg)
                  .subscribeOn(Schedulers.io())
                  .delay(1, TimeUnit.SECONDS) // 延时1秒
//                .observeOn(AndroidSchedulers.mainThread())
                  .subscribe(new Action1<String>() {
                      @Override
                      public void call(String msg) {
                          System.out.println(msg);
                      }
                  });
    }
}

单元测试

public class RxPresenterTest {

    RxPresenter rxPresenter;

    @Before
    public void setUp() throws Exception {
        rxPresenter = new RxPresenter();
    }

    @Test
    public void testTestRxJava() throws Exception {
        rxPresenter.testRxJava("test");
    }
}

运行RxPresenterTest

RxJava Async

你会发现没有输出"test",为什么呢?

由于testRxJava里面,Obserable.subscribeOn(Schedulers.io())把线程切换到io线程,并且delay了1秒,而testTestRxJava()单元测试早已在当前线程跑完了。笔者试过,即使去掉delay(1, TimeUnit.SECONDS),还是不会输出‘test’

可以看到笔者把.observeOn(AndroidSchedulers.mainThread())注释掉了,我们把那句代码加上,再跑一下testTestRxJava(),会报java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked.

AndroidScheduler

这是由于jdk没有android.os.Looper这个类及相关依赖。

解决以上两个问题,我们只要把Schedulers.io()&AndroidSchedulers.mainThread()切换为Schedulers.immediate()就可以了。RxJava开发团队已经为大家想好了,提供了RxJavaHooksRxAndroidPlugins两个hook操作的类。

新建RxTools

public class RxTools {
    public static void asyncToSync() {
        Func1<Scheduler, Scheduler> schedulerFunc = new Func1<Scheduler, Scheduler>() {
            @Override
            public Scheduler call(Scheduler scheduler) {
                return Schedulers.immediate();
            }
        };

        RxAndroidSchedulersHook rxAndroidSchedulersHook = new RxAndroidSchedulersHook() {
            @Override
            public Scheduler getMainThreadScheduler() {
                return Schedulers.immediate();
            }
        };

        RxJavaHooks.reset();
        RxJavaHooks.setOnIOScheduler(schedulerFunc);
        RxJavaHooks.setOnComputationScheduler(schedulerFunc);

        RxAndroidPlugins.getInstance().reset();
        RxAndroidPlugins.getInstance().registerSchedulersHook(rxAndroidSchedulersHook);
    }
}

RxPresenterTest.setUp()加一句RxTools.asyncToSync();:

public class RxPresenterTest {
    RxPresenter rxPresenter;

    @Before
    public void setUp() throws Exception {
        rxPresenter = new RxPresenter();

        RxTools.asyncToSync();
    }
    ...
}

再跑一次testTestRxJava()

RxJava Pass

总算输出"test",感谢上帝啊!(应该打赏下笔者吧_

读者有没发现RxTools.asyncToSync()多加了一句RxJavaHooks.setOnComputationScheduler(schedulerFunc),意思将computation线程切换为immediate线程。笔者发现,仅仅添加RxJavaHooks.setOnIOScheduler(schedulerFunc),对于有delayObserable还是未通过,于是顺手把computation线程也切换了,于是就可以了。

还有RxJavaHooks.reset()RxAndroidPlugins.getInstance().reset(),笔者发现,当运行大量单元测试时,有些会失败,但单独运行失败的单元测试,又通过了。百思不得其解后,添加了那两句.....可以了!

(关于RxJavaHooksRxAndroidPlugins的使用,在很久前的文章 《(MVP+RxJava+Retrofit)解耦+Mockito单元测试 经验分享》已经提及过)


小结

笔者:“小白同学,现在你踩过的坑,填好未?”
小白:“方丈,啊不,大神,上面几个问题是解决了,不过还有其他问题。”
笔者:“不挖坑,怎么填坑呢?以后再给你讲讲其他单元测试的玄机。”
小白:“......”

本文详述了几个单元测试重要问题的解决方法,读者不难发现,笔者一直强调 依赖隔离、依赖隔离、依赖隔离,这个概念在单元测试中相当重要。还搞不懂这个概念的同学,看多几次《Android单元测试 - 如何开始?》(又厚颜无耻地广告),同时在实践中不断回顾这个理念。

只要解决好这几个问题,Presenter单元测试就不难了。还有本文未提及的sqlite、SharedPreferences单元测试、在后面的文章会给读者介绍下。

感谢读者对笔者一直以来的支持,麻烦点赞&随手转发,好人一世平安。


关于作者

我是键盘男。
在广州生活,在互联网公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,568评论 25 707
  • 前言 在之前的系列博客中,主要围绕的是测试工具的介绍与使用。经过几个月的沉寂,在项目中摸索与实践单元测试,曾经踩坑...
    水木飞雪阅读 2,742评论 0 8
  • fetch api
    编程之上阅读 234评论 0 0
  • 靖西鹅泉日出 村中的劳作 泉边剪影 晨光与影 老人的微信 你好鹅泉
    水中木鱼阅读 221评论 0 0
  • 以为不会记住的时光 在人去楼空的岁月里 斑驳着时间里的轨迹 光怪陆离里沉淀回忆 屋檐青荇狂风里疯长 只为着轻吻孤傲...
    NonhumanSun阅读 156评论 0 0