代码之家  ›  专栏  ›  技术社区  ›  Stefan Hendriks

单元测试-合同变更的单元测试的好处?

  •  50
  • Stefan Hendriks  · 技术社区  · 14 年前

    最近,我和一位同事讨论了单元测试。我们讨论了当合同变更时,维护单元测试的效率会降低。

    也许有人能启发我如何解决这个问题。让我详细说明一下:

    所以我们假设有一个类可以做一些漂亮的计算。合同规定它应该计算一个数字,或者当它由于某种原因失败时返回-1。

    我有合同测试来测试。在我所有的其他测试中,我都忽略了这个漂亮的计算器。

    所以现在我改变合同,每当它不能计算时,它就会抛出一个无法计算的异常。

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

    出现的问题是,有了对单元测试的信心,在这种变化中能有多少信心……单元测试成功,但在测试应用程序时会出现错误。使用这个计算器的测试将需要固定,这需要花费时间,甚至可能被存根/模拟很多次…

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

    有什么想法吗?

    9 回复  |  直到 12 年前
        1
  •  92
  •   Robert C. Martin    14 年前

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

    导致这种脆弱性的一些简单因素是:

    • 测试显示的字符串。这样的字符串是不稳定的,因为它们的语法或拼写可能会因分析师的一时冲动而改变。
    • 测试应在抽象(如完整时间)后编码的离散值(如3)。
    • 从许多测试中调用相同的API。您应该将API调用包装在一个测试函数中,这样当API更改时,您可以在一个地方进行更改。

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

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

    试验安排如下:

    • 单元测试提供接近100%的代码覆盖率。他们测试独立单元。它们是由程序员使用系统的编程语言编写的。
    • 部件测试覆盖了大约50%的系统。它们由业务分析师和QA编写。它们是用Fitnesse、Selenium、Cucumber等语言编写的,它们测试整个组件,而不是单个单元。他们主要测试快乐路径案例和一些非常明显的不快乐路径案例。
    • 集成测试覆盖了大约20%的系统。他们测试的是与整个系统相反的小组件组件。也用fitnesse/selenium/cumber等书写,由建筑师撰写。
    • 系统测试覆盖了大约10%的系统。他们测试了集成在一起的整个系统。同样,它们是由建筑师用fitnesse/selenium/cumber等书写的。
    • 探索性人工测试。(参见JamesBach)这些测试是手动的,但不是脚本化的。他们运用人类的创造力。
        2
  •  12
  •   b.roth    14 年前

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

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

    但是,这种单元测试覆盖率还将使您有信心重构代码并实现任何合同更改。有些测试将失败,需要修复,但其他测试最终将失败,因为您在这些更改中引入了错误。

        3
  •  5
  •   Péter Török    14 年前

    单元测试当然不能捕获所有的错误,即使在100%代码/功能覆盖的理想情况下也是如此。我认为这是意料之中的。

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

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

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

        4
  •  4
  •   Grzenio    14 年前

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

    1. 验收测试-测试几个或更多类。这些测试通常与需要实现的用户存储相一致,因此您测试用户故事“有效”。这些不需要连接到数据库或其他外部系统,但可能。
    2. 集成测试-主要用于检查外部系统连接等。
    3. 全端到端测试-测试整个系统

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

    另一个注意事项是——由于您提到的使用单元测试的问题教您尽可能地保持组件的分离和它们的契约尽可能小——这绝对是一个很好的实践!

        5
  •  3
  •   Esko Luontola    12 年前

    有人问了同样的问题 Google Group 为书“成长面向对象软件-由测试指导”。线程是 Unit-test mock/stub assumptions rots .

    这里是 J.B. Rainsberger's answer (他是曼宁的作者) JUnit食谱 “”。

        6
  •  2
  •   ratkok    14 年前

    单元测试代码(以及用于测试的所有其他代码)的规则之一是将其与生产代码(不多也不少)一样对待。

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

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

        7
  •  1
  •   Brett    14 年前

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

        8
  •  0
  •   Syd    14 年前

    我是这样看的,当你的合同变更时,你应该像对待新合同一样对待它。因此,您应该为这个“新”契约创建一套全新的单元测试。事实上,您已经有了一组现有的测试用例。

        9
  •  0
  •   DaveFar Fabian Barney    12 年前

    我认为问题出在设计上。我还要后退一步 检查合同的设计 .

    简而言之

    而不是说“X==0时返回-1”或“X==Y时引发无法计算异常”, 欠详细说明 niftyCalcuatorThingy(x,y) 前提是 x!=y && x!=0 在适当的情况下(见下文)。因此,您的存根对于这些情况可能具有任意行为,您的单元测试必须反映出这一点,并且您具有最大的模块性,也就是说,对于所有未指定的情况,您可以随意更改系统在测试中的行为,而无需更改合同或测试。

    适当情况下的非特定化

    您可以根据以下条件区分您的语句“-1,当它由于某种原因失败时”:是场景吗?

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

    如果且仅当1)至3)保持,则在合同中指定方案(例如 EmptyStackException 在空堆栈上调用pop()时引发)。

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

    如果没有2),则不满足单一责任原则,模块性被破坏,代码的用户/读者会感到困惑。例如, Graph transform(Graph original) 不应指定 MissingResourceException 可能会被抛出,因为深层次,通过序列化完成了一些克隆。

    如果没有3,则调用方无法使用指定的行为(某些返回值/异常)。例如,如果JVM抛出一个未知错误。

    利弊

    如果您指定了1)、2)或3)不适用的情况,则会遇到一些困难:

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

    非特定化的缺点是(测试)健壮性,即实现对异常情况做出适当反应的能力,更加困难。

    作为折衷方案,我喜欢尽可能使用以下合同模式:

    <(半)正式的前后条件,包括例外情况 1)至3)保持的行为

    如果不满足pre,则当前实现将抛出rte a、b或 C.