原文链接:http://www.jianshu.com/p/77ee7c0270bc
前言
读者有没发觉我写文章时,喜欢有个前言、序?真相是,一半用来装逼凑字数,一半是因为不知道接下来要写什么,先闲聊几句压压惊^_^ 哈哈哈......该说的还是要说。
上一篇《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");
- }
- }
这个单元测试是通过的。
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次,如果参数更多,一个工程有几十上百个这种测试......感受到那种蛋蛋的痛了吗?
这里有两个痛点:
- 生成对象必须 调用所有setter() 赋值成员变量
- 验证返回值,或者回调参数时,必须 调用所有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));
- }
运行一下:
诶,还真通过了!第一个问题解决了,鼓掌..... 稍等,我们把Presenter代码改改,看还能不能凑效:
- public class Presenter {
- public Bean getBean(int id) {
- Bean bean = dao.get(id);
- return new Bean(bean.getId(), bean.getName());
- }
- }
再运行单元测试:
果然出错了!
我们分析一下问题,修改前的Presenter.getBean()方法, dao.get()得到的Bean对象,直接作为返回值,所以PresenterTest中Assert.assertTrue(result.equals(bean));通过测试,因为bean和result是同一个对象;修改后,Presenter.getBean()里,返回值是dao.get()得到的Bean的深拷贝,bean和result是不同对象,因此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);
- }
- }
代码少了许多,很爽有没有?
运行一下,通过:
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='我来捣乱'}
居然通过了!(不符合预期结果)这是怎么回事?
笔者给大家分析下:我们希望返回的结果是Bean{id=3282, name='name_954'},但是在Presenter里mock指定的返回对象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 :我来捣乱
符合我们期望值(测试不通过)!等等....这不就回到老路了吗?当有很多成员变量,不就写到手软?前面讲的都白费了?
接下来,进入本文高潮。
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(符合想要的结果):
我们把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());
- }
- }
再运行单元测试,通过:
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
- 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
- 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));
- }
你看出哪里错了吗?你要把窗口滚动到右边,才看到哪个字段不一样;而且当对象比较复杂,就更难看了。怎么才能更人性化提示?
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);
- }
运行,测试不通过(符合预期结果):
读者可以看到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));
- }
运行,单元测试不通过(预期结果):
对于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));
- }
运行单元测试,通过:
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);
- }
运行,测试通过:
一个json,一个bean作为参数,都没问题;如果是Gson的话,还要把Bean转成json去比较。
小结
感觉这次谈了没多少东西,但文章很冗长,繁杂的代码挺多。唠唠叨叨地讲了一大堆,不知道读者有没看明白,本文写作顺序,就是笔者当时探索校验参数的经历。这次没什么高大上的概念,就是基础的、容易忽略的东西,在单元测试中也十分好用,希望读者好好体会。
单元测试的细节,已经讲得七七八八了。下一篇再指导一下项目使用单元测试,单元测试的系列就差不多完结。当然以后有更多心得,还会写的。
关于作者
我是键盘男。在广州生活,在互联网公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。