Android单元测试 - 验证函数参数、返回值的正确姿势

前言

读者有没发觉我写文章时,喜欢有个前言、序?真相是,一半用来装逼凑字数,一半是因为不知道接下来要写什么,先闲聊几句压压惊_ 哈哈哈......该说的还是要说。

上一篇《Android单元测试 - Sqlite、SharedPreference、Assets、文件操作 怎么测?》 讲了一些DAO(Data Access Object)单元测试的细节。本篇讲解参数验证。

验证参数传递、函数返回值,是单元测试中十分重要的环节。笔者相信不少读者都有验证过参数,但是你的单元测试代码真的是正确的吗?笔者在早期实践的时候,遇到一些问题,积累了一点心得,本期与大家分享一下。


1.一般形式

Bean

public class Bean {
    int    id;
    String name;

    public Bean(int id, String name) {
        this.id = id;
        this.name = name;
    }
    // getter and setter
    ......
}

DAO

public class DAO {
    public Bean get(int id) {
        return new Bean(id, "bean_" + id);
    }
}

Presenter

public class Presenter {

    DAO dao;

    public Presenter(DAO dao) {
        this.dao = dao;
    }

    public Bean getBean(int id) {
        Bean bean = dao.get(id);

        return bean;
    }
}

单元测试PresenterTest(下文称为“例子1”

public class PresenterTest {

    DAO       dao;
    Presenter presenter;

    @Before
    public void setUp() throws Exception {
        dao = mock(DAO.class);
        presenter = new Presenter(dao);
    }

    @Test
    public void testGetBean() throws Exception {
        Bean bean = new Bean(1, "bean_1");

        when(dao.get(1)).thenReturn(bean);

        Bean result = presenter.getBean(1);

        Assert.assertEquals(result.getId(), 1);
        Assert.assertEquals(result.getName(), "bean_1");
    }
}

这个单元测试是通过的。

Presenter Pass

2.问题:对象很多变量

上面的Bean只有2个参数,但实际项目,对象往往有很多很多参数,例如,用户信息User

public class User {
    int    id;
    String name;

    String country;
    String province;
    String city;
    String address;
    int    zipCode;

    long birthday;

    double height;
    double weigth;

    ...
}

单元测试:

    @Test
    public void testUser() throws Exception {
        User user = new User(1, "bean_1");
        user.setCountry("中国");
        user.setProvince("广东");
        user.setCity("广州");
        user.setAddress("天河区临江大道海心沙公园");
        user.setZipCode(510000);
        user.setBirthday(631123200);
        user.setHeight(173);
        user.setWeigth(55);
        user.setXX(...);

        .....

        User result = presenter.getUser(1);

        Assert.assertEquals(result.getId(), 1);
        Assert.assertEquals(result.getName(), "bean_1");
        Assert.assertEquals(result.getCountry(), "中国");
        Assert.assertEquals(result.getProvince(), "广东");
        Assert.assertEquals(result.getCity(), "广州");
        Assert.assertEquals(result.getAddress(), "天河区临江大道海心沙公园");
        Assert.assertEquals(result.getZipCode(), 510000);
        Assert.assertEquals(result.getBirthday(), 631123200);
        Assert.assertEquals(result.getHeight(), 173);
        Assert.assertEquals(result.getWeigth(), 55);
        Assert.assertEquals(result.getXX(), ...);
        ......
    }

一般形式的单元测试,有10个参数,就要set()10次,get()10次,如果参数更多,一个工程有几十上百个这种测试......感受到那种蛋蛋的痛了吗?

这里有两个痛点

1.生成对象必须 调用所有setter() 赋值成员变量
2.验证返回值,或者回调参数时,必须 调用所有getter() 获取成员值


3.equals()对比对象,可行吗?

直接调用equals()

这时同学A举手了:“不就是比较对象吗,用equal()还不行?”

为了演示方便,还是用回Bean做例子:

    @Test
    public void testGetBean() throws Exception {
        Bean bean = new Bean(1, "bean_1");

        when(dao.get(1)).thenReturn(bean);

        Bean result = presenter.getBean(1);

        Assert.assertTrue(result.equals(bean));
    }

运行一下:

Bean Equals Pass

诶,还真通过了!第一个问题解决了,鼓掌..... 稍等,我们把Presenter代码改改,看还能不能凑效:

public class Presenter {

    public Bean getBean(int id) {
        Bean bean = dao.get(id);

        return new Bean(bean.getId(), bean.getName());
    }
}

再运行单元测试:

Presenter Error

果然出错了!

我们分析一下问题,修改前的Presenter.getBean()方法, dao.get()得到的Bean对象,直接作为返回值,所以PresenterTestAssert.assertTrue(result.equals(bean));通过测试,因为beanresult同一个对象;修改后,Presenter.getBean()里,返回值是dao.get()得到的Bean深拷贝beanresult不同对象,因此result.equals(bean)==false,测试失败。如果我们使用一般形式Assert.assertEquals(result.getXX(), ...);,单元测试是通过的。

无论是直接返回对象,深拷贝,只要参数一致,都符合我们期望的结果。所以,仅仅调用equals()解决不了问题。

重写equals()方法

同学B:“既然只是比较成员值,重写equals()!”

 public class Bean {
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Bean) {
            Bean bean = (Bean) obj;

            boolean isEquals = false;

            if (isEquals) {
                isEquals = id == bean.getId();
            }

            if (isEquals) {
                isEquals = (name == null && bean.getName() == null) || (name != null && name.equals(bean.getName()));
            }

            return isEquals;
        }

        return false;
    }
}

