TDD如何处理模拟对象的更改

16
在编写单元测试时,针对每个单元所涉及的对象,我采取了以下步骤(从我对JBrains“Integration Tests are a Scam”的理解中借鉴):
  1. 编写一个单元测试,以确保它向协作对象发送正确的调用和参数
  2. 编写一个单元测试,以确保它处理来自协作对象的所有可能响应。这些响应都是模拟的,因此该单元在隔离状态下进行测试。
  3. 编写一个协作对象测试,以确保它接受调用和参数。
  4. 编写测试以确保发送回每个可能的响应。
当我决定重构在第2步中模拟响应的对象时,我的问题出现了。如果我更改对象响应调用的方式,则其他对象为该调用编写的所有测试都不会失败,因为它们都被模拟以匹配旧样式。如何使模拟与其所模拟的对象保持最新?有没有最佳实践?还是我完全误解了事情,做错了一切?
4个回答

4
我是这么做的。
假设我必须更改接口方法foo()的响应。我将所有模拟foo()的协作测试收集到列表中。我收集方法foo()的所有契约测试,如果没有契约测试,则收集foo()的所有当前实现的所有测试,并将它们放在列表中。
现在我创建一个版本控制分支,因为它会有一段时间非常混乱。
我使用@Ignore(JUnit语言)或其他方式禁用模拟foo()的协作测试,并逐个重新实现和运行这些测试,直到全部通过。我可以在不触及生产环境下的任何foo()实现的情况下完成此操作。
现在,我逐个重新实现实现foo()的对象,并使用与存根的新返回值匹配的预期结果进行实现。请记住:在协作测试中的存根对应于契约测试中的预期结果。
此时,所有协作测试现在都假定来自foo()的新响应,并且契约测试/实现测试现在期望来自foo()的新响应,所以这应该是有效的(TM)。
现在集成您的分支并倒一杯酒。

2

修改后:

这是一种权衡。通过将对象与其环境隔离来轻松测试与当所有部件组合在一起时确保其全部正常运作之间的平衡。

  1. 以稳定的角色为目标:从客户为导向的角度考虑(而不是一堆方法)。我发现以客户需求/客户为先/自外而内编写角色会更加稳定。检查该角色是否泄露实现细节的不良抽象。此外,还要注意那些容易变化的角色,并制定缓解计划。
  2. 如果您必须进行更改,请尽量依赖编译器。例如,更改方法签名将被编译器很好地标记出来。因此请使用它。
  3. 如果编译器无法帮助您发现更改,请比平常更加勤奋地查看您是否错过了某个地方(客户端使用)。
  4. 最后,您可以退回到验收测试来捕捉此类问题-确保对象A和协作者B、C、D遵循相同的假设(协议)。如果有什么东西逃脱了您的搜索范围,那么至少有一个测试应该能够发现它。

退而求其次,采用验收测试应该会有所帮助,但并非必须。我宁愿不依赖于它们,但很高兴将其作为一种紧急错误检测系统。 - J. B. Rainsberger
@J.B.Rainsberger - 是的。我并不是在暗示每一个交互都需要进行高级别/系统测试。然而,一些系统测试应该能够发现交互中的任何异常变化。我们之前在这个问题上进行过辩论http://tech.groups.yahoo.com/group/testdrivendevelopment/message/32743 不确定我是如何错过那个线程上的最后一条消息的。我认为抛出异常是合同的一部分(你似乎在暗示它们是实现细节) 。你能详细解释一下吗?除此之外,在不漏掉任何地方的情况下,给出足够的勤奋,可以安全地进行更改。 - Gishu
1
首先,抛出的异常确实代表了合同的一部分,但我不喜欢在合同中添加异常,除非这是必要的。其次,最重要的是,我不喜欢你的答案将验收测试列为解决此问题的第一和最重要的方法,因为我通常将它们视为最后和最薄弱的防线。勤奋工作,我们都认为可以给我们带来最大的成功机会。 - J. B. Rainsberger
@J.B.Rainsberger - 是的,我能理解读者可能会有这种想法... 已修订 - Gishu

0

首先,使用集成测试来达到这种覆盖率肯定更难,所以我认为单元测试仍然更优秀。不过,我认为你说得有道理。很难保持对象的行为同步。

解决这个问题的方法是进行部分集成测试,其中包含真实服务的一级深度,但超出此范围的部分则使用模拟。例如:

var sut = new SubjectUnderTest(new Service1(Mock.Of<Service1A>(), ...), ...);

