然而,在使用TDD时,我经常编写针对偶然行为的测试。我该怎么办?抛弃它们似乎不太合适,但文章中的建议是这些测试会降低敏捷性。"[...] 测试中一个常见的陷阱是将测试硬编码到实现的细节中,而这些细节是偶然的,与所需功能无关。"
那么将它们分开成一个独立的测试套件呢?听起来像是一个开始,但直觉上似乎不实用。有人这样做吗?
然而,在使用TDD时,我经常编写针对偶然行为的测试。我该怎么办?抛弃它们似乎不太合适,但文章中的建议是这些测试会降低敏捷性。"[...] 测试中一个常见的陷阱是将测试硬编码到实现的细节中,而这些细节是偶然的,与所需功能无关。"
你描述的问题是非常真实且在TDD中很容易遇到的。一般来说,并不是测试附带行为本身成为了问题,而是如果有大量的测试依赖于这些附带行为。
DRY原则适用于测试代码和生产代码。在编写测试代码时,这通常是一个很好的指导方针。目标应该是,你在编写过程中指定的所有‘附带’行为都被隔离起来,只有整个测试套件中的一小部分测试使用它们。这样,如果你需要重构这些行为,你只需要修改几个测试而不是整个测试套件的大部分。
通过广泛使用接口或抽象类作为合作者,可以最好地实现这一点,因为这意味着你能够减少类之间的耦合度。
这里有一个我所说的例子。假设你有一种MVC实现,其中一个控制器应该返回一个视图。假设我们在BookController上有这样一个方法:
public View DisplayBookDetails(int bookId)
public View DisplayBookDetails(int bookId)
{
return this.mapper.Map(this.repository.GetBook(bookId);
}
显然,这只是一个过于简单化的例子,但重点是现在你可以为你的真实IBookMapper实现编写一组测试,这意味着当你测试DisplayBookDetails方法时,你只需使用一个存根(最好由动态模拟框架生成)来实现映射,而不是试图定义图书领域对象与其如何映射之间脆弱而复杂的关系。
使用IBookMaper绝对是一个偶然的实现细节,但如果你使用SUT工厂或者更好的自动模拟容器,那么这个偶然行为的定义就被隔离了,这意味着如果以后你决定重构实现,你只需要在几个地方修改测试代码。
“将它们分离成一个单独的测试套件怎么样?”
你会如何处理这个单独的测试套件呢?
以下是典型的使用情况:
你编写了一些测试,测试了不应该测试的实现细节。
你将这些测试从主套件中分离出来,放入一个单独的套件中。
有人更改了实现。
你的实现套件现在失败了(应该失败)。
现在怎么办?
修复实现测试?我认为不是。重点是不要测试实现,因为这会导致太多的维护工作。
有可能测试失败,但整个单元测试仍然被认为是好的吗?如果测试失败,但失败并不重要,那意味着什么?[阅读此问题以获取示例:非关键单元测试失败 忽略或无关的测试只会带来成本。
你必须丢弃它们。
现在就丢弃它们,节省时间和烦恼,而不是等到它们失败时再丢弃。
如果你真的在进行TDD,那么问题并不像一开始看起来那么大,因为你是在编写代码之前编写测试。在编写测试之前,你甚至不应该考虑任何可能的实现。
当你在实现代码后编写测试时,这种测试偶然行为的问题就会更加普遍。此时,最简单的方法就是检查函数输出是否正确,并编写使用该输出的测试。但这其实是作弊,而不是TDD,作弊的代价就是测试会在实现更改时出现故障。
好消息是,这样的测试比好测试(指仅依赖所需功能而非实现的测试)更容易出现故障。拥有如此通用的测试,它们永远不会出现故障,这是相当糟糕的。
在我工作的地方,我们只需要在遇到这些测试时对其进行修复即可。我们如何修复取决于所执行的偶然测试的类型。
最常见的测试可能是按某个明确的顺序进行测试结果,忽略这个顺序并不保证。简单的解决方法是:对结果和期望结果进行排序。对于更复杂的结构,请使用一些比较器来忽略这种差异。
有时我们会测试最内层的函数,而实际上是一些最外层的函数执行了该功能。这很糟糕,因为重构最内层的函数变得困难。解决方案是编写另一个测试,覆盖相同功能范围的最外层函数级别,然后删除旧测试,只有在此之后才能重构代码。
当这样的测试失败时,如果我们看到了使它们与实现无关的简单方法,我们就会这样做。然而,如果不容易实现,我们可能会选择修复它们,使其仍然依赖于新实现。测试将在下一次实现更改时再次失败,但这并不一定是一个大问题。如果这是一个大问题,那么肯定要放弃那个测试,并找到另一个测试来覆盖该功能,或者更改代码以使其更容易测试。
另一个糟糕的情况是,我们使用某个模拟对象(用作存根)编写了测试,然后模拟对象的行为发生了变化(API 更改)。这是不好的,因为它不会在应该时打破代码,因为更改模拟对象的行为不会改变模拟它的 Mock。解决方法是尽可能使用真实对象而不是模拟对象,或者修复模拟以适应新行为。在这种情况下,模拟行为和真实对象行为都是偶然的,但我们认为测试不会在应该失败时失败比测试在不应该失败时失败更严重。(当然,这样的情况也可以在集成测试级别上处理)。