再次运行单元测试Assert.assertTrue(result.equals(bean));

稍等,这样我们不是回到老路,每个java bean都要重写equals()吗?尽管整个工程下来,总体代码会减少,但这真不是好办法。

反射比较成员值

同学C:“我们可以用反射获取两个对象所有成员值,并逐一对比。”

哈哈哈,同学C比同学A、B都要聪明点,还会反射!

public class PresenterTest{
    @Test
    public void testGetBean() throws Exception {
        ...
        ObjectHelper.assertEquals(bean, result);
    }
}
public class ObjectHelper {

    public static boolean assertEquals(Object expect, Object actual) throws IllegalAccessException {
        if (expect == actual) {
            return true;
        }

        if (expect == null && actual != null || expect != null && actual == null) {
            return false;
        }

        if (expect != null) {
            Class clazz = expect.getClass();

            while (!(clazz.equals(Object.class))) {
                Field[] fields = clazz.getDeclaredFields();

                for (Field field : fields) {
                    field.setAccessible(true);

                    Object value0 = field.get(expect);
                    Object value1 = field.get(actual);

                    Assert.assertEquals(value0, value1);
                }

                clazz = clazz.getSuperclass();
            }
        }

        return true;
    }
}

运行单元测试,通过!

用反射直接对比成员值,思路是正确的。这里解决了“对比两个对象的成员值是否相同,不需要get()n次”问题。不过,仅仅比较两个对象,这个单元测试还是有问题的。我们先讲第4节,这个问题留在第5节给大家说明。


4.省略不必要setter()

testUser()中,第一个痛点:“生成对象必须 调用所有setter() 赋值成员变量”。 上一节同学C用反射方案,把对象成员值拿出来,逐一比较。这个方案提醒了我们,赋值也可以同样方案。

ObjectHelper:

public class ObjectHelper {

    protected static final List numberTypes = Arrays.asList(int.class, long.class, double.class, float.class, boolean.class);

