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

前言

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

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

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

1.一般形式

Bean


  1. public class Bean { 
  2.     int    id; 
  3.     String name
  4.  
  5.     public Bean(int id, String name) { 
  6.         this.id = id; 
  7.         this.name = name
  8.     } 
  9.     // getter and setter 
  10.     ...... 
  11. }  

DAO


  1. public class DAO { 
  2.     public Bean get(int id) { 
  3.         return new Bean(id, "bean_" + id); 
  4.     } 
  5. }  

Presenter


  1. public class Presenter { 
  2.  
  3.     DAO dao; 
  4.  
  5.     public Presenter(DAO dao) { 
  6.         this.dao = dao; 
  7.     } 
  8.  
  9.     public Bean getBean(int id) { 
  10.         Bean bean = dao.get(id); 
  11.  
  12.         return bean; 
  13.     } 
  14. }  

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


  1. public class PresenterTest { 
  2.  
  3.     DAO       dao; 
  4.     Presenter presenter; 
  5.  
  6.     @Before 
  7.     public void setUp() throws Exception { 
  8.         dao = mock(DAO.class); 
  9.         presenter = new Presenter(dao); 
  10.     } 
  11.  
  12.     @Test 
  13.     public void testGetBean() throws Exception { 
  14.         Bean bean = new Bean(1, "bean_1"); 
  15.  
  16.         when(dao.get(1)).thenReturn(bean); 
  17.  
  18.         Bean result = presenter.getBean(1); 
  19.  
  20.         Assert.assertEquals(result.getId(), 1); 
  21.         Assert.assertEquals(result.getName(), "bean_1"); 
  22.     } 
  23. }  

这个单元测试是通过的。

2.问题:对象很多变量

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


  1. public class User { 
  2.     int    id; 
  3.     String name
  4.  
  5.     String country; 
  6.     String province; 
  7.     String city; 
  8.     String address; 
  9.     int    zipCode; 
  10.  
  11.     long birthday; 
  12.  
  13.     double height; 
  14.     double weigth; 
  15.  
  16.     ... 
  17. }  

单元测试:


  1. @Test 
  2.    public void testUser() throws Exception { 
  3.        User user = new User(1, "bean_1"); 
  4.        user.setCountry("中国"); 
  5.        user.setProvince("广东"); 
  6.        user.setCity("广州"); 
  7.        user.setAddress("天河区临江大道海心沙公园"); 
  8.        user.setZipCode(510000); 
  9.        user.setBirthday(631123200); 
  10.        user.setHeight(173); 
  11.        user.setWeigth(55); 
  12.        user.setXX(...); 
  13.  
  14.        ..... 
  15.  
  16.        User result = presenter.getUser(1); 
  17.  
  18.        Assert.assertEquals(result.getId(), 1); 
  19.        Assert.assertEquals(result.getName(), "bean_1"); 
  20.        Assert.assertEquals(result.getCountry(), "中国"); 
  21.        Assert.assertEquals(result.getProvince(), "广东"); 
  22.        Assert.assertEquals(result.getCity(), "广州"); 
  23.        Assert.assertEquals(result.getAddress(), "天河区临江大道海心沙公园"); 
  24.        Assert.assertEquals(result.getZipCode(), 510000); 
  25.        Assert.assertEquals(result.getBirthday(), 631123200); 
  26.        Assert.assertEquals(result.getHeight(), 173); 
  27.        Assert.assertEquals(result.getWeigth(), 55); 
  28.        Assert.assertEquals(result.getXX(), ...); 
  29.        ...... 
  30.    }  

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

这里有两个痛点:

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

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

直接调用equals()

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

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


  1. @Test 
  2.     public void testGetBean() throws Exception { 
  3.         Bean bean = new Bean(1, "bean_1"); 
  4.  
  5.         when(dao.get(1)).thenReturn(bean); 
  6.  
  7.         Bean result = presenter.getBean(1); 
  8.  
  9.         Assert.assertTrue(result.equals(bean)); 
  10.     }  

运行一下:

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


  1. public class Presenter { 
  2.  
  3.     public Bean getBean(int id) { 
  4.         Bean bean = dao.get(id); 
  5.  
  6.         return new Bean(bean.getId(), bean.getName()); 
  7.     } 
  8. }  

再运行单元测试:

Android单元测试 - 验证函数参数、返回值的正确姿势_第1张图片

果然出错了!

我们分析一下问题,修改前的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()!”


  1. public class Bean { 
  2.     @Override 
  3.     public boolean equals(Object obj) { 
  4.         if (obj instanceof Bean) { 
  5.             Bean bean = (Bean) obj; 
  6.  
  7.             boolean isEquals = false
  8.  
  9.             if (isEquals) { 
  10.                 isEquals = id == bean.getId(); 
  11.             } 
  12.  
  13.             if (isEquals) { 
  14.                 isEquals = (name == null && bean.getName() == null) || (name != null && name.equals(bean.getName())); 
  15.             } 
  16.  
  17.             return isEquals; 
  18.         } 
  19.  
  20.         return false
  21.     } 
  22. }  

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

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

