测试所需行为与测试驱动开发(TDD)的区别

5
在文章“测试必需行为而非偶然行为”中,Kevlin Henney建议我们:

"[...] 测试中一个常见的陷阱是将测试硬编码到实现的细节中,而这些细节是偶然的,与所需功能无关。"

然而,在使用TDD时,我经常编写针对偶然行为的测试。我该怎么办?抛弃它们似乎不太合适,但文章中的建议是这些测试会降低敏捷性。
那么将它们分开成一个独立的测试套件呢?听起来像是一个开始,但直觉上似乎不实用。有人这样做吗?

我认为如果您包含一个示例,会更有帮助。这绝对是一个有效的问题,但可能会被解释成不同的方式。 - Frank Schwieterman
4个回答

3
根据我的经验,依赖于实现的测试很容易出错,并且在第一次重构时就会大规模失败。我尝试的做法是在编写测试时专注于为类推导出适当的接口,从而在接口中避免这些实现细节。这不仅解决了脆弱的测试问题,还促进了更清晰的设计。
这仍然允许额外的测试来检查我选择的实现中存在的风险部分,但仅作为对类的“正常”接口进行良好覆盖的额外保护。
对我而言,最大的范式转变发生在我开始编写测试之前。我最初的惊喜是,它变得更容易生成“极端”测试用例。然后我意识到,改进的接口反过来有助于塑造其背后的实现。结果是,我的代码现在并不比接口公开的内容多,有效地减少了大部分“实现”测试的需求。
在类的内部重构期间,所有测试都将保持不变。只有在公开接口发生更改的情况下,测试集可能需要扩展或修改。

嗨Timo。感谢你的回答。困难在于,使用TDD,测试驱动实现,因此根据定义,测试依赖于实现。就我所知,只要我的实现被100%的测试覆盖,并且我可以自由地重构实现(使用Feathers的重构定义-即行为不会改变),那么这是可以接受的。我遇到的问题是“不要测试偶发行为”的哲学:从美学上讲,这对我有意义,但实际上,我不知道如何在不失去TDD测试提供的实现覆盖率的情况下做到这一点。 - Matt Curtis
我现在已经将我的个人测试优先见解添加到了我的答案中。 - Timo

1

你描述的问题是非常真实且在TDD中很容易遇到的。一般来说,并不是测试附带行为本身成为了问题,而是如果有大量的测试依赖于这些附带行为。

DRY原则适用于测试代码和生产代码。在编写测试代码时,这通常是一个很好的指导方针。目标应该是,你在编写过程中指定的所有‘附带’行为都被隔离起来,只有整个测试套件中的一小部分测试使用它们。这样,如果你需要重构这些行为,你只需要修改几个测试而不是整个测试套件的大部分。

通过广泛使用接口或抽象类作为合作者,可以最好地实现这一点,因为这意味着你能够减少类之间的耦合度。

这里有一个我所说的例子。假设你有一种MVC实现,其中一个控制器应该返回一个视图。假设我们在BookController上有这样一个方法:

public View DisplayBookDetails(int bookId)

实现应该使用注入的IBookRepository从数据库中获取书籍,然后将其转换为该书籍的视图。您可以编写许多测试来覆盖DisplayBookDetails方法的所有方面,但您也可以做其他事情:
定义一个额外的IBookMapper接口,并将其注入到BookController中,除了IBookRepository之外。然后,该方法的实现可能是这样的:
public View DisplayBookDetails(int bookId)
{
    return this.mapper.Map(this.repository.GetBook(bookId);
}

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

使用IBookMaper绝对是一个偶然的实现细节,但如果你使用SUT工厂或者更好的自动模拟容器,那么这个偶然行为的定义就被隔离了,这意味着如果以后你决定重构实现,你只需要在几个地方修改测试代码。


1

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

你会如何处理这个单独的测试套件呢?

以下是典型的使用情况:

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

  2. 你将这些测试从主套件中分离出来,放入一个单独的套件中。

  3. 有人更改了实现。

  4. 你的实现套件现在失败了(应该失败)。

现在怎么办?

  • 修复实现测试?我认为不是。重点是不要测试实现,因为这会导致太多的维护工作。

  • 有可能测试失败,但整个单元测试仍然被认为是好的吗?如果测试失败,但失败并不重要,那意味着什么?[阅读此问题以获取示例:非关键单元测试失败 忽略或无关的测试只会带来成本。

你必须丢弃它们。

现在就丢弃它们,节省时间和烦恼,而不是等到它们失败时再丢弃。


0

如果你真的在进行TDD,那么问题并不像一开始看起来那么大,因为你是在编写代码之前编写测试。在编写测试之前,你甚至不应该考虑任何可能的实现。

当你在实现代码后编写测试时,这种测试偶然行为的问题就会更加普遍。此时,最简单的方法就是检查函数输出是否正确,并编写使用该输出的测试。但这其实是作弊,而不是TDD,作弊的代价就是测试会在实现更改时出现故障。

好消息是,这样的测试比好测试(指仅依赖所需功能而非实现的测试)更容易出现故障。拥有如此通用的测试,它们永远不会出现故障,这是相当糟糕的。

在我工作的地方,我们只需要在遇到这些测试时对其进行修复即可。我们如何修复取决于所执行的偶然测试的类型。

  • 最常见的测试可能是按某个明确的顺序进行测试结果,忽略这个顺序并不保证。简单的解决方法是:对结果和期望结果进行排序。对于更复杂的结构,请使用一些比较器来忽略这种差异。

  • 有时我们会测试最内层的函数,而实际上是一些最外层的函数执行了该功能。这很糟糕,因为重构最内层的函数变得困难。解决方案是编写另一个测试,覆盖相同功能范围的最外层函数级别,然后删除旧测试,只有在此之后才能重构代码。

  • 当这样的测试失败时,如果我们看到了使它们与实现无关的简单方法,我们就会这样做。然而,如果不容易实现,我们可能会选择修复它们,使其仍然依赖于新实现。测试将在下一次实现更改时再次失败,但这并不一定是一个大问题。如果这是一个大问题,那么肯定要放弃那个测试,并找到另一个测试来覆盖该功能,或者更改代码以使其更容易测试。

  • 另一个糟糕的情况是,我们使用某个模拟对象(用作存根)编写了测试,然后模拟对象的行为发生了变化(API 更改)。这是不好的,因为它不会在应该时打破代码,因为更改模拟对象的行为不会改变模拟它的 Mock。解决方法是尽可能使用真实对象而不是模拟对象,或者修复模拟以适应新行为。在这种情况下,模拟行为和真实对象行为都是偶然的,但我们认为测试不会在应该失败时失败比测试在不应该失败时失败更严重。(当然,这样的情况也可以在集成测试级别上处理)。


嗨Kriss,感谢你的回答。我们正在真正意义上进行TDD,实现完全由测试驱动(遵循Uncle Bob的极简规则,编写足够的测试等等),但这意味着测试根据定义是特定于实现的,对吧?我认为在某种程度上可能没问题,只要测试测试的组件足够小,并且组件是可分离的。但是如何为行为和实现组织测试?是否存在(不好的)偶然测试和(值得进行保留行为重构的)实现测试之间的重叠? - Matt Curtis

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接