测试所需行为与TDD

时间:2009-09-15 14:07:43

标签: unit-testing tdd

在文章Test for Required Behavior, not Incidental Behavior中,Kevlin Henney告诉我们:

  

“[...]测试中常见的陷阱是将测试硬件连接到实现的细节,其中这些细节是偶然的,并且与所需的功能无关。”

但是,在使用TDD时,我经常会为偶然行为编写测试。我该怎么办这些测试?抛弃它们似乎是错误的,但文章中的建议是这些测试可以降低敏捷性。

将它们分成单独的测试套件怎么样?这听起来像是一个开始,但直觉上似乎不切实际。有没有人这样做?

4 个答案:

答案 0 :(得分:3)

根据我的经验,依赖于实现的测试很脆弱,并且在第一次重构时会大量失败。我尝试做的是在编写测​​试时专注于为类派生适当的接口,有效地避免接口中的这​​种实现细节。这不仅解决了脆性测试,而且还促进了更清洁的设计。

这仍然允许进行额外的测试,以检查我所选实施的风险部分,但仅作为对我班级“普通”界面的良好覆盖的额外保护。

对我来说,当我开始编写测试之前,即使考虑实现,也会出现大范式的转变。我最初的惊喜是,生成“极端”测试用例变得更加容易。然后我认识到改进的界面反过来帮助塑造了它背后的实现。结果是我的代码现在没有比界面暴露更多的功能,从而有效地减少了对大多数“实现”测试的需求。

在重构类的内部时,所有测试都将成立。仅在暴露的接口发生变化的情况下,可能需要扩展或修改测试集。

答案 1 :(得分:1)

你描述的问题非常真实,在TDD时很容易遇到。一般来说,你可以说它不是测试附带行为本身这是一个问题,而是大量的测试依赖于偶然的行为。

DRY原则适用于测试代码以及​​生产代码。在编写测试代码时,这通常是一个很好的准则。目标应该是您在此过程中指定的所有“偶然”行为都是隔离的,以便整个测试套件中只有少数测试使用它们。这样,如果您需要重构该行为,您只需修改一些测试而不是整个测试套件的大部分。

最好通过大量使用接口或抽象类作为协作者来实现,因为这意味着您可以获得低级耦合。

这是我的意思的一个例子。假设您有某种MVC实现,其中Controller应返回View。假设我们在BookController上有这样的方法:

public View DisplayBookDetails(int bookId)

实现应该使用注入的IBookRepository从数据库中获取该书,然后将其转换为该书的View。您可以编写大量测试来涵盖DisplayBookDetails方法的所有方面,但您也可以执行其他操作:

定义一个额外的IBookMapper接口,并在IBookRepository之外将其注入BookController。该方法的实现可能是这样的:

public View DisplayBookDetails(int bookId)
{
    return this.mapper.Map(this.repository.GetBook(bookId);
}

显然这是一个过于简单的例子,但重点是现在你可以为你真正的IBookMapper实现编写一组测试,这意味着当你测试DisplayBookDetails方法时,你可以只使用一个Stub(最好由动态模拟框架)实现映射,而不是试图定义Book Domain对象与其映射方式之间的脆弱和复杂关系。

使用IBookMaper绝对是一个偶然的实现细节,但如果你使用SUT Factory或更好的自动模拟容器,那个偶然行为的定义是孤立的,这意味着如果你以后在你身上决定重构实现,只需在几个地方更改测试代码即可。

答案 2 :(得分:1)

“将它们分成单独的测试套件怎么样?”

你会对这个单独的套房做什么?

这是典型的用例。

  1. 您编写了一些测试,测试了他们不应该测试的实现细节。

  2. 您将这些测试从主套件中分解到一个单独的套件中。

  3. 有人改变了实施方式。

  4. 您的实施套件现在失败了(应该如此)。

  5. 现在怎么办?

    • 修复实施测试?我想不是。关键是不测试实现,因为它导致了很多维护工作。

    • 测试可能会失败,但整体单元测试运行仍然被认为是好的?如果测试失败,但失败无关紧要,这甚至意味着什么? [请阅读此问题的示例:Non-critical unittest failures忽略或无关的测试费用很高。

    你必须丢弃它们。

    通过现在丢弃它们而不是在它们失败时节省自己一些时间和恶化。

答案 3 :(得分:0)

真的做TDD这个问题没有像现在看起来那么大,因为你在代码之前编写了测试。在编写测试之前,您甚至不应考虑任何可能的实现。

当您在实现代码之后编写测试时,这种测试偶然行为的问题会更加常见。然后,简单的方法就是检查函数输出是否正常并执行您想要的操作,然后使用该输出编写测试。真的是作弊,而不是TDD,作弊的代价是如果实施改变会破坏的测试。

好处是这样的测试会比良好的测试更容易破坏(良好的测试意味着这里的测试仅取决于所需的功能,而不依赖于实现)。测试如此通用,他们永远不会破坏更糟糕。

在我工作的地方我们所做的只是在我们偶然发现它们时修复这些测试。我们如何解决这些问题取决于所进行的偶然测试。

  • 最常见的此类测试可能是测试结果以某种确定的顺序发生而忽略此顺序的情况,实际上并不能保证。简单的修复很简单:对结果和预期结果进行排序。对于更复杂的结构,使用一些忽略这种差异的比较器。

  • 我们经常测试最里面的功能,而它是执行功能的最外层功能。这很糟糕,因为重构最里面的功能变得困难。解决方案是在最外层功能级别编写覆盖相同功能范围的另一个测试,然后删除旧测试,然后我们才能重构代码。

  • 当这样的测试中断并且我们看到一种简单的方法使它们实现独立时我们就这样做了。然而,如果不容易,我们可能会选择修复它们仍然依赖于实现,但取决于新的实现。测试将在下一次实施更改时再次中断,但这不一定是一个大问题。如果这是一个大问题,那么一定要扔掉那个测试并找到另一个来覆盖该功能,或者更改代码以便更容易测试。

  • 另一个不好的情况是我们使用一些Mocked对象(用作存根)编写测试,然后模拟对象行为更改(API更改)。这个很糟糕,因为它不应该破坏代码,因为更改模拟对象的行为不会改变Mock模仿它。这里的修复是尽可能使用真实对象而不是模拟,或修复Mock以获得新行为。在这种情况下,Mock行为和真实对象行为都是偶然的,但是我们认为测试不会失败,当它们应该是一个更大的问题而不是测试时它们不应该破坏。 (在这种情况下,也可以在集成测试级别处理这些案例)。