在单元测试中使用反射是不好的做法吗?

120

在过去的几年里,我一直认为在Java中,单元测试时广泛使用反射。由于其中一些需要检查的变量/方法是私有的,因此读取它们的值似乎是必要的。我一直认为反射API也是用于这个目的的。

上周,我不得不测试一些包并编写一些JUnit测试。像往常一样,我使用反射来访问私有字段和方法。但是我的主管审核代码时并不是很满意,并告诉我反射API不适用于这种“黑客”行为。相反,他建议修改生产代码中的可见性。

使用反射真的是不好的做法吗?我实在无法相信-

编辑:我应该提到,我被要求所有测试都在名为test的单独包中(因此使用受保护的可见性等并不是可行的解决方案)


你在反射中使用什么样的访问方式,是set、get还是两者都有? - fish
@fish:我只使用get来验证是否设置了特定的值。 - RoflcoptrException
8
在Java中,将测试代码放置在相同的包中是一种常见做法,这样你就可以测试“包级私有”(默认访问)类。 - Tom Hawtin - tackline
9
对之前评论的一个补充(将测试放在同一包中),可以将测试放在一个不随应用程序部署的单独源树中。 - deterb
为这些变量添加getter应该不是什么大问题,特别是如果您能说服您的主管接受在不同源树但相同包中进行测试(这已经是常见做法了)。在我看来,直接访问字段总是一个坏主意,无论是使用反射还是具有松散可见性。 - fish
7个回答

96

我认为,反射(Reflection)应该只在最后情况下被使用,仅用于测试遗留代码或无法更改的API。如果你正在测试自己的代码,需要使用反射意味着你的设计不可测试,所以你应该修复它而不是使用反射。

如果你需要访问单元测试中的私有成员变量,通常意味着该类的接口不合适并且/或试图完成过多任务。因此,要么应该重新设计其接口,要么将某些代码提取到单独的类中,在那里这些有问题的方法/字段访问器可以被公开访问。

请注意,一般而言,使用反射会导致代码更难理解和维护,并且更容易出现错误。有些错误通常由编译器检测,但是使用反射会导致这些错误仅作为运行时异常出现。

更新:如@tackline所指出的,这仅涉及在自己的测试代码中使用反射,而不是测试框架的内部。JUnit(和可能所有其他类似的框架)使用反射来识别和调用测试方法-这是一种合理且局部化的使用反射。如果不使用反射,将很难或不可能提供相同的功能和便利。但是,它完全封装在框架实现中,因此不会使我们自己的测试代码变得复杂或受到影响。


11
反射在测试框架中作为调用测试和模拟接口的一种选择,可以替代编译时注解处理器,显然是非常有用的。但是,应该明确将其作为一种单独的用法来区分开来。 - Tom Hawtin - tackline
2
@tackline,这才是我真正的意思,感谢你指出来。我在我的回答中添加了澄清。 - Péter Török
1
你不需要很多限定词。反射应该主要作为最后的手段使用,这样可以有效地防止程序的推理。 - Ira Baxter

72

仅出于测试目的而修改生产API的可见性是非常不好的行为。该可见性很可能由于合法原因而设置为其当前值,不应更改。

在单元测试中使用反射大多数情况下是可以的。当然,你应该设计可测试的类,以便需要较少的反射操作。

例如Spring框架有ReflectionTestUtils,但其目的是设置依赖项的模拟对象,在这些依赖项原本被Spring注入的情况下。

这个话题比“做与不做”更深刻,并关系到 何时 应进行测试 - 是否需要测试对象的内部状态;是否应该质疑正在测试的类的设计等等。


6
API改变有两个方面。仅为测试而更改API不好,而更新API/设计使您的代码更易于测试(从而不仅仅是为了测试而更改可见性)是一种良好的实践。 - deterb
2
如果您确定您所做的事情没有副作用 - 是的。;) - Bozho
如果你没有测试来测试你所做的API更改,那么你可以编写测试... Bozho提出了一个非常有效的观点和答案。 - Justin
我遇到了这个问题:我必须测试一个类的不变量,而不向客户端公开其所有细节。在这种情况下,反射非常有用。 - Yann-Gaël Guéhéneuc
2
但请注意,将成员的可见性从“私有”更改为“包私有”对用户包的消费者没有任何影响,除非他们正在您包的命名空间中编写代码。这种特定的作用域更改可能是可以容忍的。 - David Bullock
@Bozho,“使用反射进行单元测试大多没有问题。”- 这个说法实在是“过了”。而且你甚至在下一句话中承认了这一点。实际上,这两个句子相互矛盾。这就是为什么我建议稍微修改一下你的答案,以避免这种矛盾。我不自己去做这件事,因为这是你的答案,我不想干涉你的意见。 - Victor Yarema

36

从测试驱动设计的角度来看,这是一种不好的实践。我知道您没有标记此为TDD,也没有特别要求,但TDD是一种良好的实践,而这种方法与其背道而驰。

在TDD中,我们使用测试来定义类的接口 - 公共接口 - 因此,我们编写的测试直接与公共接口交互。 我们关注该接口;适当的访问级别是设计和良好代码的重要组成部分。 如果您发现自己需要测试某些私有内容,则通常意味着存在设计问题。