反射比较成员值

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

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


  1. public class PresenterTest{ 
  2.     @Test 
  3.     public void testGetBean() throws Exception { 
  4.         ... 
  5.         ObjectHelper.assertEquals(bean, result); 
  6.     } 
  7. }  

  1. public class ObjectHelper { 
  2.  
  3.     public static boolean assertEquals(Object expect, Object actual) throws IllegalAccessException { 
  4.         if (expect == actual) { 
  5.             return true
  6.         } 
  7.  
  8.         if (expect == null && actual != null || expect != null && actual == null) { 
  9.             return false
  10.         } 
  11.  
  12.         if (expect != null) { 
  13.             Class clazz = expect.getClass(); 
  14.  
  15.             while (!(clazz.equals(Object.class))) { 
  16.                 Field[] fields = clazz.getDeclaredFields(); 
  17.  
  18.                 for (Field field : fields) { 
  19.                     field.setAccessible(true); 
  20.  
  21.                     Object value0 = field.get(expect); 
  22.                     Object value1 = field.get(actual); 
  23.  
  24.                     Assert.assertEquals(value0, value1); 
  25.                 } 
  26.  
  27.                 clazz = clazz.getSuperclass(); 
  28.             } 
  29.         } 
  30.  
  31.         return true
  32.     } 
  33. }  

运行单元测试,通过!

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

4.省略不必要setter()

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

