层楼

Write codes that work and speak

0%

【译】私有方法到底怎么测试

译自:Still No Consensus On Testing Private Methods

昨天工作的时候,在关于Rust编程的一次会议中,我随口说道:“我觉得大家应该都认可,在编写单元测试的时候,私有方法不应该直接测试,除非在某些特殊情况下。”令我惊讶的是,我错了。现场爆发了一场辩论,大家的观点各不相同。我们快速结束了争论继续开会,但我因为对开发者的思潮判断错误而感觉有些尴尬。

在整个软件开发行业中,一定有一种观点大多数人都认可,难道不是吗?再猜一下。如果你想知道在这个问题上大家的共识少的有多可怜,不妨阅读一下这些 Stack Overflow 的帖子:这个这个这个这个。有些人认为我们应该始终直接测试私有方法,而有些人认为我们永远不应该直接测试私有方法。这两种观点不可能同时正确!是否有一种观点更符合软件开发的实际情况呢?

关于测试私有方法,主要有五种流行的观点:

  1. 从一开始就别用私有方法
  2. 总是测试私有方法
  3. 永远不要测试私有方法
  4. 有时候测试私有方法
  5. 将私有方法提取到一个专门的类中

在本文中,我将探讨上述每一种观点,然后将它们综合成我自己的经验法则,希望大多数人能够认同我。请注意,我们将基于类和方法的术语进行讨论,但这些观点同样适用于函数式编程语言中的一般函数。

观点1:从一开始就别用私有方法

首先我必须排除这个观点,因为大部分人直觉上都会认为它很极端。而且如果它正确的话,我们后面的讨论就完全没意义了。

这个观点并不是对测试私有方法的攻击,而是对私有方法的攻击,是对尝试预测未来的攻击。其核心的观点是,在编写库代码时,你不可能提前知道你的用户将来会用到哪些方法。将方法默认设置成私有,与默认设置成公有(或受保护)相比,会给你和用户带来更多的问题。这种观点似乎只出现在库开发者中(参见这个这个),因为应用程序开发者可以轻松地通过几次按键将方法置为公有,而库的用户则需要fork代码或提issue并等着回复。

这种观点的缺点在于:将私有方法提升为公有很容易,但将公有方法降级为私有则会带来不兼容的问题。此外,你的公共API向用户传达了你对他们如何使用你的库的期望。如果基于想象出的用例,额外提供一些原本应该是私有的方法,使公共API变得臃肿,这样只会给用户带来麻烦,因为他们只想知道如何解决已知的用例。这些缺点相互交织:客户错误地使用了错误的方法与你的库交互,进而使重构变得愈发困难。

观点2:总是测试私有方法

尽管这不是一个很受欢迎的观点,但仍有一些支持者。主要有三个论点:

  • 在进行测试驱动开发(TDD)时,你需要先编写测试,然后再编写代码,因此不管方法是公有的还是私有的,最好逐个方法进行测试。

  • 通过对每个方法进行隔离测试(不考虑访问修饰符),你可以清楚地向读者展示每个独立方法的预期行为,从而使他们更好地理解每个方法在整个项目中所扮演的角色和承担的职责。

  • 直接测试私有方法的明显替代方案之一,是套一层公有方法进行测试,但这需要在测试中编写设置代码(比如模拟测试环境、创建对象实例等),这需要写更多的代码,并且可能让测试运行时间更长。如果你更倾向于节约开发时间,并且相信编写公有方法测试的初始成本比重构私有方法测试的持续成本更高,那么最好一开始就对私有方法进行测试,然后再在有必要时花时间持续重构。

有些编程语言比其他语言更容易测试私有方法。如果你平时用的编程语言让这件事困难重重,那你可能很难赞同这个观点。

观点3:永远不要测试私有方法

这个观点和前一个完全对立,它的主要论点是,你的类的用户只能通过其公有接口(即类上的公有方法)与该类交互,那么为什么你的测试就要有所不同呢?如果一个私有方法无法通过公有方法访问到,那么它就是无用代码,应该被删除。如果它可以通过公有方法访问到,那么你应该通过公有方法测试这个私有方法,因为测试的目的不就是模拟未来使用这些代码的用户吗?

这是一个哲学上的论点,但实际上更容易说服人的是:如果你的测试仅依赖于类的公有接口,那么你可以随心所欲地重构该类的内部结构,不用修改任何测试。如果你不需要修改测试,那么就能确定,如果测试出现错误一定意味着搞坏了什么,如果测试一路绿灯则说明你成功地保留了类的原有行为。

与之相反,如果类的测试依赖了私有方法,并且你在代码重构时删除或修改了任何私有方法,那么你必须重写这些测试,处理新的内部结构。但是这样你对测试的信心就会降低,因为重写测试和重写代码一样容易出错。其次,即使你小心翼翼重写测试,确保类的行为与以前保持一致,这仍然是一个费时费力的过程,可能会阻碍代码库的重构以及健康度的提升。

前一个观点更强调通过公有方法测试私有方法的初始成本,而这个观点更关注重构的持续成本。

观点4:有时候测试私有方法

前面的观点都很强调“公共接口”,但是这个观点质疑什么才是真正的公共接口,什么才是真正的单元。如果你正在编写一个应用程序(运行的二进制文件)而不是库(将代码导出以供其他代码库使用),那么只有一个真正的公共接口,那就是应用程序本身,比如说用户的按键和点击。如果你想像前一个观点所说最大限度地提高重构能力,最好的做法是每次测试都打开应用,并且模拟用户的点击和按键,这样就完全不依赖于任何内部代码,你可以自信地重构代码,而无需修改任何测试。

