如何避免易碎的单元测试?


24

我们已经编写了将近3,000个测试-数据已经过硬编码,很少重复使用代码。这种方法已经开始使我们陷入困境。随着系统的变化,我们发现自己花费更多的时间来修复损坏的测试。我们有单元测试,集成测试和功能测试。

我正在寻找一种确定的方式来编写可管理和可维护的测试。

构架


这更适合于Programmers.StackExchange,IMO ...
IAbstract

Answers:


21

不要将它们视为“破碎的单元测试”,因为它们不是。

它们是规范,您的程序不再支持这些规范。

不要认为它是“修复测试”,而是“定义新需求”。

测试应首先指定您的应用程序,而不是相反。

您不能说自己有一个可行的实现,直到您知道它可行。您不能说它有效,直到您对其进行测试。

其他一些注意事项可能会指导您:

  1. 测试被测类应该简短而简单。每次测试仅应检查是否有凝聚力。也就是说,它并不关心其他测试已经检查过的事情。
  2. 测试和您的对象应该松散地耦合在一起,这样,如果您更改对象,则只需向下更改其依赖关系图,而使用该对象的其他对象则不受此影响。
  3. 您可能正在创建和测试错误的内容。您的对象是否构建为易于接口或易于实现?如果是后一种情况,您将发现自己更改了许多使用旧实现接口的代码。
  4. 在最佳情况下,请严格遵守“单一责任”原则。在更坏的情况下,请遵循接口隔离原则。请参阅SOLID原则

5
+1Don't think of it as "fixing the tests", but as "defining new requirements".
StuperUser 2011年

2
+1 测试应首先指定您的应用程序,而不是其他方法
treecoder

11

您所描述的内容实际上可能并不是一件坏事,但它是测试发现的更深层问题的指针

随着系统的变化,我们发现自己花费更多的时间来修复损坏的测试。我们有单元测试,集成测试和功能测试。

如果您可以更改代码,并且测试不会中断,那对我来说将是可疑的。合法更改和错误之间的区别仅在于请求的事实,而测试定义了请求的内容(假定为TDD)。

数据已被硬编码。

测试中的硬编码数据是一件好事。测试只是伪造,而不是证明。如果计算过多,则测试可能是重言式的。例如:

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

抽象度越高,您就越接近算法,从而越接近将自动实现与其自身进行比较。

很少重复使用代码

与jUnits一样assertThat,测试中代码的最佳重用是imho'Checks' ,因为它们使测试保持简单。除此之外,如果可以将测试重构为共享代码,则实际测试的代码也可能如此,从而将测试减少为测试重构基础的测试。


我想知道下降投票者的不同意见。
keppla 2011年

keppla-我不是讨价还价者,但是通常,根据我在模型中的位置,我更喜欢测试对象交互而不是在单元级别测试数据。在集成级别上测试数据效果更好。
里奇·梅尔顿

@keppla我有一个类,如果其总项目中包含某些受限项目,则该类会将订单路由到其他渠道。我创建了一个假订单,其中填充了4个项目,其中两个是受限项目。至于添加的限制项目,此测试是唯一的。但是创建虚假订单并添加两个常规物料的步骤与另一个测试使用的设置相同,另一个测试用于测试非受限物料工作流程。在这种情况下,如果订单需要具有客户数据设置和地址设置等信息,则这与重复使用设置助手的良好情况无关。为什么只主张重用?
阿西夫·设拉子

6

我也有这个问题。我改进的方法如下:

  1. 不要编写单元测试,除非它们是测试某些东西的唯一好方法。

    我完全准备承认单元测试的诊断成本和修复时间最低。这使它们成为有价值的工具。问题是,随着里程数的变化,单元测试通常过于琐碎,不足以维持代码量。我在底部写了一个例子,看看。

  2. 在与该组件的单元测试等效的地方使用断言。断言具有很好的属性,可以在任何调试版本中始终对其进行验证。因此,您不是在单独的测试单元中测试“ Employee”类约束,而是通过系统中的每个测试用例有效地测试Employee类断言还具有不错的属性,即它们不会像单元测试那样增加代码量(最终需要脚手架/模拟/任何方式)。

    在有人杀死我之前:生产构建不应因断言而崩溃。而是,他们应该在“错误”级别登录。

    作为对尚未考虑的人的警告,请勿在用户或网络输入中声明任何内容。这是一个巨大的错误™。

    在我最新的代码库中,我明智地删除了在有明显断言机会的地方进行的单元测试。这大大降低了整体维护成本,使我变得更加快乐。

  3. 首选系统/集成测试,以针对所有主要流程和用户体验实施它们。极端情况可能不需要在这里。系统测试通过运行所有组件来验证用户端的行为。因此,系统测试的速度必然较慢,因此编写重要的测试(不要多也不少),您会遇到最重要的问题。系统测试的维护费用非常低。

    请记住,关键是,由于您使用断言,因此每个系统测试将同时运行数百个“单元测试”。您还可以放心,最重要的那些可以多次运行。

  4. 编写可以进行功能测试的强大API。如果您的API过于难以自行验证功能组件,那么功能测试将很尴尬,并且(让我们面对现实)毫无意义。良好的API设计a)使测试步骤简单明了,b)产生清晰而有价值的断言。

    功能测试是最难解决的问题,尤其是当您具有跨过程障碍进行一对多或(甚至更糟糕的是,哦,上帝)多对多通信的组件时。附加到单个组件的输入和输出越多,功能测试就越困难,因为您必须隔离其中的一个才能真正测试其功能。