    public static <T> T random(Class<T> clazz) throws IllegalAccessException, InstantiationException {
        try {
            T obj = newInstance(clazz);

            Class tClass = clazz;

            while (!tClass.equals(Object.class)) {

                Field[] fields = tClass.getDeclaredFields();

                for (Field field : fields) {
                    field.setAccessible(true);

                    Class type      = field.getType();
                    int   modifiers = field.getModifiers();

                    // final 不赋值
                    if (Modifier.isFinal(modifiers)) {
                        continue;
                    }

                    // 随机生成值
                    if (type.equals(Integer.class) || type.equals(int.class)) {
                        field.set(obj, new Random().nextInt(9999));
                    } else if (type.equals(Long.class) || type.equals(long.class)) {
                        field.set(obj, new Random().nextLong());
                    } else if (type.equals(Double.class) || type.equals(double.class)) {
                        field.set(obj, new Random().nextDouble());
                    } else if (type.equals(Float.class) || type.equals(float.class)) {
                        field.set(obj, new Random().nextFloat());
                    } else if (type.equals(Boolean.class) || type.equals(boolean.class)) {
                        field.set(obj, new Random().nextBoolean());
                    } else if (CharSequence.class.isAssignableFrom(type)) {
                        String name = field.getName();
                        field.set(obj, name + "_" + (int) (Math.random() * 1000));
                    }
                }
                tClass = tClass.getSuperclass();
            }
            return obj;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    protected static <T> T newInstance(Class<T> clazz) throws IllegalAccessException, InvocationTargetException, InstantiationException {

        Constructor constructor = clazz.getConstructors()[0];// 构造函数可能是多参数

        Class[] types = constructor.getParameterTypes();

        List<Object> params = new ArrayList<>();

        for (Class type : types) {
            if (Number.class.isAssignableFrom(type) || numberTypes.contains(type)) {
                params.add(0);
            } else {
                params.add(null);
            }
        }

        T obj = (T) constructor.newInstance(params.toArray());//clazz.newInstance();

        return obj;
    }
}

写个单元测试,生成并随机赋值的Bean,输出Bean所有成员值:

@Test
public void testNewBean() throws Exception {
    Bean bean = ObjectHelpter.random(Bean.class);

    // 输出bean
    System.out.println(bean.toString()); // toString()读者自己重写一下吧
}

运行测试:

Bean {id: 5505, name: "name_145"}

修改单元测试

单元测试PresenterTest

public class PresenterTest {
    @Test
    public void testUser() throws Exception {
        User expect = ObjectHelper.random(User.class);

        when(dao.getUser(1)).thenReturn(expect);

        User actual = presenter.getUser(1);

        ObjectHelper.assertEquals(expect, actual);
    }
}

代码少了许多,很爽有没有?

运行一下,通过:

Presenter Test User Pass

5.比较对象bug

上述笔者提到的解决方案,有一个问题,看以下代码:

Presenter

public class Presenter {

    DAO dao;

    public Bean getBean(int id) {
        Bean bean = dao.get(id);

        // 临时修改bean值
        bean.setName("我来捣乱");

        return new Bean(bean.getId(), bean.getName());
    }
}
    @Test
    public void testGetBean() throws Exception {
        Bean expect = random(Bean.class);

        System.out.println("expect: " + expect);// 提前输出expect

        when(dao.get(1)).thenReturn(expect);

        Bean actual = presenter.getBean(1);

        System.out.println("actual: " + actual);// 输出结果

        ObjectHelper.assertEquals(expect, actual);
    }

运行一下修改后的单元测试:

Pass
expect: Bean {id=3282, name='name_954'}
actual: Bean {id=3282, name='我来捣乱'}

test bean

居然通过了!(不符合预期结果)这是怎么回事?

笔者给大家分析下:我们希望返回的结果是Bean{id=3282, name='name_954'},但是在Presentermock指定的返回对象Bean被修改了,同时返回的Bean深拷贝对象,变量name也跟着变;运行单元测试时,在最后才比较两个对象的成员值,两个对象的name都被修改了,导致equals()认为是正确。

这里的问题:

Presenter内部篡改了mock指定返回对象的成员值

最简单的解决方法:

在调用Presenter方法前,把的mock返回对象的成员参数,提前拿出来,在单元测试最后比较。

修改单元测试:

    @Test
    public void testGetBean() throws Exception {
        Bean   expect = random(Bean.class);
        int    id     = expect.getId();
        String name   = expect.getName();

        when(dao.get(1)).thenReturn(expect);

        Bean actual = presenter.getBean(1);

        //  ObjectHelper.assertEquals(expect, actual);

        Assert.assertEquals(id, actual.getId());
        Assert.assertEquals(name, actual.getName());
    }

运行,测试不通过(符合预期结果):

org.junit.ComparisonFailure:
Expected :name_825
Actual :我来捣乱

test bean error

符合我们期望值(测试不通过)!等等....这不就回到老路了吗?当有很多成员变量,不就写到手软?前面讲的都白费了?
接下来,进入本文高潮


6.解决方案1:提前深拷贝expect对象

public class ObjectHelpter {
    public static <T> T copy(T source) throws IllegalAccessException, InstantiationException, InvocationTargetException {
        Class<T> clazz = (Class<T>) source.getClass();

        T obj = newInstance(clazz);

        Class tClass = clazz;

        while (!tClass.equals(Object.class)) {

            Field[] fields = tClass.getDeclaredFields();

            for (Field field : fields) {
                field.setAccessible(true);

                Object value = field.get(source);

                field.set(obj, value);
            }
            tClass = tClass.getSuperclass();
        }
        return obj;
    }
}

单元测试:

    @Test
    public void testGetBean() throws Exception {
        Bean bean   = ObjectHelpter.random(Bean.class);
        Bean expect = ObjectHelpter.copy(bean);

        when(dao.get(1)).thenReturn(bean);

        Bean actual = presenter.getBean(1);
        
        ObjectHelpter.assertEquals(expect, actual);
    }

运行一下,测试不通过,great(符合想要的结果):

test bean error

我们把Presenter改回去:

public class Presenter {
    DAO dao;

    public Bean getBean(int id) {
        Bean bean = dao.get(id);

//      bean.setName("我来捣乱");

        return new Bean(bean.getId(), bean.getName());
    }
}

再运行单元测试,通过:

test bean pass

7.解决方案2:对象->JSON,比较JSON

看到这节标题,大家都明白怎么回事了吧。例子中,我们会用到Gson

Gson

public class PresenterTest{
    @Test
    public void testBean() throws Exception {
        Bean   bean       = random(Bean.class);
        String expectJson = new Gson().toJson(bean);

        when(dao.get(1)).thenReturn(bean);

        Bean actual = presenter.getBean(1);

        Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class));
    }
}   

