【问题标题】:How to use unit tests in projects with many levels of indirection如何在具有多个间接级别的项目中使用单元测试
【发布时间】:2025-12-01 19:15:01
【问题描述】:

我正在查看一个相当现代的项目,该项目非常强调单元测试。按照古老的格言“面向对象编程中的每个问题都可以通过引入新的间接层来解决”,这个项目正在运动多层间接。副作用是相当多的代码如下所示:

public bool IsOverdraft)
{
    balanceProvider.IsOverdraft();
}

现在,由于强调单元测试和保持高代码覆盖率,每段代码都有针对它编写的单元测试。因此,这个小方法将包含三个单元测试。那些会检查:

  1. 如果 balanceProvider.IsOverdraft() 返回 true,则 IsOverdraft 应该返回 true
  2. 如果 balanceProvider.IsOverdraft() 返回 false,则 IsOverdraft 应该返回 false
  3. 如果 balanceProvider 引发异常,则 IsOverdraft 应重新引发相同的异常

更糟糕的是,使用的模拟框架 (NMock2) 接受方法名称作为字符串文字,如下所示:

NMock2.Expect.Once.On(mockBalanceProvider)
    .Method("IsOverdraft")
    .Will(NMock2.Return.Value(false));

这显然使“红、绿、重构”规则变成了“红、绿、重构、在测试中重命名、在测试中重命名、在测试中重命名”。使用 Moq 等不同的模拟框架有助于重构,但需要对所有现有的单元测试进行扫描。

处理这种情况的理想方法是什么?

A) 保留较小级别的层,这样这些转发呼叫就不会再发生了。

B) 不要测试这些转发方法,因为它们不包含业务逻辑。出于覆盖目的,将它们全部标记为 ExcludeFromCodeCoverage 属性。

C) 仅在调用正确的方法时进行测试,而不检查返回值、异常等。

D) 接受它,继续编写这些测试;)