关于“不要编写单元测试”的问题,我将举一个例子:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

该测试的作者添加了七行内容,这些内容完全对最终产品的验证没有帮助。用户永远不会看到这种情况,这是因为a)没有人应该在那里传递NULL(然后写一个断言)或b)NULL的情况会引起一些不同的行为。如果是(b),请编写一个实际验证该行为的测试。

我的理念是,我们不应测试实现工件。我们应该只测试任何可以视为实际输出的东西。否则,无法避免在单元测试(强制执行特定实现)和实现本身之间编写两倍的基本代码量。

重要的是要注意,这里有很好的单元测试候选人。实际上,甚至在某些情况下,单元测试是验证某件事的唯一适当方法,并且在这种情况下编写和维护这些测试具有很高的价值。在我的头上,该列表包括非平凡的算法,API中公开的数据容器以及看起来“复杂”的高优化代码(又名“下一个家伙可能会搞砸了”。)。

然后,我向您提供具体建议:明智地开始删除单元测试,以免问自己一个问题:“这是输出,还是我在浪费代码?” 您可能会成功地减少浪费时间的事情。


3
首选系统/集成测试-这真是令人讨厌。您的系统达到了这样的程度:它使用这些(慢!)测试来测试可能在单元级别上迅速捕获的内容,并且由于要进行许多类似而缓慢的测试,因此它们需要花费数小时才能运行。
里奇·梅尔顿

1
@RitchMelton与讨论完全分开,听起来您需要一个新的CI服务器。CI不应那样做。
安德烈斯·贾恩·塔克

1
崩溃的程序(这是断言的作用)不应杀死测试运行程序(CI)。这就是为什么您有一个测试跑步者的原因。因此某些东西可以检测并报告此类故障。
Andres Jaan Tack的

1
我熟悉的仅调试的“声明”式断言(不是测试断言)会弹出一个对话框,该对话框挂起CI,因为它正在等待开发人员交互。
里奇·梅尔顿

1
嗯,那可以很好地解释我们之间的分歧。:)我指的是C风格的断言。我才刚刚注意到这是一个.NET问题。cplusplus.com/reference/clibrary/cassert/assert
Andres Jaan Tack

5

在我看来,您的单元测试就像一种魅力。这是一个很好的事情,它是如此脆弱的变化,因为这是整点的排序。对代码中断测试进行小的更改,以便您消除整个程序中出错的可能性。

但是,请记住,您只需要真正测试可能会使您的方法失败或产生意外结果的条件。如果存在真正的问题而不是琐碎的事情,这将使您的单元测试更易于“破坏”。

尽管在我看来,您正在大量重新设计该程序。在这种情况下,请执行所需的任何操作并删除旧测试,然后再用新测试替换。仅当由于程序的重大更改而无法修复时,才需要修复单元测试。否则,您可能会发现您花了太多时间来重写测试,以致无法在您的程序代码的新编写部分中应用。


3

我相信其他人会提供更多的投入,但是以我的经验,这些是可以帮助您的一些重要事项:

  1. 使用测试对象工厂来构建输入数据结构,因此您无需重复该逻辑。也许看看诸如AutoFixture之类的帮助程序库,以减少测试设置所需的代码。
  2. 对于每个测试类,都集中创建SUT,因此在重构时很容易进行更改。
  3. 请记住,测试代码与生产代码一样重要。如果您发现自己在重复自己,代码感到难以维护等,也应该对其进行重构。

您在测试之间重用代码的次数越多,代码变得脆弱,因为现在更改一个测试会破坏另一个测试。这可能是一个合理的成本,以换取可维护性-我在这里不讨论这个问题-但认为第1点和第2点使测试变得不那么脆弱(这是问题)是错误的。
pdr

@driis-对,测试代码与运行代码有不同的习惯用法。通过重构“通用”代码并使用诸如IoC容器之类的东西来隐藏事物,只会掩盖测试中暴露出的设计问题。
里奇·梅尔顿

尽管@pdr提出的观点可能对单元测试有效,但我认为对于集成/系统测试,考虑“为任务X准备应用程序”可能会很有用。这可能涉及导航到正确的位置,设置某些运行时设置,打开数据文件等等。如果多个集成测试在同一位置开始,那么如果您了解这种方法的风险和局限性,那么重构该代码以在多个测试中重用它可能不是一件坏事。
CVn

2

像处理源代码一样处理测试。

版本控制,检查点发布,问题跟踪,“功能所有权”,计划和工作量估算等。在此之前-我认为这是处理您所描述的问题的最有效方法。


1

您绝对应该看一下Gerard Meszaros的XUnit测试模式。它有很多章节,其中包含许多配方,可以重用您的测试代码并避免重复。

如果您的测试很脆弱,也可能是您没有采取足够的方法来测试双打。尤其是,如果您在每个单元测试开始时重新创建对象的整个图,则测试中的“排列”部分可能会变得过大,并且您可能经常会发现自己不得不重写大量测试中的“排列”部分。您最常用的类之一已更改。小样和存根可以通过减少需要补水以具有相关测试环境的对象的数量来为您提供帮助。

通过模拟和存根从测试设置中删除不重要的细节,并将测试模式应用于重用代码应该会大大降低其脆弱性。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.