运行:

test json string pass

测试失败的场景:

    @Test
    public void testBean() throws Exception {
        Bean   bean       = random(Bean.class);
        String expectJson = new Gson().toJson(bean);

        when(dao.get(1)).thenReturn(bean);

        Bean actual = presenter.getBean(1);
        actual.setName("我来捣乱");// 故意让单元测试出错

        Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class));
    }

运行,测试不通过(符合预计结果):

test json string error

咋看没什么问题。但如果成员变量很多,这时单元测试报错呢?

    @Test
    public void testUser() throws Exception {
        User   user       = random(User.class);
        String expectJson = new Gson().toJson(user);

        when(dao.getUser(1)).thenReturn(user);

        User actual = presenter.getUser(1);
        actual.setWeigth(10);// 错误值

        Assert.assertEquals(expectJson, new Gson().toJson(actual, User.class));
    }
test json string error

你看出哪里错了吗?你要把窗口滚动到右边,才看到哪个字段不一样;而且当对象比较复杂,就更难看了。怎么才能更人性化提示?

JsonUnit

笔者给大家介绍一个很强大的json比较库——Json Unit.

gradle引入:

dependencies {
    compile group: 'net.javacrumbs.json-unit', name: 'json-unit', version: '1.16.0'
}

maven引入:

<dependency>
    <groupId>net.javacrumbs.json-unit</groupId>
    <artifactId>json-unit</artifactId>
    <version>1.16.0</version>
</dependency>
import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals;

@Test
public void testUser() throws Exception {
    User   user       = random(User.class);
    String expectJson = new Gson().toJson(user);

    when(dao.getUser(1)).thenReturn(user);

    User actual = presenter.getUser(1);
    actual.setWeigth(10);// 错误值

    assertJsonEquals(expectJson, actual);
}

