【问题标题】:TDD: Why is there only one test per function?TDD:为什么每个函数只有一个测试?
【发布时间】:2009-12-26 01:06:14
【问题描述】:

我很难理解为什么在我见过的大多数专业 TDD 代码中每个函数只有一个测试。当我最初接触 TDD 时,如果它们是相关的,我倾向于将每个功能分组 4-5 个测试,但我发现这似乎不是标准。我知道每个函数只有一个测试更具描述性,因为您可以更轻松地缩小问题的范围,但我发现自己很难想出函数名称来区分不同的测试,因为许多测试非常相似。

所以我的问题是:将多个测试放在一个函数中真的是一种不好的做法,如果是这样,为什么?那里有共识吗?谢谢

编辑:哇很多很棒的答案。我相信。您需要真正将它们全部分开。我经历了一些我最近编写的测试并将它们全部分开,你瞧,它更容易阅读并帮助我更好地理解我正在测试的内容。此外,通过给测试提供自己冗长的名称,它给了我诸如“哦等等我没有测试其他东西”这样的想法,所以我认为这是要走的路。

很好的答案。很难选出赢家

【问题讨论】:

    标签: oop tdd


    【解决方案1】:

    看起来您在问“为什么在我见过的大多数专业 TDD 代码中每个测试只有一个 断言”。这可能是为了增加测试隔离,以及出现故障时的测试覆盖率。这当然是我以这种方式制作我的 TDD 库(用于 PHP)的原因。说你有

    function testFoo()
    {
        $this->assertEquals(1, foo(10));
        $this->assertEquals(2, foo(20));
        $this->assertEquals(3, foo(30));
    }
    

    如果第一个断言失败,您将看不到其他两个断言会发生什么。这并不能完全帮助查明问题:这是特定于输入的问题,还是系统性的?

    【讨论】:

    • 嗯,有趣的一点。为了改变这一点,我假设我的测试代码的大小可能会爆炸。我想这只是做生意的成本。
    • @John Baker:是的,单断言单元测试的阴暗面是臃肿。但是在我进入 TDD 业务后不久我就很清楚,用实现语言编写单元测试是一个错误,这个业务真的需要一个解耦的 DSL,你可以在其中编写多个-assertioin 测试并保留单断言测试的“我想看到所有失败”的属性。
    • @just 某人:哇,你让我大吃一惊。有没有这样的框架?
    • 是的,很有趣。我第二个问题是你是否已经知道一个具体的例子。
    • @John Baker & @Tchalvak:遗憾的是,我还没有开始使用这样的接口扩展我的 TDD 库,因为“本机”接口并没有真正被破坏,因为它可以完成工作,如果如果有一些重复,很多 PHP 程序员在面对一种小型专业语言时会感到非常沮丧。 :] 也就是说,TODO 上的文章已经写了三年了……是的,时间过得像箭。
    【解决方案2】:

    是的,您应该在 TDD 中为每个函数测试一个行为。原因如下。

    1. 如果您在编写代码之前编写测试,那么在一个函数中测试多个行为意味着您要同时实现多个行为,这是一个糟糕的想法。
    2. 每个函数测试一个行为意味着如果测试失败,您确切地知道它失败的原因,并且可以针对特定问题区域进行归零。如果您在单个函数中测试了多个行为,则“稍后”测试中的失败可能是由于早期测试中未报告的失​​败导致状态不佳。
    3. 每个函数测试一个行为意味着如果需要重新定义该行为,您只需担心特定于该行为的测试,而不必担心其他不相关的测试(嗯,至少不是由于测试布局...)

    还有最后一个问题——为什么每个函数都有一个测试?有什么好处?我认为函数声明不需要征税。

    【讨论】:

      【解决方案3】:

      建议使用高粒度的测试,这不仅是为了便于识别问题,还因为函数内部的排序测试可能会意外隐藏问题。例如,假设使用参数bar 调用方法foo 应该返回23 - 但由于对象初始化其状态的方式存在错误,它返回42 而不是作为第一个调用新构造的对象上的方法(之后,它确实正确地切换到返回23)。如果您在对象创建后没有立即对foo 进行测试,那么您将错过这个问题;如果你一次将 5 个测试捆绑在一起,那么你只有 20% 的机会意外地把它做对了。每个功能进行一次测试(当然,每次都干净地重置和重建所有内容的设置/拆卸安排),您将立即确定错误。现在这是一个人为的简单问题,只是出于说明的原因,但一般问题 - 测试不应该相互影响,但通常会影响,除非它们每个都被设置和拆除功能括起来 - 确实很重要。

      是的,很好地命名事物(包括测试)不是一个小问题,但不能将其作为避免适当粒度的借口。一个有用的命名提示:每个测试都会检查给定的特定行为——例如,诸如“2008 年复活节在 3 月 23 日”之类的东西——not 用于通用“功能”,例如“正确计算复活节日期”。

      【讨论】:

      • 嗯,似乎基于该论点,您只会遇到相反的问题。如果你有一个错误,其中第一次返回为真,但进一步的返回是错误的,那么你永远不会通过精细的一次性测试来捕捉它,不是吗?
      • ...除了需要明确检查连续的方法调用是否按照它们应该的方式(通过对象状态)相互影响,包括在不应该相互影响时不相互影响到——例如因为您可能确实需要检查两个 foo 调用是否返回相同的值(如果对象状态应该在它们之间保持不变),那么您的测试之一将是两个 foo 调用背靠背——只要那个test 在它自己的函数中,它会发现任何一个调用的问题(只有当它遵循其他不相关的测试时,问题才可能被掩盖)。
      • +1 保持测试简单是一个黄金建议。您最不想看到的就是测试中的错误。
      • 在工作中一直在努力解决这个问题。测试如此复杂,以至于他们自己需要设计文档。快把我逼疯了!
      【解决方案4】:

      我很难理解为什么在我见过的大多数专业 TDD 代码中每个函数只有一个测试

      当您说“测试”时,我假设您的意思是“断言”。一般来说,一个测试应该只测试一个函数的一个“用例”。我所说的“用例”是指:代码可以通过控制流语句流过的路径(不要忘记处理的异常等)。本质上,您正在测试该功能的所有“要求”。例如,假设您有一个函数,例如:

      Public Function DoSomething(ByVal foo as Boolean) As Integer
         Dim result as integer = 0     
      
         If(foo) then
              result = MakeRequestToWebServiceA()
         Else
              result = MakeRequestToWebServiceB()
         End If     
      
         return result
      End Function
      

      在这种情况下,该函数可以采用 2 个“用例”或控制流。这个函数应该至少有 2 次测试。一种接受 foo 为 true 并向下分支 if(true) 代码,另一种接受 foo 为 false 并向下分支。如果您有更多 if 语句或流程,则代码可以运行,那么它将需要更多测试。这有几个原因 - 对我来说最重要的一个是没有它,测试将太复杂且难以阅读。还有其他原因,比如上面的函数,控制流是基于输入参数的——这意味着你必须调用函数两次来测试所有的代码路径。在您的测试 IMO 中进行测试时,您永远不应调用该函数。

      但我发现自己很难想出函数名称来区分不同的测试,因为许多测试非常相似

      也许你想多了??不要害怕为您的测试函数编写疯狂的、过于冗长的名称。无论该测试做什么,都用英文编写,使用下划线,并提出一套名称标准,以便其他人(包括 6 个月后的您自己)可以轻松地弄清楚它的作用。请记住,您实际上不必自己调用此函数(至少在大多数测试框架中),所以谁在乎它的名称是否为 100 个字符。疯了。在上面的示例中,我的 2 个测试将被命名为:

       DoSomethingTest_TestWhenFooIsTrue_RequestIsMadeToWebServiceA()
       DoSomethingTest_TestWhenFooIsFalse_RequestIsMadeToWebServiceB()
      

      另外 - 这只是一般准则。肯定有在同一个单元测试中有多个断言的情况。这将在您测试 same 控制流时发生,但在您编写断言语句时需要检查多个字段。以这个为例 - 一个函数的测试,它将一个 CSV 文件解析为一个具有 Header、Body 和 Footer 字段的业务对象:

       Public Sub ParseFileTest_TestFileIsParsedCorrectly()
              Dim target as new FileParser()
              Dim actual as SomeBusinessObject = target.ParseFile(TestHelper.GetTestData("ParseFileTest.csv")))
      
              Assert.Equals(actual.Header,"EXPECTED HEADER FROM TEST DATA FILE")
              Assert.Equals(actual.Footer,"EXPECTED FOOTER FROM TEST DATA FILE")
              Assert.Equals(actual.Body,"TEST DATA BODY")
       End Sub
      

      在这里,我们实际上是在测试同一个用例,但我们需要多个断言来检查所有数据并确保我们的代码确实有效。

      -画了

      【讨论】:

        【解决方案5】:

        当一个测试函数只执行一个测试时,更容易确定哪个案例失败了。

        您还隔离了测试,因此一个测试失败不会影响其他测试的执行。

        【讨论】:

          【解决方案6】:

          我认为最好的方法不是考虑每个函数的测试数量,而是考虑code coverage

          • 函数覆盖 - 在
            中包含每个函数(或子例程) 程序被调用了吗?
          • 语句覆盖率 - 程序中的每个节点是否已
            执行了吗?
          • 分支覆盖 - 程序中的每一条边是否都已
            执行了吗?
          • 决策覆盖 - 具有每个控制结构(如 IF 声明)评估为真和 假的?
          • 条件覆盖 - 对每个布尔子表达式进行评估 无论是真还是假?这不 必然意味着决策范围。
          • 条件/决策覆盖 - 决策和条件覆盖
            应该满意。

          编辑: 我重读了我写的东西,发现它有点“可怕”......这让我想起了一个好主意,我 heard 几周前关于代码覆盖率:

          代码覆盖率就像股票市场 投资 !你需要足够的投资 是时候有一个很好的报道了,但不是 太多了,不要浪费你的时间和打击 启动你的项目!

          【讨论】:

          • 这是否意味着您会说可以将大部分测试放在一个函数中?
          • 如果你想坚持 TDD 原则,你需要做一些小步骤,这意味着:更精细的粒度和更多的测试......
          • 我认为最好优先考虑您首先编写的测试,但在进一步覆盖之前关注最重要的核心功能。特别是如果您正在制作一个内部结构可能会在一段时间内快速改变的原型。
          【解决方案7】:

          似乎多重测试功能中的单个故障将导致所有人都失败,对吗?通常测试框架测试只是通过失败,这对于多测试方法意味着您必须手动确定多个测试中的哪一个会失败,因为如果您正在运行大量测试列表,那么第一次执行的失败会导致函数整体失败,并且进一步的测试不会失败。

          测试的粒度很好。如果您要编写 5 个测试,除了每次创建新样板函数的开销较小之外,将它们各自放在各自的函数中似乎并不比将它们全部放在同一个位置更困难。使用正确的 IDE,甚至可能比复制和粘贴更简单。

          【讨论】:

          • “你必须手动找出多个测试中的哪一个会失败” 这通常很容易:你可以通过堆栈中的行号来判断跟踪。
          • “每次创建一个新的样板函数 [...] 可能比复制和粘贴更简单” 错误的二分法。这是关于有或没有样板;无论哪种方式,复制和粘贴都不是一个好的选择。
          • 回复:Jason,回复:堆栈跟踪,我想这取决于您在测试套件中运行的测试量。回复:样板:我的意思是(主要是作为旁白)如果您有一个在某些方面为您提供可重复性的 IDE,那么创建一个新的测试功能可能会更少工作。无论如何,社区 wiki-ed,请随时根据需要更正/添加。 耸耸肩
          【解决方案8】:

          考虑一下这个稻草人(在 C# 中)

          void FooTest()
          {
              C c = new C();
              c.Foo();
              Assert(c.X == 7);
              Assert(c.Y == -7);
          }
          

          虽然“每个测试函数一个断言”是很好的 TDD 建议,但它并不完整。单独应用它会给出:

          void FooTestX()
          {
              C c = new C();
              c.Foo();
              Assert(c.X == 7);
          }
          
          void FooTestY()
          {
              C c = new C();
              c.Foo();
              Assert(c.X == 7);
          }
          

          它缺少两件事:Once-and-only-once(又名 DRY)和“每个场景一个测试类”。后者是鲜为人知的:不是一个包含所有测试方法的测试类/测试夹具,而是具有用于非平凡场景的嵌套类。像这样:

          class CTests
          {
              class FooTests
              {
                  readonly C c;
          
                  void Setup()
                  {
                      c = new C();
                      c.Foo();
                  }
          
                  void XTest()
                  {
                      Assert(c.X == 7);
                  }
          
                  void YTest()
                  {
                      Assert(c.Y == -7);
                  }
              }
          }
          

          现在你没有重复了,每个测试方法都断言关于被测代码的一件事。

          如果不是那么冗长,我会考虑以这种方式编写我的所有测试,这样测试方法总是只有一个断言的微不足道的单行方法。但是,当一个测试不与另一个测试共享“设置”代码时,这似乎太笨拙了。

          (我避免了特定于单元测试技术的细节,例如 NUnit 或 MSTest。你必须调整以适应你使用的任何东西,但原则是合理的。)

          【讨论】:

          • 在这些类型的情况下,当每个测试的设置略有不同时该怎么办? c = 新 C(a); c.Foo();断言(c.X == 7); c = 新 C(b); c.Foo();断言(c.X == -7);
          • @dvide:如果就这么简单,那就把它们分开。如果他们都有c.Foo(); c.Bar(); c.Baz();" then I would consider this a code smell on C. Perhaps a new method Bill()`,那么Foo/Bar/Baz。请记住,单元测试是您的类的合法客户端。让你的类在测试中运行良好也可能使它们在生产中运行良好。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2011-04-17
          • 2021-11-18
          • 2016-10-28
          • 1970-01-01
          • 2011-05-07
          • 1970-01-01
          • 2021-03-16
          相关资源
          最近更新 更多