引言
对于软件开发人员来说,单元测试是一项必不可少的工作。它既可以验证程序的有效性,又可以在程序出现 BUG 的时候,帮助开发人员快速的定位问题所在。但是,在写单元测试的过程中,开发人员经常要访问类的一些非公有的成员变量或方法,这给测试工作带来了很大的困扰。本文总结了访问类的非公有成员变量或方法的四种途径,以方便测试人员在需要访问类非公有成员变量或方法时进行选择。
尽管有很多经验丰富的程序员认为不应该提倡访问类的私有成员变量或方法,因为这样做违反了 Java 语言封装性的基本规则。然而,在实际测试中被测试的对象千奇百怪,为了有效快速的进行单元测试,有时我们不得不违反一些这样或那样的规则。本文只讨论如何访问类的非公有成员变量或方法,至于是否应该在开发测试中这样做,则留给读者自己根据实际情况去判断和选择。
方法一:修改访问权限修饰符
先介绍最简单也是最直接的方法,就是利用 Java 语言自身的特性,达到访问非公有成员的目的。说白了就是直接将 private 和 protected 关键字改为 public 或者直接删除。我们建议直接删除,因为在 Java 语言定义中,缺省访问修饰符是包可见的。这样做之后,我们可以另建一个源码目录 —— test 目录(多数 IDE 支持这么做,如 Eclipse 和 JBuilder),然后将测试类放到 test 目录相同包下,从而达到访问待测类的成员变量和方法的目的。此时,在其它包的代码依然不能访问这些变量或方法,在一定程度上保障了程序的封装性。
下面的代码示例展示了这一方法。
清单 1. 原始待测类 A 代码
1
2
3
4
5
|
public class A { private String name = null; private void calculate() { } } |
清单 2. 针对单元测试修改后的待测类 A 的代码
1
2
3
4
5
|
public class A { String name = null; private void calculate() { } } |
这种方法虽然看起来简单粗暴,但经验告诉我们这个方法在测试过程中是非常有效的。当然,由于改变了源代码,虽然只是包可见,也已经破坏了对象的封装性,对于多数对代码安全性要求严格的系统此方法并不可取。
方法二:利用安全管理器
安全性管理器与反射机制相结合,也可以达到我们的目的。Java 运行时依靠一种安全性管理器来检验调用代码对某一特定的访问而言是否有足够的权限。具体来说,安全性管理器是 java.lang.SecurityManager 类或扩展自该类的一个类,且它在运行时检查某些应用程序操作的权限。换句话说,所有的对象访问在执行自身逻辑之前都必须委派给安全管理器,当访问受到安全性管理器的控制,应用程序就只能执行那些由相关安全策略特别准许的操作。因此安全管理器一旦启动可以为代码提供足够的保护。默认情况下,安全性管理器是没有被设置的,除非代码明确地安装一个默认的或定制的安全管理器,否则运行时的访问控制检查并不起作用。我们可以通过这一点在运行时避开 Java 的访问控制检查,达到我们访问非公有成员变量或方法的目的。为能访问我们需要的非公有成员,我们还需要使用 Java 反射技术。Java 反射是一种强大的工具,它使我们可以在运行时装配代码,而无需在对象之间进行源代码链接,从而使代码更具灵活性。在编译时,Java 编译程序保证了私有成员的私有特性,从而一个类的私有方法和私有成员变量不能被其他类静态引用。然而,通过 Java 反射机制使得我们可以在运行时查询以及访问变量和方法。由于反射是动态的,因此编译时的检查就不再起作用了。
下面的代码演示了如何利用安全性管理器与反射机制访问私有变量。
清单 3. 利用反射机制访问类的成员变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
//获得指定变量的值 public static Object getValue(Object instance, String fieldName) throws IllegalAccessException, NoSuchFieldException { Field field = getField(instance.getClass(), fieldName); // 参数值为true,禁用访问控制检查 field.setAccessible(true); return field.get(instance); } //该方法实现根据变量名获得该变量的值 public static Field getField(Class thisClass, String fieldName) throws NoSuchFieldException { if (thisClass == null) { throw new NoSuchFieldException("Error field !"); } } |
其中 getField(instance.getClass(), fieldName) 通过反射机制获得对象属性,如果存在安全管理器,方法首先使用 this 和 Member.DECLARED 作为参数调用安全管理器的 checkMemberAccess 方法,这里的 this 是 this 类或者成员被确定的父类。 如果该类在包中,那么方法还使用包名作为参数调用安全管理器的 checkPackageAccess 方法。 每一次调用都可能导致 SecurityException。当访问被拒绝时,这两种调用方式都会产生 securityexception 异常 。
setAccessible(true) 方法通过指定参数值为 true 来禁用访问控制检查,从而使得该变量可以被其他类调用。我们可以在我们所写的类中,扩展一个普通的基本类 java.lang.reflect.AccessibleObject 类。这个类定义了一种 setAccessible 方法,使我们能够启动或关闭对这些类中其中一个类的实例的接入检测。这种方法的问题在于如果使用了安全性管理器,它将检测正在关闭接入检测的代码是否允许这样做。如果未经允许,安全性管理器抛出一个例外。
除访问私有变量,我们也可以通过这个方法访问私有方法。
清单 4. 利用反射机制访问类的成员方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public static Method getMethod(Object instance, String methodName, Class[] classTypes) throws NoSuchMethodException { Method accessMethod = getMethod(instance.getClass(), methodName, classTypes); //参数值为true,禁用访问控制检查 accessMethod.setAccessible(true); return accessMethod; } private static Method getMethod(Class thisClass, String methodName, Class[] classTypes) throws NoSuchMethodException { if (thisClass == null) { throw new NoSuchMethodException("Error method !"); } try { return thisClass.getDeclaredMethod(methodName, classTypes); } catch (NoSuchMethodException e) { return getMethod(thisClass.getSuperclass(), methodName, classTypes); } } |
获得私有方法的原理与获得私有变量的方法相同。当我们得到了函数后,需要对它进行调用,这时我们需要通过 invoke() 方法来执行对该函数的调用,代码示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
//调用含单个参数的方法 public static Object invokeMethod(Object instance, String methodName, Object arg) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { Object[] args = new Object[1]; args[0] = arg; return invokeMethod(instance, methodName, args); } //调用含多个参数的方法 public static Object invokeMethod(Object instance, String methodName, Object[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { Class[] classTypes = null; if (args != null) { classTypes = new Class[args.length]; for (int i = 0; i < args.length; i++) { if (args[i] != null) { classTypes[i] = args[i].getClass(); } } } return getMethod(instance, methodName, classTypes).invoke(instance, args); } |
利用安全管理器及反射,可以在不修改源码的基础上访问私有成员,为测试带来了极大的方便。尤其是在编译期间,该方法可以顺利地通过编译。但同时该方法也有一些缺点。第一个是性能问题,用于字段和方法接入时反射要远慢于直接代码。第二个是权限问题,有些涉及 Java 安全的程序代码并没有修改安全管理器的权限,此时本方法失效。
方法三:使用模仿(Mock)对象
在单元测试的过程中模仿对象被广泛使用。它从测试中分离了外部的不需要的因素,并且帮助开发人员专注于被测试的功能。模仿对象(Mock object)的核心是构造一个伪类,在测试中通常用这个构造的伪类替换原来的需要访问相关环境(如应用服务器,数据库等)的需要测试的待测类,这样单元测试便可以运行在本地环境下(这也是对单元测试的基本要求之一,不依赖于任何特定的环境),并可以正确的执行。此外, 由于 Java 语言不能多继承的特性,使得该方法也可以被用来作为非公有成员变量及方法的访问方法(测试类不能同时继承 TestCase 和待测类),利用该方法,在模仿对象中改变类成员的访问控制权限,从而达到访问非公有类变量及方法的目的。
下面的代码示例演示了模仿对象方法。
本方法的应用场景在单元测试中非常常见,即在待测试的公有方法中,有一些受限制的成员变量是由其它私有方法来初始化的,在测试该方法的时候,需要给这个变量置初值才能完成测试。
清单 5. 待测类 A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public class A { protected String s = null; public A() { } private void method() { s = "word"; System.out.println("this is mock test"); } public void makeWord() { String prefix = s; System.out.println("prefix is:" + prefix); } } |
在待测类 A 中,增加工厂方法。
清单 6. 包含工厂方法的待测类 A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
// 增加工厂方法的类 A public class A { protected String s = null; public A getA() { return new A(); } private void method() { s = "word"; System.out.println("this is mock test"); } public void makeWord() { String prefix = s; System.out.println("prefix is:" + prefix); } } //伪类,在运行时替换类 A public class MockA extends A{ public String s = null; public MockA(){ } } //测试类 public class TestA extends TestCase{ public void setup(){ } public void teardown(){ } public void makeWordTest(){ A a = new MockA(); a.s = "test"; a.makeWord(); } } |
此方法中有几个值得注意的地方,首先是将创建代码抽取到工厂方法中,在测试子类中覆盖该工厂方法,然后令被覆盖的方法返回模仿对象。如果可以的话,添加需要原始对象的工厂方法的单元测试,以返回正确类型的对象。模仿对象方法在处理许多对象依赖基础结构的其它对象或层时, 可以起到很好的效果。模仿对象符合实际对象的接口,但只要有足够的代码来“欺骗”测试对象并跟踪其行为。例如, 在单元测试中需要测试一个使用数据库的对象,或者需要测试连接 J2EE 应用服务器的对象,通常的测试用例需要安装、配置和发送本地数据库副本、运行测试然后再卸装本地数据库或者需要安装、配置应用服务器、运行测试然后再卸装应用服务器,操作可能很麻烦,。模仿对象提供了解决这一困难的途径。对于既需要访问相关环境又要访问非公有变量或方法的类来说,模仿对象非常适合,但是,如果只是访问非公有变量或方法,那么传统的模仿对象法显得有些笨重,可以对该法进行简化,不使用工厂方法,达到同样的效果。
下面的代码示例演示了经过简化的模仿对象方法:
清单 7. 简化的待测类 A 的模仿对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
//伪类,在运行时替换类A public class MockA extends A{ public MockA(){ super(); s = "test"; } } //测试类 public class TestA extends TestCase{ public void setup(){ } public void teardown(){ } public void makeWordTest(){ A a = new MockA(); a.makeWord(); } } |
模仿对象方法既能消除运行环境的影响,又能解决多继承的难题,但是由于该方法使用子类的实例来替代父类的实例,对于私有成员变量及方法来说,仍然不能进行访问。
方法四:利用字节码技术
Java 编译器把 Java 源代码编译成字节码 bytecode(字节码),既然在测试中尽量要避免改变原来的代码,那么最直接的改造 Java 类的方法莫过于直接改写 class 文件。通过修改字节码中的关键字,将私有的成员变量及方法改成公有的成员变量及方法,可以做到在不改变源码的情况下访问到需要的成员变量及方法。Java 规范有 class 文件的格式的详细说明,直接编辑字节码确实可以改变 Java 类的行为,但是这也要求使用者对 Java class 文件有较深的理解。目前,比较流行的字节码处理工具有 Javassist,BCEL 和 ASM 等。这几种工具各有特点,适合于不同的应用场景,如果读者对字节码技术感兴趣,可以阅读后面的参考文献。本文选择利用字节码工具 ASM。
ASM 能被用来动态生成类或者修改既有类的功能。它可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类(.class)。ASM 作为 Java 字节码操控框架,是所有同类工具中效率最高的一个,并且由于其采用了基于 Vistor 模式的框架设计,它也是同类工具中最轻巧灵活的,尽管它的学习台阶相对要高一些,它仍然是达到本文目的的首选。
利用 ASM 访问私有变量及方法,需要了解的比较重要的几个类:ClassReader、ClassVistor、MethodVisitor、FieldVisitor 和 ClassAdaptor 等。ClassReader 类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,通过调用 accept 方法接受一个 ClassVisitor 接口的实现类实例作为参数,然后依次调用 ClassVisitor 接口的各个方法;ClassVisitor 接口中定义了对应 Java 类各个成员的访问函数,比如 visitMethod 会返回一个实现 MethordVisitor 接口的实例,visitField 会返回一个实现 FieldVisitor 接口的实例。不同 Visitor 的组合,可以非常简单的封装对字节码的各种修改;ClassAdaptor 类为 ClassVisitor 接口提供了一个默认实现。创建一个 ClassAdaptor 对象实例时,需要传入一个 ClassVisitor 接口的实现类实例来访问字节吗。因此当我们需要对字节码进行调整时,只需从 ClassAdaptor 类派生出一个子类,覆写需要修改的方法,完成相应功能后再把调用传递到下一个需要修改的 visitor 即可。
本例的应用场景为,要对公有方法 method() 进行单元测试,但是,该方法中有一个私有变量 number 是由另一个私有方法 makePaper() 付值,所以,需要在测试中为该私有变量置初值。
清单 8. 待测类 A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class A{ private String number = “”; public void method() { if(number.eaquals(“prefix”)) System.out.println("method..."+number); else System.out.println(number +”is null”); } private void makePaper() { number=”prefix”; System.out.println("makePaper..."); } } |
清单 9. 使用字节码访问类 A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
//修改变量的修饰符 public class AccessClassAdapter extends ClassAdapter { public AccessClassAdapter(ClassVisitor cv) { super(cv); } public FieldVisitor visitField(final int access, String name, final String desc, final String signature, final Object value) { int privateAccess = access; //找到名字为number的变量 if (name.equals("number")) privateAccess = Opcodes.ACC_PUBLIC; //修字段的修饰符为public:在职责链传递过程中替换调用参数 return cv.visitField(privateAccess, name, desc, signature, value); } public static void main(String[] args) throws Exception { ClassReader cr = new ClassReader("A"); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); ClassAdapter classAdapter = new AccessClassAdapter(cw); cr.accept(classAdapter, ClassReader.SKIP_DEBUG); byte[] data = cw.toByteArray(); //生成新的字节码文件 File file = new File("A.class"); FileOutputStream fout = new FileOutputStream(file); fout.write(data); fout.close(); } } |
执行完该类,将产生一个新的 A.class 文件。
测试类测试 method 方法,先对变量进行置初值,然后就可以像其他单元测试一样,对 method 方法进行测试。
方法对比
方法 | 修饰符 | 使用难度 | 缺陷 | ||
---|---|---|---|---|---|
protected | 缺省 | private | |||
方法一:修改访问权限修饰符 | 是 | 是 | 是 | 低,有java编程基础即可。 | 由于需要修改源代码,虽然是同包可见,也会带来一些封闭性的问题。 |
方法二:利用安全性管理器 | 是 | 是 | 是 | 中,需要了解java安全性管理器及反射机制。 | 一些对代码安全有要求的程序,程序员并没有修改security manager的权限,此时,安全管理器方法失效。 |
方法三:使用模仿对象 | 是 | 是 | 否 | 较高,需要了解设计模式和待测对象的内部实现细节。 | 由于模仿对象要求伪类必需和待测类是继承与被继承的关系,所以当源码以private关键字修饰时,此方法失效。 |
方法四:利用字节码技术 | 是 | 是 | 是 | 高,需要操作和改写类部分的字节码。 | 学习成本高,需要了解Java字节码技术 |
总结
在进行单元测试时,我们要尽可能的考虑代码的移植性和通用性,在不修改源程序的前提下达到测试的最佳效果。对于是否应该使用以及如何使用本文中提到的四种方法,需要开发人员根据具体场合谨慎选择。