52

最近我和一位同事就单元测试进行了一次有趣的讨论。当您的合同发生变化时,我们正在讨论何时维护单元测试变得不那么高效。

也许任何人都可以启发我如何解决这个问题。让我详细说明:

所以可以说有一个类可以进行一些漂亮的计算。合同说它应该计算一个数字,或者当它由于某种原因失败时返回-1。

我有合同测试来测试它。在我所有的其他测试中,我将这个漂亮的计算器存根。

所以现在我更改了合同,每当它无法计算时,它都会抛出一个 CannotCalculateException。

我的合同测试将失败,我将相应地修复它们。但是,我所有的模拟/存根对象仍将使用旧的合同规则。这些测试会成功,但它们不应该成功!

随之而来的问题是,有了这种对单元测试的信心,对这样的改变有多大信心……单元测试成功了,但是在测试应用程序时会出现错误。需要修复使用此计算器的测试,这会花费时间,甚至可能会被多次存根/嘲笑......

你怎么看这个案子?我从来没有仔细考虑过。在我看来,这些对单元测试的更改是可以接受的。如果我不使用单元测试,我也会在测试阶段(由测试人员)看到此类错误。然而,我没有足够的信心指出什么会花费更多(或更少)时间。

有什么想法吗?

4

9 回答 9

92

您提出的第一个问题是所谓的“脆弱测试”问题。您对应用程序进行了更改,数百个测试由于该更改而中断。发生这种情况时,您遇到了设计问题。你的测试被设计成脆弱的。它们没有与生产代码充分分离。解决方案是(就像在所有这样的软件问题中一样)找到一个抽象,将测试与生产代码分离,从而使生产代码的易变性对测试隐藏。

导致这种脆弱性的一些简单的事情是:

  • 测试显示的字符串。这样的字符串是易变的,因为它们的语法或拼写可能会随着分析师的心血来潮而改变。
  • 测试应该在抽象后面编码的离散值(例如 3)(例如 FULL_TIME)。
  • 从许多测试中调用相同的 API。您应该将 API 调用包装在一个测试函数中,以便当 API 更改时,您可以在一个地方进行更改。

测试设计是TDD初学者经常忽略的一个重要问题。这通常会导致脆弱的测试,从而导致新手拒绝 TDD 为“非生产性”。

你提出的第二个问题是误报。您使用了如此多的模拟,以至于您的任何测试都没有真正测试集成系统。虽然测试独立单元是一件好事,但测试系统的部分和整体集成也很重要。TDD不仅仅是单元测试。

测试应安排如下:

  • 单元测试提供接近 100% 的代码覆盖率。他们测试独立的单元。它们是由程序员使用系统的编程语言编写的。
  • 组件测试覆盖约 50% 的系统。它们由业务分析师和 QA 编写。它们是用 FitNesse、Selenium、Cucumber 等语言编写的。它们测试整个组件,而不是单个单元。他们主要测试快乐路径案例和一些高度可见的不快乐路径案例。
  • 集成测试覆盖了大约 20% 的系统。他们测试组件的小组件而不是整个系统。也用 FitNesse/Selenium/Cucumber 等编写。由建筑师编写。
  • 系统测试覆盖约 10% 的系统。他们测试集成在一起的整个系统。同样,它们是用 FitNesse/Selenium/Cucumber 等编写的。由建筑师编写。
  • 探索性手动测试。(参见 James Bach)这些测试是手动的,但不是脚本化的。他们运用人类的聪明才智和创造力。
于 2010-06-03T20:08:32.557 回答
12

最好修复由于故意代码更改而失败的单元测试,而不是没有测试来捕获这些更改最终引入的错误。

当您的代码库具有良好的单元测试覆盖率时,您可能会遇到许多单元测试失败,这些失败不是由于代码中的错误,而是合同的故意更改或代码重构。

但是,单元测试覆盖率也将使您有信心重构代码并实施任何合同更改。某些测试会失败并需要修复,但由于您在这些更改中引入的错误,其他测试最终会失败。

于 2010-06-03T11:54:20.193 回答
5

即使在 100% 代码/功能覆盖率的理想情况下,单元测试也肯定无法捕获所有错误。我认为这是不可预料的。

如果测试的合约发生变化,我(开发者)应该用我的大脑相应地更新所有代码(包括测试代码!)。如果我未能更新一些因此仍然产生旧行为的模拟,那是我的错,而不是单元测试。

这类似于我修复错误并为其生成单元测试的情况,但我没有仔细考虑(和测试)所有类似的情况,其中一些后来也被证明是错误的。

所以是的,单元测试和生产代码本身一样需要维护。如果没有维护,它们会腐烂和腐烂。

于 2010-06-03T11:51:35.477 回答
4

我在单元测试方面也有类似的经验——当您更改一个类的合同时,您通常还需要更改其他测试的负载(在许多情况下实际上会通过,这使得它变得更加困难)。这就是为什么我也总是使用更高级别的测试:

  1. 验收测试 - 测试几个或更多类。这些测试通常与需要实施的用户商店保持一致——因此您可以测试用户故事是否“有效”。这些不需要连接到数据库或其他外部系统,但可以。
  2. 集成测试——主要是检查外部系统连接等。
  3. 完整的端到端测试 - 测试整个系统

