最近我和一位同事就单元测试进行了一次有趣的讨论。当合同发生变化时,我们正在讨论何时维持单元测试的效率降低。
也许任何人都可以告诉我如何解决这个问题。让我详细说明:
所以,让我们说有一个类可以做一些漂亮的计算。合同说它应该计算一个数字,或者当它因某种原因失败时返回-1。
我有合同测试谁测试。在我所有的其他测试中,我将这个漂亮的计算器存放起来。
所以现在我改变了契约,只要它无法计算就会抛出一个CannotCalculateException。
我的合同测试将失败,我将相应地修复它们。但是,我所有的模拟/存根对象仍将使用旧的合同规则。这些测试会成功,但他们不应该!
提出的问题是,凭借对单元测试的这种信念,对这些变化有多少信心......单元测试成功,但在测试应用程序时会出现错误。使用这个计算器的测试需要修复,这需要花费很多时间,甚至可能被抄袭/嘲笑很多次......
您如何看待此案?我从来没有想过它。在我看来,单元测试的这些变化是可以接受的。如果我不使用单元测试,我也会在测试阶段(测试人员)看到这样的错误。然而,我没有足够的信心指出什么会花费更多的时间(或更少)。
有什么想法吗?
答案 0 :(得分:92)
您提出的第一个问题是所谓的“脆弱测试”问题。您对应用程序进行了更改,并且由于此更改而导致数百个测试中断。发生这种情况时,您遇到了设计问题。您的测试设计为脆弱的。它们与生产代码没有充分分离。解决方案是(就像在所有软件问题中一样)找到一个抽象,将测试与生产代码分离,使生产代码的波动性从测试中隐藏。
造成这种脆弱性的一些简单事情是:
测试设计是TDD初学者经常忽视的重要问题。这通常导致脆弱的测试,然后导致新手拒绝TDD作为“非生产性”。
你提出的第二个问题是误报。你已经使用了很多模拟,你的测试都没有真正测试集成系统。虽然测试独立单元是一件好事,但测试系统的部分和整体集成也很重要。 TDD 不只是关于单元测试。
测试应安排如下:
答案 1 :(得分:12)
最好不得不修复由于故意代码更改导致失败的单元测试而不是没有测试来捕获这些更改最终引入的错误。
当您的代码库具有良好的单元测试覆盖率时,您可能会遇到许多单元测试失败,这些失败不是由于代码中的错误,而是对合同或代码重构的有意更改。
但是,单元测试覆盖率还可以让您有信心重构代码并实施任何合同更改。某些测试将失败并且需要修复,但是由于您在这些更改中引入了错误,其他测试最终会失败。
答案 2 :(得分:5)
单元测试肯定无法捕获所有错误,即使在100%代码/功能覆盖的理想情况下也是如此。我认为这是不可预期的。
如果测试合同发生变化,我(开发人员)应该使用我的大脑相应地更新所有代码(包括测试代码!)。如果我无法更新一些因此仍会产生旧行为的模拟,那就是我的错,而不是单元测试。
这与我修复bug并为其进行单元测试的情况类似,但是我没有仔细思考(并测试)所有类似的情况,其中一些后来证明也是错误的。
所以,是的,单元测试需要维护以及生产代码本身。没有维护,它们会腐烂和腐烂。
答案 3 :(得分:4)
我在单元测试方面有类似的经验 - 当你改变一个类的合同时,你也需要改变其他测试的负载(在很多情况下实际上会通过,这使得它更加困难)。这就是为什么我总是使用更高级别的测试:
请注意,即使您拥有100%的单元测试覆盖率,也不能保证您的应用程序启动!这就是为什么你需要更高级别的测试。有许多不同的测试层,因为测试的东西越低,通常就越便宜(在开发方面,维护测试基础设施以及执行时间)。
作为附注 - 由于您提到的使用单元测试的问题,教会您尽可能地将组件分离并尽可能减少合同 - 这绝对是一种很好的做法!
答案 4 :(得分:3)
有人在Google Group中提出了同样的问题,即“面向成长的面向对象的软件 - 测试指导”。该主题是Unit-test mock/stub assumptions rots。
这是J.B. Rainsberger's answer(他是Manning的“ JUnit Recipes ”的作者)。
答案 5 :(得分:2)
单元测试代码(以及用于测试的所有其他代码)的一个规则是以与生产代码相同的方式对待它 - 不多也不少 - 只是相同。
我对此的理解是(除了保持其相关性,重构,工作等,如生产代码),它应该从投资/成本预期同样的方式来看待它。
可能你的测试策略应该包含一些东西来解决你在初始文章中描述的问题 - 有些内容指明应该检查(执行,检查,修改,修复等)的测试代码(包括存根/模拟)。设计者更改生产代码中的函数/方法。因此,任何生产代码更改的成本必须包括这样做的成本 - 如果不是 - 测试代码将成为“三等公民”,设计师对单元测试套件的信心及其相关性将降低......显然,ROI正处于发现和修复错误的时间。
答案 6 :(得分:1)
我依赖的一个原则是删除重复。我通常没有很多不同的假货或模仿实施这个合同(由于这个原因,我使用的假货比假货更多)。当我更改合同时,检查该合同,生产代码或测试的每个实施都是很自然的。当我发现我正在进行这种改变时,它会让我感到烦恼,我的抽象应该更好地考虑一下,但是如果测试代码太繁重而无法根据合同变更的规模进行更改,那么我必须问自己是否这些也是由于一些重构。
答案 7 :(得分:0)
我这样看,当你的合同发生变化时,你应该把它当成新合同。因此,您应该为此“新”合同创建一组全新的UNIT测试。除了这一点之外,您拥有一组现有测试用例的事实。
答案 8 :(得分:0)
我的第二个叔叔鲍勃认为问题出在设计中。我还会退回一步并检查合同的设计。
而不是说“返回-1表示x == 0”或“抛出CannotCalculateException表示x == y”,未指定 niftyCalcuatorThingy(x,y)
,前提条件为x!=y && x!=0
情况(见下文)。因此,对于这些情况,您的存根可能会任意行为,您的单元测试必须反映这一点,并且您具有最大的模块性,即可以随意更改所有未指定情况下测试系统的行为 - 无需更改合同或测试。
根据以下标准,您可以将语句“-1在由于某种原因失败时”区分开来:是方案
当且仅当1)到3)成立时,在合同中指定场景(例如,在空堆栈上调用pop()时抛出EmptyStackException
。
没有1),实现不能保证特殊情况下的特定行为。例如,当反射性,对称性,传递性和条件反射时,Object.equals()没有指定任何行为。不符合一致性。
没有2),不满足SingleResponsibilityPrinciple,模块化被破坏,代码的用户/读者感到困惑。例如,Graph transform(Graph original)
不应指定可能抛出MissingResourceException
因为内心深处,通过序列化进行了一些克隆。
如果没有3),调用者无法使用指定的行为(某些返回值/异常)。例如,如果JVM抛出UnknownError。
如果你确实指定1),2)或3)不成立的情况,你会遇到一些困难:
缺乏规范的缺点是(测试)稳健性,即实施对异常条件作出适当反应的能力,更难。
作为妥协,我希望尽可能使用以下合同模式:
<(半)正式的PRE和POST条件,包括例外情况 行为1)至3)持有>
如果不满足PRE,则当前实现会抛出RTE A,B或 下进行。