ObjectHelper:


  1. public class ObjectHelper { 
  2.  
  3.     protected static final List numberTypes = Arrays.asList(int.class, long.class, double.class, float.class, boolean.class); 
  4.  
  5.     public static  T random(Class clazz) throws IllegalAccessException, InstantiationException { 
  6.         try { 
  7.             T obj = newInstance(clazz); 
  8.  
  9.             Class tClass = clazz; 
  10.  
  11.             while (!tClass.equals(Object.class)) { 
  12.  
  13.                 Field[] fields = tClass.getDeclaredFields(); 
  14.  
  15.                 for (Field field : fields) { 
  16.                     field.setAccessible(true); 
  17.  
  18.                     Class type      = field.getType(); 
  19.                     int   modifiers = field.getModifiers(); 
  20.  
  21.                     // final 不赋值 
  22.                     if (Modifier.isFinal(modifiers)) { 
  23.                         continue
  24.                     } 
  25.  
  26.                     // 随机生成值 
  27.                     if (type.equals(Integer.class) || type.equals(int.class)) { 
  28.                         field.set(obj, new Random().nextInt(9999)); 
  29.                     } else if (type.equals(Long.class) || type.equals(long.class)) { 
  30.                         field.set(obj, new Random().nextLong()); 
  31.                     } else if (type.equals(Double.class) || type.equals(double.class)) { 
  32.                         field.set(obj, new Random().nextDouble()); 
  33.                     } else if (type.equals(Float.class) || type.equals(float.class)) { 
  34.                         field.set(obj, new Random().nextFloat()); 
  35.                     } else if (type.equals(Boolean.class) || type.equals(boolean.class)) { 
  36.                         field.set(obj, new Random().nextBoolean()); 
  37.                     } else if (CharSequence.class.isAssignableFrom(type)) { 
  38.                         String name = field.getName(); 
  39.                         field.set(obj, name + "_" + (int) (Math.random() * 1000)); 
  40.                     } 
  41.                 } 
  42.                 tClass = tClass.getSuperclass(); 
  43.             } 
  44.             return obj; 
  45.         } catch (Exception e) { 
  46.             e.printStackTrace(); 
  47.         } 
  48.         return null
  49.     } 
  50.  
  51.     protected static  T newInstance(Class clazz) throws IllegalAccessException, InvocationTargetException, InstantiationException { 
  52.  
  53.         Constructor constructor = clazz.getConstructors()[0];// 构造函数可能是多参数 
  54.  
  55.         Class[] types = constructor.getParameterTypes(); 
  56.  
  57.         List params = new ArrayList<>(); 
  58.  
  59.         for (Class type : types) { 
  60.             if (Number.class.isAssignableFrom(type) || numberTypes.contains(type)) { 
  61.                 params.add(0); 
  62.             } else { 
  63.                 params.add(null); 
  64.             } 
  65.         } 
  66.  
  67.         T obj = (T) constructor.newInstance(params.toArray());//clazz.newInstance(); 
  68.  
  69.         return obj; 
  70.     } 
  71. }  
  72. 写个单元测试,生成并随机赋值的Bean,输出Bean所有成员值:

    
    
    1. @Test 
    2. public void testNewBean() throws Exception { 
    3.     Bean bean = ObjectHelpter.random(Bean.class); 
    4.  
    5.     // 输出bean 
    6.     System.out.println(bean.toString()); // toString()读者自己重写一下吧 
    7. }  

    运行测试:

    
    
    1. Bean {id: 5505, name"name_145"

    修改单元测试

    单元测试PresenterTest:

    
    
    1. public class PresenterTest { 
    2.     @Test 
    3.     public void testUser() throws Exception { 
    4.         User expect = ObjectHelper.random(User.class); 
    5.  
    6.         when(dao.getUser(1)).thenReturn(expect); 
    7.  
    8.         User actual = presenter.getUser(1); 
    9.  
    10.         ObjectHelper.assertEquals(expect, actual); 
    11.     } 
    12. }  

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

    运行一下,通过:

    5.比较对象bug

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

    Presenter:

    
    
    1. public class Presenter { 
    2.  
    3.     DAO dao; 
    4.  
    5.     public Bean getBean(int id) { 
    6.         Bean bean = dao.get(id); 
    7.  
    8.         // 临时修改bean值 
    9.         bean.setName("我来捣乱"); 
    10.  
    11.         return new Bean(bean.getId(), bean.getName()); 
    12.     } 
    13. }  
    
    
    1. @Test 
    2.     public void testGetBean() throws Exception { 
    3.         Bean expect = random(Bean.class); 
    4.  
    5.         System.out.println("expect: " + expect);// 提前输出expect 
    6.  
    7.         when(dao.get(1)).thenReturn(expect); 
    8.  
    9.         Bean actual = presenter.getBean(1); 
    10.  
    11.         System.out.println("actual: " + actual);// 输出结果 
    12.  
    13.         ObjectHelper.assertEquals(expect, actual); 
    14.     } 

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

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第2张图片

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

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

    这里的问题:

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

    最简单的解决方法:

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

    修改单元测试:

    
    
    1. @Test 
    2.     public void testGetBean() throws Exception { 
    3.         Bean   expect = random(Bean.class); 
    4.         int    id     = expect.getId(); 
    5.         String name   = expect.getName(); 
    6.  
    7.         when(dao.get(1)).thenReturn(expect); 
    8.  
    9.         Bean actual = presenter.getBean(1); 
    10.  
    11.         //    ObjectHelper.assertEquals(expect, actual); 
    12.  
    13.         Assert.assertEquals(id, actual.getId()); 
    14.         Assert.assertEquals(name, actual.getName()); 
    15.     }  

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

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第3张图片

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

    接下来,进入本文高潮。

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

    
    
    1. public class ObjectHelpter { 
    2.     public static  T copy(T source) throws IllegalAccessException, InstantiationException, InvocationTargetException { 
    3.         Class clazz = (Class) source.getClass(); 
    4.  
    5.         T obj = newInstance(clazz); 
    6.  
    7.         Class tClass = clazz; 
    8.  
    9.         while (!tClass.equals(Object.class)) { 
    10.  
    11.             Field[] fields = tClass.getDeclaredFields(); 
    12.  
    13.             for (Field field : fields) { 
    14.                 field.setAccessible(true); 
    15.  
    16.                 Object value = field.get(source); 
    17.  
    18.                 field.set(obj, value); 
    19.             } 
    20.             tClass = tClass.getSuperclass(); 
    21.         } 
    22.         return obj; 
    23.     } 
    24. }  

    单元测试:

    
    
    1. @Test 
    2.    public void testGetBean() throws Exception { 
    3.        Bean bean   = ObjectHelpter.random(Bean.class); 
    4.        Bean expect = ObjectHelpter.copy(bean); 
    5.  
    6.        when(dao.get(1)).thenReturn(bean); 
    7.  
    8.        Bean actual = presenter.getBean(1); 
    9.         
    10.        ObjectHelpter.assertEquals(expect, actual); 
    11.    }  

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第4张图片

    我们把Presenter改回去:

    
    
    1. public class Presenter { 
    2.     DAO dao; 
    3.  
    4.     public Bean getBean(int id) { 
    5.         Bean bean = dao.get(id); 
    6.  
    7. //        bean.setName("我来捣乱"); 
    8.  
    9.         return new Bean(bean.getId(), bean.getName()); 
    10.     } 
    11. }  

    再运行单元测试,通过:

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

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

    Gson

    
    
    1. public class PresenterTest{ 
    2.     @Test 
    3.     public void testBean() throws Exception { 
    4.         Bean   bean       = random(Bean.class); 
    5.         String expectJson = new Gson().toJson(bean); 
    6.  
    7.         when(dao.get(1)).thenReturn(bean); 
    8.  
    9.         Bean actual = presenter.getBean(1); 
    10.  
    11.         Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class)); 
    12.     } 
    13. }   

    运行:

    测试失败的场景:

    
    
    1. @Test 
    2.     public void testBean() throws Exception { 
    3.         Bean   bean       = random(Bean.class); 
    4.         String expectJson = new Gson().toJson(bean); 
    5.  
    6.         when(dao.get(1)).thenReturn(bean); 
    7.  
    8.         Bean actual = presenter.getBean(1); 
    9.         actual.setName("我来捣乱");// 故意让单元测试出错 
    10.  
    11.         Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class)); 
    12.     } 

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第5张图片

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

    
    
    1. @Test 
    2.     public void testUser() throws Exception { 
    3.         User   user       = random(User.class); 
    4.         String expectJson = new Gson().toJson(user); 
    5.  
    6.         when(dao.getUser(1)).thenReturn(user); 
    7.  
    8.         User actual = presenter.getUser(1); 
    9.         actual.setWeigth(10);// 错误值 
    10.  
    11.         Assert.assertEquals(expectJson, new Gson().toJson(actual, User.class)); 
    12.     }  

    Android单元测试 - 验证函数参数、返回值的正确姿势_第6张图片

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

    JsonUnit

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

    gradle引入:

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

    maven引入:

    
    
    1.  
    2.     net.javacrumbs.json-unit 
    3.     json-unit 
    4.     1.16.0 
    5.   
    
    
    1. import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals; 
    2.  
    3. @Test 
    4. public void testUser() throws Exception { 
    5.     User   user       = random(User.class); 
    6.     String expectJson = new Gson().toJson(user); 
    7.  
    8.     when(dao.getUser(1)).thenReturn(user); 
    9.  
    10.     User actual = presenter.getUser(1); 
    11.     actual.setWeigth(10);// 错误值 
    12.  
    13.     assertJsonEquals(expectJson, actual); 
    14. }  

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第7张图片

    读者可以看到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

    
    
    1. public class DAO { 
    2.  
    3.     public List getBeans() { 
    4.         return ...; // sql、sharePreference操作等 
    5.     } 
    6. }  

    Presenter

    
    
    1. public class Presenter { 
    2.     DAO dao; 
    3.      
    4.     public List getBeans() { 
    5.         List result = dao.getBeans(); 
    6.  
    7.         Collections.reverse(result); // 反转列表  
    8.  
    9.         return result; 
    10.     } 
    11. }  

    PresenterTest

    
    
    1. @Test 
    2.     public void testList() throws Exception { 
    3.         Bean bean0 = random(Bean.class); 
    4.         Bean bean1 = random(Bean.class); 
    5.  
    6.         List list       = Arrays.asList(bean0, bean1); 
    7.         String     expectJson = new Gson().toJson(list); 
    8.  
    9.         when(dao.getBeans()).thenReturn(list); 
    10.  
    11.         List actual = presenter.getBeans(); 
    12.          
    13.         Assert.assertEquals(expectJson, new Gson().toJson(actual)); 
    14.     }  

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

    Android单元测试 - 验证函数参数、返回值的正确姿势_第8张图片

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

    
    
    1. @Test 
    2.     public void testList() throws Exception { 
    3.         Bean bean0 = random(Bean.class); 
    4.         Bean bean1 = random(Bean.class); 
    5.  
    6.         List list       = Arrays.asList(bean0, bean1); 
    7.         String     expectJson = new Gson().toJson(list); 
    8.  
    9.         when(dao.getBeans()).thenReturn(list); 
    10.  
    11.         List actual = presenter.getBeans(); 
    12.  
    13.         //        Assert.assertEquals(expectJson, new Gson().toJson(actual)); 
    14.  
    15.         // expect是json,actual是对象,jsonUnit都没问题 
    16.         assertJsonEquals(expectJson, actual, JsonAssert.when(Option.IGNORING_ARRAY_ORDER)); 
    17.     }  

    运行单元测试,通过:

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

    解析json的场景

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

    
    
    1. public class Presenter { 
    2.     public Bean parse(String json) { 
    3.         return new Gson().fromJson(json, Bean.class); 
    4.     } 
    5.     @Test 
    6.     public void testParse() throws Exception { 
    7.         String json = "{\"id\":1,\"name\":\"bean\"}"
    8.  
    9.         Bean actual = presenter.parse(json); 
    10.  
    11.         assertJsonEquals(json, actual); 
    12.     }  

    运行,测试通过:

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

    小结

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

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

    关于作者

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


    作者:键盘男

    来源:51CTO

    你可能感兴趣的:(测试,移动开发,json)