【问题讨论】:

    标签: c# unit-testing mocking tdd


    【解决方案1】:

    我会说 D) 接受它,并继续编写这些测试;) 并尝试看看您是否可以用 MOQ 替换 NMock。

    这似乎没有必要,即使它现在只是委托,但测试正在测试它是否使用正确的参数调用正确的方法,并且方法本身在返回值之前没有做任何时髦的事情。所以在测试中覆盖它们是个好主意。但是为了更容易使用 MOQ 或类似的框架,这将使重构变得更加容易。

    【讨论】:

      【解决方案2】:

      B 或 C。这就是这种一般要求的问题(“每个方法都必须有单元测试,每一行代码都需要被覆盖”) - 有时,它们提供的好处是不值得的成本。如果这是您想出的,我建议重新考虑这种方法。 “我们必须有 95% 的代码覆盖率” 可能在纸面上很有吸引力,但在实践中它很快就会产生像你所遇到的问题。

      另外,您正在测试的代码是我称之为琐碎代码的代码。对其进行 3 次测试很可能是矫枉过正。对于那一行代码,您将不得不维护 40 多个。除非您的软件是关键任务(这可能解释了高覆盖率要求),否则我会跳过这些测试。

      (恕我直言)most pragmatic advices on this topic 之一是 Kent Beck 不久前在这个网站上提供的,我在我的博客文章中对这些想法进行了一些扩展 - What should you test?

      【讨论】:

      • 不错!我很惊讶地看到肯特贝克在这个网站上与凡人交谈 - 非常有趣 - 也是一篇好文章。
      • +1 表示“普通代码”。如果由于某种原因必须存在,我通常不会测试这样的传递层。
      【解决方案3】:

      我认为在单元测试中要牢记的最重要的事情之一是,今天如何实现代码并不一定重要,而是当测试的代码(直接或间接)在未来。

      如果您今天忽略这些方法并且它们对您的应用程序的操作至关重要,那么有人决定在以后的某个时候实现一个新的 balanceProvider 或者决定重定向不再有意义,那么您很可能会遇到故障点.

      所以,如果这是我的应用程序,我首先会考虑将只转发调用减少到最低限度(降低代码复杂性),然后引入一个不依赖字符串值作为方法名称的模拟框架。

      【讨论】:

        【解决方案4】:

        老实说,我认为我们应该编写测试,只是为了以有用的方式记录我们的代码。我们不应该仅仅为了代码覆盖而编写测试。 (代码覆盖率只是一个很好的工具,可以找出它没有被覆盖,这样我们就可以确定我们是否忘记了重要的单元测试用例,或者我们是否真的在某处有一些死代码。

        如果我编写了一个测试,但测试最终只是实现的“重复”或更糟......如果测试比实际实现更难理解....那么真的这样的测试不应该存在。没有人有兴趣阅读这样的测试。 测试不应包含实现细节。测试是关于应该发生的“什么”,而不是“如何”。由于您已经用“TDD”标记了您的问题,我想补充一点,TDD 是一种设计实践。因此,如果我已经提前 100% 确定我将要实现的设计是什么,那么我就没有必要使用 TDD 和编写单元测试(但在所有情况下我都会有涵盖该代码的高级验收测试)。当设计的东西非常简单时,通常会发生这种情况,例如您的示例。 TDD 不是关于测试和代码覆盖,而是真正帮助我们设计代码和记录代码。使用设计工具或文档工具来设计/记录简单/显而易见的事情是没有意义的。

        在您的示例中,通过直接阅读实现比测试更容易理解发生了什么。该测试在文档方面没有增加任何价值。所以我很乐意删除它。

        除此之外,此类测试非常脆弱,因为它们与实现紧密耦合。从长远来看,当您需要重构东西时,这是一场噩梦,因为任何时候您想要更改它们都会破坏的实现。

        我的建议是不要编写此类测试,而是进行更高级别的组件测试或快速集成测试/验收测试,这些测试将在完全不了解内部工作的情况下运行这些层。

        【讨论】:

        • 这也是我倾向于的。在那个项目中,如果不破坏单元测试就不可能重构任何东西,如果你必须修复你的单元测试,那么你就不能真正使用它们来验证没有任何东西被破坏。
        【解决方案5】:

        这里有几件事要补充到讨论中。

        立即逐步切换到更好的模拟框架。 大约 3 年前,我们从 RhinoMock 切换到 Moq。所有新测试都使用最小起订量,当我们更改测试类时,我们经常会切换它。但是没有太大变化或有大量测试用例的代码区域仍在使用 RhinoMock,这没关系。由于进行了切换,我们日常使用的代码要好得多。所有测试更改都可以以这种增量方式发生。

        您编写的测试太多。 在 TDD 中要记住的重要一点是,您应该只编写满足红色测试的代码,并且您应该只编写一个测试来指定一些未编写的测试代码。所以在你的例子中,三个测试是多余的,因为最多需要两个来强制你编写所有的生产代码。 异常测试不会让你写任何新代码,所以没必要写。我大概只会写这个测试:

        [Test]
        public void IsOverdraftDelegatesToBalanceProvider()
        {
            var result = RandomBool();
            providerMock.Setup(p=>p.IsOverdraft()).Returns(result);
            Assert.That(myObject.IsOverDraft(), Is.EqualTo(result);
        }
        

        不要创建无用的间接层。大多数情况下,单元测试会告诉你是否需要间接。大多数间接需求可以通过依赖倒置原则来解决,或者“耦合到抽象,而不是具体化”。由于其他原因需要某些层(我将 WCF ServiceContract 实现设置为一个薄的传递层。我也没有测试该传递)。如果你看到一个无用的间接层,1)确保它真的没用,然后 2)删除它。随着时间的推移,代码混乱会带来巨大的成本。 Resharper 让这一切变得非常简单和安全。

        此外,对于有意义的委派或委派场景,您无法摆脱但需要测试,something like this 使其变得容易得多。

        【讨论】: