我第一次使用 JUnit 是为了为 ServiceUI API 构建一个一致性测试工具包 [ 1 ]。一致性测试工具包的目的是帮助确保同一 API 的替代实现与 API 的规范兼容。由于 API 规范仅定义 API 的公共接口,而不是 API 的实现,因此一致性测试仅测试公共接口。换句话说,一致性测试是一种“黑盒”测试。它将测试中的 API 视为黑盒,可以看到其外部接口,但看不到其内部实现。因此,Java API 的一致性测试只需要访问测试中的包和类的公共成员。无需访问包级、受保护或私有成员。
当我后来将 JUnit 应用于编写实际单元测试(而不是一致性测试)时,我发现自己想要编写白盒测试,即利用被测包和类的内部实现知识的测试。虽然我只想在一致性测试中测试公共方法,但我想为包访问编写单元测试,有时也为私有方法和公共方法编写单元测试。
Daniel Steinberg [ 2 ] 向我展示了使用并行源代码树的常见 JUnit 技术,这使我能够将测试类与被测类放在同一个包中,但将它们放在不同的目录中。这提供了测试和生产代码的明确分离。通过将两个源代码树都放在 CLASSPATH 中,我的测试类可以访问被测包中的包级方法和类。然而,这仍然给我带来了测试私有方法的问题。
当我向 Daniel 询问有关测试私有方法的问题时,他温和地建议我通过测试包访问方法和调用私有方法的公共方法来间接测试私有方法。这个答案并没有让我很满意,因为有时我确实很想直接测试私有方法。我最初的解决方案是让这些私有方法成为包访问方法,这样我就可以直接使用 JUnit 从并行源代码树中同一包中的测试类中测试它们。这种方法效果很好,但不知何故让我感觉有点不妥。虽然总的来说,我发现思考如何设计接口以便于进行单元测试有助于我设计出更好的接口,但在这种情况下,我感觉为了让它可测试,我让设计变得稍微糟糕了一点。
后来,我参与创建了 Frank Sommers、Matt Gerrans 和我最终发布的 Artima SuiteRunner [ 3 ],我发誓要让 SuiteRunner 中的私有方法测试比 JUnit 中的更容易。但在研究了测试私有方法的各种方法后,我决定不对 SuiteRunner 进行任何特殊处理以支持测试私有方法。因此,无论您使用的是 JUnit 还是 SuiteRunner,您都有相同的四种基本方法来测试私有方法:
- 不要测试私有方法。
- 授予方法包访问权限。
- 使用嵌套测试类。
- 使用反射。
在本文中,我将讨论这四种在 Java 中测试私有方法的方法。我将研究每种方法的优缺点,并尝试阐明何时使用每种方法才是合理的。
方法 1:不要测试私有方法
正如我在介绍中提到的,我第一次听到 Daniel Steinberg 建议我抑制偶尔测试私有方法的冲动。但 Daniel 并不是我遇到的唯一一个提出这种建议的人。这似乎是 Java 社区的一种普遍态度。例如,JUnit FAQ [ 4 ] 指出:
测试私有方法可能表明应该将这些方法移到另一个类中以提高可重用性。
Charles Miller 在他的博客中表达了类似的观点[ 5 ]:
如果您对类的公开(非私有)接口进行了一套全面的测试,那么这些测试本质上应该验证类中的任何私有方法是否也能正常工作。如果不是这种情况,或者您的私有方法过于复杂,需要在其公共调用方上下文之外进行测试,我会认为这是一种代码异味。
Dave Thomas 和 Andy Hunt 在他们的书《实用单元测试》 [ 6 ]中写道:
一般来说,您不会为了测试而破坏任何封装(或者像妈妈常说的那样,“不要暴露您的私有信息!”)。大多数时候,您应该能够通过执行其公共方法来测试类。如果在私有或受保护的访问背后隐藏着重要的功能,这可能是一个警告信号,表明其中有另一个类正在努力逃脱。
我相信所有这些建议。大多数情况下,最有效的测试私有方法的方式是采用方法 1,即间接测试调用它们的包级、受保护和公共方法。但不可避免的是,在某些情况下,有些人会觉得直接测试私有方法是正确的做法。
就我而言,我倾向于创建许多私有实用方法。这些实用方法通常不对实例数据执行任何操作,它们只是对传递的参数进行操作并返回结果。我创建此类方法是为了使调用方法更易于理解。这是一种管理类实现复杂性的方法。现在,如果我从已经运行且具有良好单元测试覆盖率的方法中提取私有方法,那么那些现有的单元测试可能就足够了。我不需要仅为私有方法编写更多单元测试。但是,如果我想在调用方法之前编写私有方法,并且我想在编写私有方法之前编写单元测试,那么我又回到了直接测试私有方法的冲动。对于私有实用方法,我并不觉得我直接测试这些方法的冲动是,正如 JUnit FAQ 所说,“表明应该将这些方法移到另一个类中以促进可重用性。”这些方法实际上只在它们所在的类中需要,而且实际上通常只由另一个方法调用。
我有时会想要直接测试私有方法的另一个原因是,我倾向于认为单元测试可以帮助我实现一个强大的系统,因为它由强大的部分组成。每个部分都是一个“单元”,我可以为其编写“单元测试”。单元测试帮助我确保每个单元都正常运行,这反过来又帮助我构建一个整体正常运行的系统。在使用 Java 编程时,我考虑的主要单元是类。我用类构建系统,单元测试让我确信我的类是强大的。但在某种程度上,我对组成包访问、受保护和公共方法的私有方法也有同样的感觉。这些私有方法是可以单独测试的单元。这样的单元测试让我相信私有方法可以正常工作,这有助于我构建强大的包访问、受保护和公共方法。
方法 2:授予方法包访问权限
正如我在介绍中提到的,赋予方法包访问权限是我使用 JUnit 测试私有方法的第一种方法。这种方法实际上效果很好,但确实需要付出一些代价。当我在方法上看到私有访问说明符时,它会告诉我一些我想知道的事情 — 这是类实现的一部分。我知道如果我只是想使用包中另一个类的类,我可以忽略该方法。我可以通过更仔细地查看方法的名称、文档和代码来弄清楚包访问方法,但“私有”一词可以更有效地传达这一点。此外,我对这种方法的主要问题是哲学上的。虽然我不介意“为了测试而破坏封装”,正如 Dave 和 Andy 所说的那样,但我只是不喜欢以改变包级 API 的方式破坏封装。换句话说,尽管我非常热衷于测试类的非公共方法,即创建“白盒”单元测试,但我宁愿不要改变被测试类的 API(包括包级 API)以方便进行这些测试。
方法 3:使用嵌套测试类
测试私有方法的第三种方法是将静态测试类嵌套在被测试的生产类中。由于嵌套类可以访问其封闭类的私有成员,因此它可以直接调用私有方法。静态类本身可以是包访问,允许将其作为白盒测试的一部分加载。
这种方法的缺点是,如果您不希望嵌套的测试类在部署 JAR 文件中可访问,则必须做一些额外的工作来提取它。此外,有些人可能不喜欢将测试代码与生产代码混合在同一个文件中,但其他人可能更喜欢这种方法。
方法 4:使用反射
第四种测试私有方法的方法是由 Vladimir R. Bossicard 建议的,他是 JUnit Addons 的作者 [ 7 ]。一天午餐时,Vladimir 告诉我,java.lang.reflect
API 中包含的方法允许客户端代码绕过 Java 虚拟机的访问保护机制。他还告诉我,他的 JUnit Addons 项目包含一个类junitx.util.PrivateAccessor
[ 8 ],用于协助使用反射 API 来实现此目的:编写单元测试来操作被测类的私有成员。JUnit FAQ 中提到了一个类似的类,名为PrivilegedAccessor
[ 9 ],由 Charlie Hubbard 和 Prashant Dhotke 编写。
使用反射方法测试私有方法的一个优点是,它可以将测试代码和生产代码完全分开。测试不需要像方法 3 那样嵌套在被测类中。相反,它们可以与执行类的包级和公共方法的其他测试放在一起。此外,您不需要更改被测类的 API。与方法 2 不同,私有方法可以保持私有。与方法 3 不同,您不需要在包访问级别添加任何额外的嵌套类。这种方法的主要缺点是测试代码更加冗长,因为它使用了反射 API。此外,Eclipse 和 IntelliJ 等重构 IDE 通常不擅长更改方法的名称,因为这些方法被称为传递String
给反射 API 的方法。因此,如果您使用重构 IDE 更改私有方法的名称,您可能仍需要在测试代码中手动进行一些更改。
一个例子
举一个我认为值得直接进行单元测试的私有方法的例子,我从 main
类 的方法 中提取了一些功能org.suiterunner.Runner
。 Runner.main
解析命令行参数并运行一套测试,可选择启动 GUI。我提取的方法 parseArgsIntoLists
负责解析 SuiteRunner 应用程序的命令行参数。现在,要测试调用此私有方法的公共方法,我需要测试 main
。当然,Main 是整个应用程序,这使得该方法很难测试。事实上,我还没有现有的 测试 main
。
看到这里,你可能会想,如果我先按照测试驱动开发 [ 10 ] 的风格编写测试,那我怎么会编写出没有单元测试的解析代码呢?主要原因是我的测试感染是分阶段出现的。在我听说 JUnit 或者读到《测试感染》 [ 11 ] 之前,我确实感染了单元测试流感。例如,当我用 C++ 构建 Windows 应用程序时,我会编写一些代码来测试一个新实现的方法,然后执行该代码,并用调试器逐步执行被测方法观察其执行情况。这种单元测试确实帮助我实现了健壮性,但是测试本身并不能检查行为是否正确。我通过调试器观察自己检查了行为是否正确。测试不是自动化的,因此我没有保存它们以便以后再次运行。当我读到《Test Infected》时,我立即意识到了自动化测试的价值,并在重构后将它们作为一种回归测试保留下来,但很长一段时间以来,我都觉得先写测试没有意义。我想在实现功能之后再写测试,因为那时我已经用调试器运行了测试。在开发 SuiteRunner 的大部分内容时,我没有先写测试的第二个原因是,我想用 SuiteRunner 本身来编写 SuiteRunner 的测试,以便吃自己的狗粮。在 SuiteRunner 的基本 API 稳定下来之前,我还没有我想用来编写测试的测试工具包。
然而,从那时起,测试病毒对我的感染越来越强烈,现在我更喜欢在大多数时间先编写单元测试。我之所以喜欢先编写测试,并不是因为我发现最终的设计更简洁,这通常被认为是测试驱动开发的主要优点。相反,我更喜欢先编写测试,因为我发现,如果我在压力下深入研究代码,并打算稍后再编写测试,那么测试实际上永远不会被编写。SuiteRunner 本身目前很少有单元测试,原因就在于此。方法如下parseArgsIntoLists
:
private static void parseArgsIntoLists(String[] args, List runpathList,
List reportersList, List suitesList) {
if (args == null || runpathList == null
|| reportersList == null || suitesList == null) {
throw new NullPointerException();
}
for (int i = 0; i < args.length; i++) {
if (args[i].startsWith("-p")) {
runpathList.add(args[i]);
runpathList.add(args[i + 1]);
++i;
}
else if (args[i].startsWith("-g")) {
reportersList.add(args[i]);
}
else if (args[i].startsWith("-o")) {
reportersList.add(args[i]);
}
else if (args[i].startsWith("-e")) {
reportersList.add(args[i]);
}
else if (args[i].startsWith("-f")) {
reportersList.add(args[i]);
reportersList.add(args[i + 1]);
++i;
}
else if (args[i].startsWith("-r")) {
reportersList.add(args[i]);
reportersList.add(args[i + 1]);
++i;
}
else if (args[i].startsWith("-s")) {
suitesList.add(args[i]);
do {
++i;
suitesList.add(args[i]);
} while (i + 1 < args.length);
}
else {
throw new IllegalArgumentException("Unrecognized argument: "
+ args[i]);
}
}
}
SuiteRunner 的命令行包含 SuiteRunner 用于运行测试的三种信息:运行路径、报告器和套件。该parseArgsIntoLists
方法仅遍历作为 数组传递的参数,并将每个参数放入、和String
之一的列表中。runpathList
reportersList
suitesList
在为这个私有方法编写测试之前,我想问一下,我想要编写这个单元测试的冲动是否代表着代码异味,正如 Charles Miller 在他的博客中所说的那样?这是否表明parseArgsIntoLists
应该将 移到另一个类中以提高可重用性,正如 JUnit FAQ 所建议的那样?Dave 和 Andy 会说这是一个警告信号,表明里面有另一个类在努力摆脱它吗?嗯,也许吧。我可以想象创建一个ArgumentsParser
只包含几个执行解析工作的静态方法的类。ArgumentsParser
类和它包含的方法都可以是包访问,这将使它们易于测试。但我觉得这不对。这些方法只由 调用Runner.main
。对我来说,它们显然感觉像私有方法。我将它们移到ArgumentsParser
类中的唯一原因是为了能够测试它们。事实上,我会使用方法 2:使私有方法具有包访问权。
相反,对于这个例子,我决定采用方法 4,并使用反射。我研究了 Vladimir Bossicardjunitx.utils.PrivateAccessor
和 Charlie Hubbard 以及 Prashant Dhotke 的PrivilegedAccessor
,但认为它们都没有达到我想要的效果。首先,这两个类都能够测试字段以确保它们设置正确。到目前为止,我从未有过从单元测试中直接访问私有字段的冲动。我只想测试私有实用程序方法。但是,我对这两个类的主要问题是它们如何处理尝试通过反射调用私有方法时可能引发的异常。每个类都有一个或多个方法,其工作是通过反射调用方法。PrivilegedAccessor
的两个invokeMethod
方法将任何异常传递回其调用者,包括在 throws 子句中声明的三个已检查异常:NoSuchMethodException
、IllegalAccessException
和InvocationTargetException
。相比之下,PrivateAccessor
的两个invoke
方法捕获InvocationTargetException
和 提取并抛出目标异常,即调用的方法抛出的实际异常。然后它捕获任何其他异常并抛出NoSuchMethodException
。我不喜欢的调用者PrivilegedAccessor.invokeMethod
总是需要处理三个已检查的异常,因为我认为处理任何异常的一般方法是让测试失败。我还担心PrivateAccessor.invoke
在其异常处理策略中丢弃了可能有用的堆栈跟踪信息。我真正想要的是一种尝试使用反射调用私有方法的方法,该方法将InvocationTargetException
除未检查的之外的任何抛出的异常包装起来。大多数情况下,此异常会导致测试失败。在预期会抛出异常的测试中,可以提取TestFailedException
包含在中的异常并测试其正确性。InvocationTargetException
因此,我写了invokeStaticMethod
。setAccessible(true)
调用使得可以从类外部调用私有方法。invokeStaticMethod
与 JUnit 一起使用的相应实现将抛出AssertionFailedError
而不是TestFailedException
。以下是代码:
private static voidinvokeStaticMethod(Class targetClass,
String methodName, Class[] argClasses, Object[] argObjects)
throws InvocationTargetException {
try {
Method method = targetClass.getDeclaredMethod(methodName,
argClasses);
method.setAccessible(true);
method.invoke(null, argObjects);
}
catch (NoSuchMethodException e) { // 这种情况很少发生,因为大多数情况下指定的方法都应该存在。如果发生了,就让测试失败,以便程序员修复问题。
throw new TestFailedException(e);
}
catch (SecurityException e) { // 这种情况很少发生,因为在运行单元测试时应该允许 setAccessible(true)。如果发生了,就让测试失败,以便程序员修复问题。
throw new TestFailedException(e);
}
catch (IllegalAccessException e) { // 绝不应该发生,因为将 accessible 标志设置为true。如果设置可访问性失败,则应在此时抛出安全异常,并且永远不会调用。但以防万一,将其包装在 TestFailedException 中并让人类找出原因。
throw new TestFailedException(e);
}
catch (IllegalArgumentException e) { // 这种情况很少发生,因为通常会传递正确数量和类型的参数。如果确实发生了这种情况,只需让测试失败,以便程序员可以修复问题。
throw new TestFailedException(e);
}
}
接下来,我创建了一个便捷方法来调用我想要测试的特定私有方法:
private static voidinvokeParseArgsIntoLists(String[] args,
List runpathList, List reportersList, List suitesList)
throws InvocationTargetException { // 故意将空值传递给方法,以确保它抛出// NullPointerException
Class[] argClasses = {String[].class, List.class, List.class, List.class };
Object[] argObjects = {args, runpathList, reportersList, suitesList };
invokeStaticMethod(Runner.class, "parseArgsIntoLists", argClasses, argObjects);
}
最后,我可以针对私有方法编写测试,而不会产生太多混乱,如下所示:
public void testParseArgsIntoLists() throws InvocationTargetException {
String[] args = new String[0];
List runpathList = new ArrayList();
List reportersList = new ArrayList();
List suitesList = new ArrayList();
try {
invokeParseArgsIntoLists(null, runpathList, reportersList, suitesList);
fail();
}
catch (InvocationTargetException e) { // 抛出 InvocationTargetException,除非目标// 异常是 NullPointerException(这是预期的)
Throwable targetException = e.getTargetException();
if (!(targetException instanceof NullPointerException)) {
throw e;
}
}
try {
invokeParseArgsIntoLists(args, null, reportersList, suitesList);
fail();
}
catch (InvocationTargetException e) { // 抛出 InvocationTargetException,除非目标// 异常是 NullPointerException,这是预期的
Throwable targetException = e.getTargetException();
if (!(targetException instanceof NullPointerException)) {
throw e;
}
}
try {
invokeParseArgsIntoLists(args, runpathList, null, suitesList);
fail();
}
catch (InvocationTargetException e) { // 抛出 InvocationTargetException,除非目标// 异常是 NullPointerException,这是预期的
Throwable targetException = e.getTargetException();
if (!(targetException instanceof NullPointerException)) {
throw e;
}
}
try {
invokeParseArgsIntoLists(args, runpathList, reportersList, null);
fail();
}
catch (InvocationTargetException e) { // 抛出 InvocationTargetException,除非目标// 异常是 NullPointerException,这是预期的
Throwable targetException = e.getTargetException();
if (!(targetException instanceof NullPointerException)) {
throw e;
}
}
args = new String[7];
args[0] = "-p";
args[1] = "\"mydir\"";
args[2] = "-g";
args[3] = "-f";
args[4] = "test.out";
args[5] = "-s";
args[6] = "MySuite";
runpathList.clear();
reportersList.clear();
suitesList.clear();
invokeParseArgsIntoLists(args, runpathList, reportersList,
suitesList);
verify(runpathList.size() == 2);
verify(runpathList.get(0).equals(args[0]));
verify(runpathList.get(1).equals(args[1]));
verify(reportersList.size() == 3);
verify(reportersList.get(0).equals(args[2]));
verify(reportersList.get(1).equals(args[3]));
verify(reportersList.get(2).equals(args[4]));
verify(suitesList.size() == 2);
verify(suitesList.get(0).equals(args[5]));
verify(suitesList.get(1).equals(args[6]));
args = new String[9];
args[0] = "-p";
args[1] = "\"mydir\"";
args[2] = "-e";
args[3] = "-o";
args[4] = "-r";
args[5] = "MyCustomReporter";
args[6] = "-s";
args[7] = "MySuite";
args[8] = "MyOtherSuite";
runpathList.clear();
reportersList.clear();
suitesList.clear();
invokeParseArgsIntoLists(args, runpathList, reportersList,
suitesList);
verify(runpathList.size() == 2);
verify(runpathList.get(0).equals(args[0]));
verify(runpathList.get(1).equals(args[1]));
verify(reportersList.size() == 4);
verify(reportersList.get(0).equals(args[2]));
verify(reportersList.get(1).equals(args[3]));
verify(reportersList.get(2).equals(args[4]));
verify(reportersList.get(3).equals(args[5]));
verify(suitesList.size() == 3);
verify(suitesList.get(0).equals(args[6]));
verify(suitesList.get(1).equals(args[7]));
verify(suitesList.get(2).equals(args[8]));
args = new String[10];
args[0] = "-p";
args[1] = “\”serviceuitest-1.1beta4.jar myjini http: //myhost:9998/myfile。jar\"" ;
args[2] = "-g";
args[3] = "-s";
args[
4] = "MySuite"; args[5
] = "MySecondSuite"; args[6] = "MyThirdSuite"
; args[7] = "MyFourthSuite";
args[8] = "MyFifthSuite";
args[9] = "MySixthSuite";
runpathList.clear();
reportersList.clear();
suitesList.clear();
InvokeParseArgsIntoLists(args, runpathList, reportersList,
suitesList);
verify(runpathList.size() == 2);
verify(runpathList.get(0).equals(args[0]));
verify(runpathList.get(1).equals(args[1]));
verify(reportersList.size() == 1);
verify(reportersList.get(0).equals(args[2]));
verify(suitesList.size() == 7);
verify(suitesList.get(0).equals(args[3]));
verify(suitesList.get(1).equals(args[4]));
verify(suitesList.get(2).equals(args[5]));
verify(suitesList.get(3).equals(args[6]));
verify(suitesList.get(4).equals(args[7]));
verify(suitesList.get(5).equals(args[8]));
verify(suitesList.get(6).equals(args[9]));
}
结论
方法 1,通过测试调用私有方法的包级、受保护和公共方法来间接测试私有方法,通常是最佳方法。如果您确实想直接测试私有方法,使用反射来测试私有方法,虽然相当麻烦,但确实提供了测试代码与生产代码的最清晰分离,并且对生产代码的影响最小。但是,如果您不介意将那些要测试包访问的特定私有方法设为私有,则可以使用方法 2。或者,如果您不介意在测试的生产类中放置一个嵌套的测试类,方法 3 至少可以让您将私有方法保持为私有。
没有完美的答案。但是,如果您采用方法 4,您最终会得到一些invokeStaticMethod
可以重复使用的方法。一旦为私有方法编写了便捷方法(例如invokeParseArgsIntoLists
),您就可以毫不费力地针对私有方法编写测试。