3
同意。将被测试的对象视为黑箱 - 确保输出与输入匹配。如果不匹配,则说明实现内部存在问题。 - AngerClown
1
但是如何测试对象状态呢? - Kevin Wittek
8
只要对象的外部行为正确,其内部状态就不重要。 - Carl Manaster

7
我认为这是一种不好的做法,仅在生产代码中更改可见性并不是一个好的解决方案,你必须找到原因。要么你有一个无法测试的API(即API没有足够的暴露来进行测试),因此你想测试私有状态,要么你的测试与实现过于耦合,在重构时将使它们仅有边际用途。
不了解您的情况,我无法说更多,但确实被认为使用reflection是一种不良做法。就个人而言,如果不能控制API的不可测试部分,我宁愿将测试作为类下静态内部类,而不是使用反射,但有些地方可能会更关注测试代码与生产代码位于同一包中而不是使用反射。
编辑:针对您的编辑,至少与使用反射一样糟糕,甚至更糟。通常处理的方法是使用相同的包,但保持测试在单独的目录结构中。如果单元测试不属于与测试类相同的包中,那我不知道还有什么可以属于该包。
无论如何,您仍然可以通过使用protected(不幸的是,这并不是真正理想的包私有性)来测试子类来解决此问题,如下所示:
 public class ClassUnderTest {
      protect void methodExposedForTesting() {}
 }

在你的单元测试中

class ClassUnderTestTestable extends ClassUnderTest {
     @Override public void methodExposedForTesting() { super.methodExposedForTesting() }
}

如果你有一个受保护的构造函数:

ClassUnderTest test = new ClassUnderTest(){};

我并不一定推荐上述方法用于普通情况,但你正在被要求在非最佳实践的限制下工作。

修改生产代码的可见性不是一个好的解决方案,点赞+1。 - Robben_Ford_Fan_boy
@Yishai 但是如果我们在本地以及依赖的jar中遇到相同的类名问题怎么办?Java Gradle中如何测试受保护的方法?需要帮助。 - Amit Agarwal

6

我赞同Bozho的想法:

仅仅为了测试而修改生产API的可见性是非常糟糕的。

但如果你尝试着做可能最简单的事情,那么写反射API样板可能比较好,至少在你的代码还很新/变化时。我的意思是,每次更改方法名或参数时,手动更改测试中的反射调用的负担太重,而且方向错误。

我曾经陷入过为了测试而放松可访问性的陷阱,然后无意中从其他生产代码中访问了私有方法,因此我想到了dp4j.jar:它会注入反射API代码(Lombok-style),这样你就不需要更改生产代码,也不需要自己编写反射API;dp4j在编译时将单元测试中的直接访问替换为等效的反射API。这里有一个使用JUnit的dp4j示例


5

我认为你的代码应该通过两种方式进行测试。你应该通过单元测试测试你的公共方法,这将作为我们的黑盒测试。由于你的代码被分解成可管理的函数(良好的设计),你需要使用反射来单元测试各个部分,以确保它们独立于进程工作,我能想到的唯一方法是使用反射,因为它们是私有的。

至少,在单元测试过程中这是我的想法。


4

除了其他人已经说过的,还要考虑以下几点:

//in your JUnit code...
public void testSquare()
{
   Class classToTest = SomeApplicationClass.class;
   Method privateMethod = classToTest.getMethod("computeSquare", new Class[]{Integer.class});
   String result = (String)privateMethod.invoke(new Integer(3));
   assertEquals("9", result);
}

在这里,我们使用反射来执行私有方法SomeApplicationClass.computeSquare(),传入一个Integer并返回一个String结果。这将导致一个JUnit测试,它将编译良好,但如果发生以下任何情况,则在执行过程中失败:
  • 方法名称“computeSquare”被重命名
  • 该方法采用不同的参数类型(例如,将Integer更改为Long)
  • 参数数量发生变化(例如,传入另一个参数)
  • 方法的返回类型发生更改(可能从String更改为Integer)
如果没有简单的方法通过公共API证明computeSquare已经完成了它应该做的事情,那么你的类很可能正在尝试做太多事情。将此方法提取到一个新类中,以便您获得以下测试:
public void testSquare()
{
   String result = new NumberUtils().computeSquare(new Integer(3));
   assertEquals("9", result);
}

现在(特别是当你使用现代IDE中可用的重构工具时),更改方法名称对你的测试没有影响(因为你的IDE也会重构JUnit测试),而更改方法的参数类型、参数数量或返回类型将在你的JUnit测试中标记编译错误,这意味着你不会检查一个编译但在运行时失败的JUnit测试。
最后一点是,有时候,特别是在遗留代码中工作时,如果需要添加新功能,可能不容易在一个单独的可测试的良好编写的类中实现。在这种情况下,我的建议是将新代码更改隔离到受保护的可见性方法中,这样你就可以直接在JUnit测试代码中执行它们。这允许你开始建立测试代码库。最终,你应该重构类并提取添加的功能,但与此同时,在新代码上使用受保护的可见性有时是你在不进行大规模重构的情况下实现可测试性的最佳途径。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接