在极少数情况下,端到端的测试才算得上明智的选择。比如当你接手了一个几乎不可能运行单元测试的系统,并且打算重构整个代码仓库;或者当你正在构建一个作为标准的参考实现样例,并且准备针对两种实现运行测试以检查功能/错误兼容性。然而,在大多数情况下,抛弃单元测试,构造成千上万个模拟真实用户的端到端测试是荒谬的。测试套件如果只包含端到端测试是有一些问题的:

  • 运行给定测试的时间太长
  • 编写给定测试的时间太长
  • 每个测试的复杂性使得目标变得模糊,降低了测试作为文档的能力
  • 更改功能可能会破坏其他无关功能的测试

正是基于这些原因,才有了单元测试。作为软件开发工程师,我们通过深入了解应用程序的代码,选出我们认为值得进行独立测试的“单元”,做出妥协(这里指测试范围和代码可维护性的妥协。妥协是常见的,我们必须在不同因素中寻找平衡,尽力满足项目的需求和目标)。我们这样做是因为我们知道,如果重构导致其中一个“单元”被完全消除,我们也需要在其他地方重新编写相关测试,并承担所有上述的成本。

如果我们测试的是对其他代码而言是公共的,但对最终用户而言是私有的代码,我们必须承认在测试“单元”的选择过程中所固有的任意性。测试一个类中的私有方法和测试应用程序中的类之间只有程度上的差异,而并无本质上的区别。

这给我们提供了一个封装级别的光谱,随着我们把封装级别逐渐降低,从应用程序开始,到越来越小的切面,到模块、类,最后到私有方法。可以说封装的级别越高,测试就越困难,但封装级别越低,重构就越困难。

这个观点指出,如果一个私有方法足够自包含(实现独立于外部),并且通过公共接口测试它很麻烦,那么可以毫不犹豫直接测试它,如果有人反对这种做法,其实是双重标准。

观点5:将私有方法提取到一个专门的类中

这个观点是在前一个观点的基础上的,如果你想要测试一个私有方法,这意味着你的类可能承担了太多职责,已经违反了单一职责原则(SRP,Single Responsibility Principle)。

在《Working With Legacy Code》中,作者Michael Feathers提到:

如果我们需要测试一个私有方法,我们应该将它变成公有方法。如果将其变成公有方法让我们感到困扰,大多数情况下,这意味着我们的类做了太多的事情,我们应该进行修正。

(个人而言,我无法想象为了测试而将方法公开而不感到困扰,但你可以理解这个观点)

在《Practical Object Oriented Design in Ruby》一书中,Sandi Metz也提到:

希望被测试的私有方法可能是SRP违规的代码异味。

前面的观点认为选择“单元”是随意的,而这个观点则持不同意见。如果你想要测试私有代码,这说明你在代码中发现了一个没有明确表示的抽象边界。可能你想要测试一些直接映射到问题域的算法(这里指算法和业务逻辑是直接关联的,没有太多的中间层和抽象),这种情况下它应该构建出自己的抽象。

通过将私有方法提取到一个专门的类中,我们可以通过公共接口测试这个类,还有一点额外好处,就是可以把新类作为依赖项注入到原来的类中,方便模拟新类的行为,这样代码和测试都保持了责任的分离。

如果将单个函数包装成一个类感觉有点极端,并且你的编程语言支持函数独立于类而存在,那么本观点显然支持你把私有方法提取成独立的函数,前提是你能处理好它和相关实例变量之间的依赖关系。

讨论

我们从一个十分激进的观点1开始,认为就不应该有任何私有方法。这确实简化了测试过程,但封装的缺失可能会让工作变得一团糟。

然后我们考虑了两种完全相互矛盾的观点,观点2认为不要测试私有方法,观点3则希望测试所有方法,无论是公共还是私有。接着观点4提出,不论你在封装光谱的什么位置,以高级别(比如类)或低级别(比如私有方法)进行测试都是各有利弊,如果利大于弊,写测试没什么不好意思的。

然后观点5出现,它给我们扔了个扳手,提议说需要测试私有方法这件事本身就是代码异味,这表明这个类的责任太多了。

一个强烈支持公共API的观点3的支持者可能会对观点5的支持者说:等一下!到目前为止,我们一直讨论的是重构和封装,但你把焦点转向了单一职责。将私有方法移动到私有类中并没有减轻重构的负担:我们可能需要删除/更改私有类,就像我们之前做私有方法一样,这意味着无论如何,都要重写测试。而且这还需要你的编程语言支持私有类,如果不支持,这样做实际上扩充了公共API,暴露了不希望用户用到的类!再说了,将一个纯函数的私有方法移动到独立的文件中,而这个方法只被一个类使用,这真的有意义吗?如何提高代码可读性呢?

而支持观点5的人则可以反驳说:想对私有方法进行测试,就说明存在一个没意识到的独立抽象。这个抽象与那些你认为不需要进行测试的私有方法比,更不需要被重构。

我的建议

我的建议是:尽量让类的公共接口简洁,默认把方法设置成私有。如果你发现自己想直接测试一组私有方法,请认真考虑是否抽取一个类(或独立函数),但是只有当它本身有意义,而不是单独为了测试时,才能这样做。如果你想测试一个私有方法,并且觉得把它从类里提取出来没什么意义,那就把它转换成纯函数(只依赖输入参数,不引用实例变量,没有其他副作用)再做测试。这样,如果以后你想把函数挪到其他地方,移动相关的测试代码就跟复制粘贴一样。

在这场讨论中,我有没有忽略或曲解任何观点呢?你是否同意我的观点呢?我是不是以偏概全了?请告诉我,下次见!

链接

Should Private Methods Be Tested? - Anthony Sciamanna

Testing Private Methods with JUnit and SuiteRunner - Bill Venners

Testing private methods (don’t do it) - Charles Miller

Test private methods - Oliver Caldwell

The case against private methods - José san leandro