请注意,即使您有 100% 的单元测试覆盖率,您甚至不能保证您的应用程序可以启动!这就是为什么您需要更高级别的测试。有很多不同的测试层,因为你测试的东西越低,它通常就越便宜(在开发、维护测试基础设施以及执行时间方面)。

作为旁注-由于您提到的使用单元测试的问题教会您使组件尽可能地解耦,并且它们的合同尽可能小-这绝对是一个好习惯!

于 2010-06-03T11:56:00.203 回答
3

有人在Google Group中为《Growing Object Oriented Software - Guided by Tests》一书提出了同样的问题。该线程是单元测试模拟/存根假设腐烂

这是JB Rainsberger 的回答(他是 Manning 的“ JUnit Recipes ”的作者)。

于 2012-04-11T07:49:53.807 回答
2

单元测试代码(以及用于测试的所有其他代码)的规则之一是将其视为与生产代码相同的方式 - 不多也不少 - 完全相同。

我对此的理解是(除了保持相关性、重构、工作等像生产代码一样)它也应该从投资/成本的角度来看待它。

可能你的测试策略应该包括一些东西来解决你在最初的帖子中描述的问题 - 一些类似的东西,指定当设计师改变时应该审查(执行、检查、修改、修复等)哪些测试代码(包括存根/模拟)生产代码中的函数/方法。因此,任何生产代码更改的成本都必须包括这样做的成本——如果没有——测试代码将成为“三等公民”,并且设计人员对单元测试套件及其相关性的信心将会降低......显然,投资回报率在于发现和修复错误的时间。

于 2010-06-03T18:21:30.887 回答
1

我在这里依赖的一个原则是消除重复。我通常没有很多不同的假货或模拟来实现这个合同(我使用的假货比模拟更多,部分原因是)。当我更改合同时,很自然地会检查该合同的每个实现、生产代码或测试。当我发现我正在做这种改变时,它让我很烦恼,也许我的抽象应该被更好地考虑等等,但是如果测试代码太繁重而无法改变合同变更的规模,那么我必须问自己是否这些也是由于一些重构。

于 2010-06-03T23:54:18.200 回答
0

我是这样看的,当你的合同发生变化时,你应该把它当作新的合同来对待。因此,您应该为这个“新”合约创建一套全新的 UNIT 测试。您拥有一组现有的测试用例这一事实并不重要。

于 2010-06-03T11:52:52.400 回答
0

我赞同鲍勃叔叔的观点,即问题出在设计上。我还会后退一步检查您的合同设计

简而言之

不要说“x==0 时返回 -1”或“x==y 时抛出 CannotCalculateException”,而是 niftyCalcuatorThingy(x,y)适当的情况下用前置条件x!=y && x!=0指定不足(见下文)。因此,您的存根在这些情况下可能表现得任意,您的单元测试必须反映这一点,并且您具有最大的模块化,即对于所有未指定的情况,可以随意更改被测系统的行为 - 而无需更改合同或测试。

适当的规格不足

您可以根据以下标准区分您的陈述“-1 当它因某种原因失败时”:

  1. 实现可以检查的异常行为?
  2. 在方法的域/职责范围内?
  3. 调用者(或调用堆栈中较早的人)可以通过其他方式恢复/处理的异常?

当且仅当 1) 到 3) 成立时,在合约中指定场景(例如,EmptyStackException在空堆栈上调用 pop() 时抛出的场景)。

没有 1),实现不能保证在例外情况下的特定行为。例如,当不满足自反性、对称性、传递性和一致性的条件时,Object.equals() 不会指定任何行为。

没有 2),不满足 SingleResponsibilityPrinciple,模块化被破坏,代码的用户/读者会感到困惑。例如,Graph transform(Graph original)不应该指定MissingResourceException可能会被抛出,因为在内心深处,通过序列化进行了一些克隆。

没有 3),调用者不能使用指定的行为(某些返回值/异常)。例如,如果 JVM 抛出 UnknownError。

优点和缺点

如果您确实指定了 1)、2) 或 3) 不成立的情况,您会遇到一些困难:

  • (设计)合同的主要目的是模块化。如果您真正分离职责,这是最好的实现:当不满足前提条件(调用者的职责)时,不指定实现的行为会导致最大的模块化 - 正如您的示例所示。
  • 您将来无权更改,甚至无法更改在较少情况下引发异常的方法的更通用功能
  • 异常行为可能变得相当复杂,因此涵盖它们的合约变得复杂、容易出错且难以理解。例如:是否涵盖了所有情况?如果多个异常前提条件成立,哪种行为是正确的?

规范不足的缺点是(测试)稳健性,即实现对异常情况做出适当反应的能力,更难。

作为妥协,我喜欢尽可能使用以下合约模式:

<(半)正式的前置条件和后置条件,包括 1) 到 3) 成立的异常行为>

如果不满足 PRE,则当前实现将抛出 RTE A、B 或 C。

于 2012-10-07T16:26:02.657 回答