运行,测试不通过(符合预期结果):

json unit error

读者可以看到Different value found in node "weigth". Expected 0.005413020868182183, got 10.0.,意思节点weigth期望值0.005413020868182183,但是实际值10.0

无论json多复杂,JsonUnit都可以显示哪个字段不同,让使用者最直观地定位问题。JsonUnit还有很多好处,前后参数可以json+对象,不要求都是json或都是对象;对比List时,可以忽略List顺序.....

DAO

public class DAO {

    public List<Bean> getBeans() {
        return ...; // sql、sharePreference操作等
    }
}

Presenter

public class Presenter {
    DAO dao;
    
    public List<Bean> getBeans() {
        List<Bean> result = dao.getBeans();

        Collections.reverse(result); // 反转列表 

        return result;
    }
}

PresenterTest

    @Test
    public void testList() throws Exception {
        Bean bean0 = random(Bean.class);
        Bean bean1 = random(Bean.class);

        List<Bean> list       = Arrays.asList(bean0, bean1);
        String     expectJson = new Gson().toJson(list);

        when(dao.getBeans()).thenReturn(list);

        List<Bean> actual = presenter.getBeans();
        
        Assert.assertEquals(expectJson, new Gson().toJson(actual));
    }

运行,单元测试不通过(预期结果):

test list error

对于junit来说,列表顺序不同,生成的json string不同,junit报错。对于“代码非常在意列表顺序”场景,这逻辑是正确的。但是很多时候,我们并不那么在意列表顺序。这种场景下,junit + gson就蛋疼了,但是JsonUnit可以简单地解决:

    @Test
    public void testList() throws Exception {
        Bean bean0 = random(Bean.class);
        Bean bean1 = random(Bean.class);

        List<Bean> list       = Arrays.asList(bean0, bean1);
        String     expectJson = new Gson().toJson(list);

        when(dao.getBeans()).thenReturn(list);

        List<Bean> actual = presenter.getBeans();

        //      Assert.assertEquals(expectJson, new Gson().toJson(actual));

        // expect是json,actual是对象,jsonUnit都没问题
        assertJsonEquals(expectJson, actual, JsonAssert.when(Option.IGNORING_ARRAY_ORDER));
    }

运行单元测试,通过:

test list pass

JsonUnit还有很多用法,读者可以上github看看介绍,有大量测试用例,供使用者参考。

解析json的场景

对于测试json解析的场景,JsonUnit的简介就更明显了。

public class Presenter {
    public Bean parse(String json) {
        return new Gson().fromJson(json, Bean.class);
    }
}
    @Test
    public void testParse() throws Exception {
        String json = "{\"id\":1,\"name\":\"bean\"}";

        Bean actual = presenter.parse(json);

        assertJsonEquals(json, actual);
    }

运行,测试通过:

parse json jsonunit

一个json,一个bean作为参数,都没问题;如果是Gson的话,还要把Bean转成json去比较。


小结

感觉这次谈了没多少东西,但文章很冗长,繁杂的代码挺多。唠唠叨叨地讲了一大堆,不知道读者有没看明白,本文写作顺序,就是笔者当时探索校验参数的经历。这次没什么高大上的概念,就是基础的、容易忽略的东西,在单元测试中也十分好用,希望读者好好体会。

单元测试的细节,已经讲得七七八八了。下一篇再指导一下项目使用单元测试,单元测试的系列就差不多完结。当然以后有更多心得,还会写的。


关于作者

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

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,494评论 18 139
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,493评论 18 399
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,047评论 25 707
  • 一场旅行是恋爱中的恋爱 普通的恋爱是我和你 旅行的恋爱是我们和风景 我们期待 我们迷路 我们寻觅 我们赶路 我们眺...
    妖妖喵喵喵阅读 299评论 1 0
  • 写在前面: Android崩溃是我们开发中不可避免的异常处理,通常崩溃都会触发系统的强制关闭对话框,用户点击后系统...
    酱爆大头菜阅读 2,961评论 2 15