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