这解决了保持行为同步的问题,但增加了复杂度水平,因为现在您必须设置更多的模拟。

您可以使用区分联合在函数式编程语言中解决此问题。例如:

// discriminated union
type ResponseType
| Success
| Fail of string   // takes an argument of type string

// a function
let saveObject x =
    if x = "" then
        Fail "argument was empty"
    else
        // do something
        Success

let result = saveObject arg 

// handle response types
match result with
| Success -> printf "success"
| Fail msg -> printf "Failure: %s" msg

您定义了一个称为ResponseType的带有多个可能状态的区分联合,其中一些状态可以带有参数和其他元数据。每次访问返回值时,都必须处理可能的各种条件。如果您要添加另一种故障类型或成功类型,则编译器将在您不处理新成员的每个时间点给出警告。

这个概念对于处理程序的演变可以走得很远。C#,Java,Ruby和其他语言使用异常来传达失败情况。但是,这些失败情况通常根本不是“异常”情况,这最终导致您正在处理的情况。

我认为函数式语言最接近提供最佳答案的语言。坦白地说,我认为在许多语言中都没有完美的答案,甚至没有好的答案。但是,编译时检查可以大有作为。


我不同意保持模拟和实现同步很难这一说法。通常来说,这可能会有些繁琐,但并不是真正的困难。我对待这个问题的方式就像12年前我对待TDD一样:这是我工作中新发现的一个部分,以前从未有人向我解释过。 - J. B. Rainsberger
@J.B.Rainsberger 如果你在工作期限紧张或者和不喜欢单元测试的开发人员一起工作,保留模拟和实现真的很困难。 - Kerem Baydoğan
1
@KeremBaydoğan 我工作有时间限制。理解我编写的代码有助于我满足时间限制。让代码随着时间恶化会威胁到我满足时间限制的能力。 - J. B. Rainsberger
如果你与不喜欢单元测试的程序员合作,那么你使用的每一种自动化测试技术最终都会失败。如果你想使用自动化测试来帮助你的工作,那么你会发现很难与不想按照这种方式工作的人共享代码。@KeremBaydoğan - J. B. Rainsberger
@J.B.Rainsberger 我和你一样感到对正在处理的代码理解不够。我的意思是我们不应该相信开发人员能够保持模拟和真实软件组件的同步。每个开发人员都应该为他们维护的每个真实对象编写一个模拟对象,完成后编写一个单元测试,并将每个真实对象的行为与模拟对象进行比较。因为评论区不够大,我已经发布了一个答案。如果您看一下并告诉我有什么不对的地方,那就太好了。 - Kerem Baydoğan
@KeremBaydoğan 我们应该能够信任他们。如果我们不能相互信任,那么我们就无法共同合作,规则肯定会改变。 :) - J. B. Rainsberger

0

关于保持模拟和真实软件组件同步的问题,您甚至不能相信人类(包括自己)。

我听到你在问什么?

那么你的建议是什么?

我的建议是:

  1. 你应该编写模拟对象。

  2. 你只应该为你维护的软件组件编写模拟对象。

  3. 如果你和另一个开发者一起维护一个软件组件,你们应该一起维护该组件的模拟对象。

  4. 你不应该模拟别人的组件

  5. 当你为你的组件编写单元测试时,你应该为该组件的模拟对象编写一个单独的单元测试。我们称之为MockSynchTest

  6. MockSynchTest中,你应该将模拟对象的每个行为与真实组件进行比较。

  7. 当你对你的组件进行更改时,你应该运行MockSynchTest,以查看你是否使你的模拟对象和组件失步。

  8. 如果你需要在测试你的组件时使用你不维护的组件的模拟对象,请向该组件的开发者询问模拟对象。如果她能够为你提供经过充分测试的模拟对象,那么她很棒,你也很幸运。如果她不能,那么请友好地要求她遵循这个指南并为你提供一个经过充分测试的模拟对象。

这样做,如果您意外使您的模拟不同步,那么会有一个失败的测试用例来警告您。

这样做,您无需了解外部组件的实现细节即可进行模拟。

如何编写好的测试#不要模拟您不拥有的类型


在一个非常低信任的环境中,这些规则可能会有所帮助,但我不建议将其作为普遍规则。我认为这些规则是应对在共享代码但并非团队合作的环境中工作的一种方式。这些规则似乎只是将同事视为我们消费的产品的生产者;在这种情况下,我们不应该生活在同一个代码库中。 - J. B. Rainsberger

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