TDD Mocking - 指定模拟对象行为是否属于白盒测试?

11

最近我开始接触TDD,读完了Kent Beck关于测试驱动开发的书后,在我的脑海中仍然有很多关于测试设计的问题。

我目前遇到的一个问题是如何使用Mock对象。以下是一个生成报告的非常简单的代码:

public string MakeFinancialReport()
{
    return sys1.GetData() + sys2.GetData() + sys3.GetData();
}

报告必须包含标题、正文和页脚。因此,为了快速测试报告中是否存在这些标题:

public void TestReport()
{
    string report = MakeFinancialReport();
    Assert.IsTrue(report.Contains("[Title]") && report.Contains("[Body]") && report.Contains("[Footer]"));
 }
为了隔离方法,我想要模拟掉sys1、sys2和sys3的调用。但是,如果它们都是模拟的,那么我还剩下什么可以测试的呢?此外,当我模拟它们时,为什么必须告诉模拟对象它们将被期望调用一次并返回X等信息呢?难道不应该只进行黑盒测试,让MakeFinancialReport方法可以尽可能多地调用来构建报告吗?
我对这个小问题感到困惑,不确定自己缺少了什么。在我看来,Mocking会带走可测试的代码,对于大多数简单的方法而言,剩下的可测试部分也没有太大帮助。
4个回答

4

马丁,我认为你应该在sys1-3中使用mocks,但它们只需要足够简单,以返回每个字符的单个字符串。

这意味着你的测试应该像这样:

public void TestReport()
{
    // Setup mocks for sys1-3
    string report = MakeFinancialReport();
    Assert.IsTrue(report.equals("abc"));
}

这表明MakeFinancialReport具有从sys1-3调用GetData()的属性,并按照特定顺序连接结果。

+1 很棒的回答。如果每个人都清楚地使用模拟,它们将被更广泛地接受。 - Carl Manaster
@quamrana 我们在进行白盒测试吗?毕竟,我们需要知道 MakeFinancialReport 的实现才能编写这个测试。如果我们有一天需要添加 sys4,那么我们将不得不修改 MakeFinancialReport 和测试两者。这是否意味着使用模拟会损害灵活性? - satoru
@Satoru.Logic,我们这里不进行白盒测试 - 请查看tdd标签。如果我们有一天编写了一个sys4,那么我们将需要一个新的测试来测试真实的sys4,并修改上面的测试以测试MakeFinancialReport是否执行了新的操作。不同的职责在不同的测试中。 - quamrana

3
目前,MakeFinancialReport 除了与更有趣的合作者交互外,几乎什么都不做,可能不值得进行单元测试。
如果我为该方法编写任何单元测试,我可能只会验证当其合作者返回 null 时该方法是否按照我的预期执行,主要是为了记录预期行为(或者在提前执行时帮助我决定预期行为)。目前该方法将失败。也许这没问题,但是值得考虑的是,您是否想将 null 视为空字符串,并且单元测试可以证明您决定采取的任何行为都是有意的。
“指定模拟对象行为是否属于白盒测试?”绝对是 - 如果您的类具有正在模拟的依赖项,则您的测试将与该依赖项相关联。但白盒测试也有其优点。并非所有合作者交互都像您的示例中那样微不足道。

2
只有在必要的情况下才应该使用模拟对象。如果MakeFinancialReportsys1sys2sys3都具有复杂的逻辑,则需要独立测试每个对象。通过向MakeFinancialReport提供三个sysX对象的模拟版本,可以消除它们的复杂性,并仅测试MakeFinancialReport
当您想要测试错误条件和异常处理时,模拟对象尤其有用,因为可能很难从真实对象中强制出现异常。
当您谈论不必设置明确的期望值和返回值时,那是一个相关的概念称为存根。您可能会发现Martin Fowler的“Mocks Aren't Stubs”有所帮助。
Kent Beck的书是一个很好的介绍,但如果您想了解更多详细信息,我强烈推荐xUnit Patterns book。例如,它有关于mock objects的部分,以及更一般的test doubles类别。

1
嗯,那我想我在测试设计原则上有所遗漏。我认为单元测试是为了测试一个方法,一个小的离散代码片段,而不是它的任何协作对象。在这种情况下,协作对象sys1、sys2和sys3各自都有一组单元测试,在单独的单元测试中进行覆盖。我多次阅读了马丁·福勒的文章,并从中了解到模拟和存根之间的区别在于状态和行为风格测试之间的区别。 - Martin
我仍然需要理解为什么我们使用它们,因为我们试图消除在方法/单元之外调用代码的复杂性,以便我们可以专注于一个方法的行为。因此,在这种情况下,如果我们通过模拟去掉了3个系统调用,那么在MakeFinancialReport()方法中到底剩下什么? - Martin
2
在你的例子中,@Martin,剩下的只有字符串拼接。这不是很好测试。如果没有什么可测试的,那么这就是一种代码异味,会促使你进行“内联方法”重构。也许你应该寻找一个更好的例子。 - Don Kirkby
这里有一个相关的问题:https://dev59.com/yHM_5IYBdhLWcg3wWRuV - Don Kirkby
是的,@Martin,这个方法太简单了。你可以决定它不值得测试,但我要更进一步地说,“如果它不值得测试,那为什么还要有它呢?”“内联方法”重构将这些琐碎的代码从方法中剪切出来,并将其粘贴到调用该方法的所有位置。然后删除该方法。有时候有琐碎的方法是有原因的,比如封装,但这应该是一个有意识的选择,而不是偶然发生的事情。这可能偏离了您最初的问题,对此我感到抱歉。 - Don Kirkby
显示剩余2条评论

0

我认为问题之一是您的测试将sys1、sys2和sys3的职责与TestReport方法混合在一起。在我看来,您应该将测试分成两个部分:

1)MakeFinancialReport()返回sys1、sys2、sys3的连接。您可以使用以下内容对sys1等进行存根处理:

var sys1 =MockRepository.GenerateStub<ISys>();
sys1.Expect(s=>s.GetData()).Return("Part 1");
// etc... for sys2, sys3 var
reportMaker = new ReportMaker(sys1,sys2, sys3); 
Assert.AreEqual("Part 1" + "Part 2" + "Part 3", reportMaker.MakeFinancialReport();

拥有MakeFinancialReport()方法的类不应该关心或知道sys类正在做什么。它们可以返回任何类 - MakeFinancialReport()只是连接而已,这就是您应该测试的内容(如果您认为值得)。

2)从接口sys1、sys2、sys3实现中测试GetData()方法。这可能是您检查期望在什么情况下看到“Body”、“Title”等的情况。
模拟可能过度,但它为您提供了一个廉价的依赖项(三个sys实例)的实例化,并清晰地分离了sys所做的事情和MakeFinancialReport所做的事情。

顺便说一句,可能是因为您使用的语言,但令人惊讶的是,您的测试没有始于拥有MakeFinancialReport()的类的实例化。


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