代码之家  ›  专栏  ›  技术社区  ›  iny

为什么单元测试只测试一件事?

  •  68
  • iny  · 技术社区  · 16 年前

    What Makes a Good Unit Test? 说测试应该只测试一件事。这有什么好处?

    写一个更大的测试来测试更大的代码块不是更好吗?无论如何,调查测试失败都很困难,我看不出小型测试有什么帮助。

    编辑:单词单位没那么重要。假设我认为这个单元有点大。这不是这里的问题。真正的问题是,为什么要对所有方法进行测试或更多测试,因为涵盖许多方法的测试更少更简单。

    示例:一个列表类。为什么我要对添加和删除进行单独的测试?一个先添加后删除的测试听起来更简单。

    17 回复  |  直到 16 年前
        1
  •  82
  •   MrBoJangles hugomg    5 年前

    只测试一件事可以隔离这件事,并证明它是否有效。这就是单元测试的想法。测试多个东西的测试没有错,但这通常被称为集成测试。根据上下文,两者都有优点。

    举个例子,如果你的床头灯没有打开,而你更换了灯泡并切换了延长线,你不知道是哪种改变解决了这个问题。本应进行单元测试,并分离您的关注点以隔离问题。

    更新:我读了这篇文章和链接的文章,我不得不说,我很震惊: https://techbeacon.com/app-dev-testing/no-1-unit-testing-best-practice-stop-doing-it

    这里有实质,它让精神之汁流动。但我认为这与最初的观点相吻合,即我们应该进行背景要求的测试。我想我只是想补充一下,我们需要更确切地了解在系统上进行不同测试的好处,而不是交叉手指的方法。测量/量化和所有这些好东西。

        2
  •  74
  •   user247702    9 年前

    我在这里要冒一个险,说“唯一测试一件事”的建议并不像有时所说的那么有帮助。

    有时测试需要一定的设置。有时他们甚至会服用一定量的 时间 (在现实世界中)建立。通常,你可以一次性测试两个动作。

    优点:所有设置只发生一次。你在第一次行动后的测试将证明,在第二次行动之前,世界就是你所期望的样子。更少的代码,更快的测试运行。

    缺点:如果 任何一个 如果操作失败,您将得到相同的结果:相同的测试将失败。与在两个测试中只进行一个操作相比,您对问题所在的信息更少。

    事实上,我发现这里的“骗局”并不是什么问题。堆栈跟踪通常会很快缩小范围,我无论如何都会确保修复代码。

    这里有一个稍微不同的“骗局”,它打破了“编写新测试,使其通过,重构”的循环。我认为这是 理想的 循环,但并不总是反映现实。有时,在当前测试中添加一个额外的操作和检查(或者可能只是对现有操作进行另一个检查)比创建一个新的操作更实用。

        3
  •  13
  •   swilliams    16 年前

    通常不建议使用检查多个事物的测试,因为它们耦合更紧密,更脆弱。如果你更改了代码中的某些内容,那么更改测试将需要更长的时间,因为有更多的事情需要考虑。

    [编辑:] 好的,假设这是一个样本测试方法:

    [TestMethod]
    public void TestSomething() {
      // Test condition A
      // Test condition B
      // Test condition C
      // Test condition D
    }
    

    如果你对条件A的测试失败了,那么B、C和D似乎也会失败,不会给你任何帮助。如果你的代码更改也会导致C失败怎么办?如果你把它们分成4个单独的测试,你就会知道这一点。

        4
  •  11
  •   Peter Mortensen icecrime    15 年前

    哈哈。..单元测试。

    将任何“指令”推得太远,它很快就会变得不可用。

    单一单元测试测试单一事物和单一方法执行单一任务一样好。但依我之见,这并不意味着一个测试只能包含一个断言语句。

    @Test
    public void checkNullInputFirstArgument(){...}
    @Test
    public void checkNullInputSecondArgument(){...}
    @Test
    public void checkOverInputFirstArgument(){...}
    ...
    

    比…好

    @Test
    public void testLimitConditions(){...}
    

    在我看来,这是品味问题,而不是良好的做法。我个人更喜欢后者。

    但是

    @Test
    public void doesWork(){...}
    

    实际上,这是“指令”希望你不惜一切代价避免的,也是最快耗尽我理智的东西。

    最后的结论是,将语义相关且易于测试的东西组合在一起,这样失败的测试消息本身就足够有意义,可以直接进入代码。

    这里关于失败测试报告的经验法则是:如果你必须先阅读测试的代码,那么你的测试结构不够好,需要更多地拆分成更小的测试。

    我的2美分。

        5
  •  8
  •   Bealer    16 年前

    想想造一辆车。如果你要应用你的理论,只测试大事,那么为什么不做一个测试,让汽车穿过沙漠呢。它坏了。好的,那么告诉我是什么导致了这个问题。你不能。这是一个情景测试。

    功能测试可能是打开发动机。它失败了。但这可能是由于多种原因。你仍然不能确切地告诉我是什么导致了这个问题。不过,我们越来越近了。

    单元测试更具体,它将首先确定代码的损坏位置,但它也将(如果进行适当的TDD)帮助将代码构建成清晰的模块化块。

    有人提到使用堆栈跟踪。算了,那是第二种选择。浏览堆栈跟踪或使用调试是一件痛苦的事情,而且可能很耗时。尤其是在较大的系统和复杂的bug上。

    单元测试的良好特性:

    • 快速(毫秒)
    • 独立。它不受其他测试的影响或依赖于其他测试
    • 清楚。它不应该臃肿,也不应该包含大量的设置。
        6
  •  6
  •   Peter Mortensen icecrime    15 年前

    使用测试驱动开发,您将首先编写测试,然后编写代码以通过测试。如果你的测试是集中的,那么编写通过测试的代码会更容易。

    例如,我可能有一个接受参数的方法。我可能首先想到的一件事是,如果参数为null,会发生什么?它应该抛出ArgumentNull异常(我认为)。因此,我编写了一个测试,检查在传递null参数时是否抛出了异常。运行测试。好吧,它抛出NotImplementedException。我通过更改代码以抛出ArgumentNull异常来解决这个问题。运行我的测试,它通过了。然后我想,如果它太小或太大,会发生什么?啊,这是两个测试。我先写一个太小的案例。

    关键是我不会一下子想到方法的行为。我通过思考它应该做什么来逐步(和逻辑地)构建它,然后在构建过程中实现代码和重构,使其看起来很漂亮(优雅)。这就是为什么测试应该小而集中,因为当你思考行为时,你应该以小而可理解的增量来发展。

        7
  •  4
  •   Peter Mortensen icecrime    15 年前

    只验证一件事的测试使故障排除更容易。这并不是说你不应该也有测试多个东西的测试,或者共享相同设置/拆卸的多个测试。

    这里应该有一个说明性的例子。假设你有一个带有查询的堆栈类:

    • getSize
    • 栈空
    • getTop

    以及改变堆栈的方法

    • push(一个对象)
    • pop()

    现在,考虑以下测试用例(我在这个例子中使用了类似Python的伪代码。)

    class TestCase():
        def setup():
            self.stack = new Stack()
        def test():
            stack.push(1)
            stack.push(2)
            stack.pop()
            assert stack.top() == 1, "top() isn't showing correct object"
            assert stack.getSize() == 1, "getSize() call failed"
    

    从这个测试用例中,您可以确定是否有问题,但无法确定它是否与 push() pop() 或者返回值的查询: top() getSize() .

    如果我们为每种方法及其行为添加单独的测试用例,事情就会变得更容易诊断。此外,通过为每个测试用例进行新的设置,我们可以保证问题完全在失败的测试方法调用的方法中。

    def test_size():
        assert stack.getSize() == 0
        assert stack.isEmpty()
    
    def test_push():
        self.stack.push(1)
        assert stack.top() == 1, "top returns wrong object after push"
        assert stack.getSize() == 1, "getSize wrong after push"
    
    def test_pop():
        stack.push(1)
        stack.pop()
        assert stack.getSize() == 0, "getSize wrong after push"
    

    就测试驱动开发而言。我个人编写更大的“功能测试”,首先测试多个方法,然后在开始实现单个部分时创建单元测试。

    另一种看待它的方式是单元测试验证每个单独方法的契约,而更大的测试验证对象和整个系统必须遵循的契约。

    我仍然在使用三个方法调用 test_push 然而,两者都 top() getSize() 是由单独的测试方法测试的查询。

    通过向单个测试添加更多断言,您可以获得类似的功能,但随后断言失败将被隐藏。

        8
  •  4
  •   Owen Davies    15 年前

    如果你正在测试多个东西,那么它就被称为集成测试。..不是单元测试。您仍然可以在与单元测试相同的测试框架中运行这些集成测试。

    集成测试通常较慢,单元测试较快,因为所有依赖关系都是模拟/伪造的,所以没有数据库/web服务/慢速服务调用。

    我们在提交到源代码控制时运行单元测试,我们的集成测试只在夜间构建中运行。

        9
  •  3
  •   Rob Prouse    16 年前

    如果你测试了不止一件事,而你测试的第一件事失败了,你将不知道你测试的后续事情是通过还是失败。当你知道所有会失败的事情时,修复起来会更容易。

        10
  •  3
  •   Garnet Ulrich    16 年前

    较小的单元测试可以更清楚地说明失败时的问题所在。

        11
  •  3
  •   Peter Mortensen icecrime    15 年前

    GLib,但希望仍然有用,答案是单位=1。如果你测试了不止一件事,那么你就不是单元测试。

        12
  •  2
  •   tdahlke    16 年前

    关于你的例子:如果你在同一个单元测试中测试添加和删除,你如何验证该项目是否被添加到你的列表中?这就是为什么您需要添加并验证它是在一个测试中添加的。

    或者以灯为例:如果你想测试你的灯,你所做的就是打开开关然后关闭,你怎么知道灯曾经打开过?你必须采取中间的步骤,看看灯并确认它是否亮着。然后你可以关闭它并确认它已关闭。

        13
  •  2
  •   Dave Cameron    16 年前

    我支持单元测试应该只测试一件事的想法。我也有点偏离了它。今天我进行了一次测试,昂贵的设置似乎迫使我在每次测试中做出多个断言。

    namespace Tests.Integration
    {
      [TestFixture]
      public class FeeMessageTest
      {
        [Test]
        public void ShouldHaveCorrectValues
        {
          var fees = CallSlowRunningFeeService();
          Assert.AreEqual(6.50m, fees.ConvenienceFee);
          Assert.AreEqual(2.95m, fees.CreditCardFee);
          Assert.AreEqual(59.95m, fees.ChangeFee);
        }
      }
    }
    

    与此同时,我真的很想看到我所有失败的断言,而不仅仅是第一个。我原以为他们都会失败,我需要知道我真正得到了多少回报。但是,将每个测试分开的标准[SetUp]将导致对慢速服务的3次调用。突然间,我想起了一篇文章,其中提到使用“非传统”测试结构是隐藏单元测试一半好处的地方。(我想这是Jeremy Miller的帖子,但现在找不到了。)突然间,[TestFixtureSetUp]浮现在我的脑海里,我意识到我可以进行一次服务调用,但仍然有单独的、富有表现力的测试方法。

    namespace Tests.Integration
    {
      [TestFixture]
      public class FeeMessageTest
      {
        Fees fees;
        [TestFixtureSetUp]
        public void FetchFeesMessageFromService()
        {
          fees = CallSlowRunningFeeService();
        }
    
        [Test]
        public void ShouldHaveCorrectConvenienceFee()
        {
          Assert.AreEqual(6.50m, fees.ConvenienceFee);
        }
    
        [Test]
        public void ShouldHaveCorrectCreditCardFee()
        {
          Assert.AreEqual(2.95m, fees.CreditCardFee);
        }
    
        [Test]
        public void ShouldHaveCorrectChangeFee()
        {
          Assert.AreEqual(59.95m, fees.ChangeFee);
        }
      }
    }
    

    这个测试中有更多的代码,但它通过一次向我显示所有不符合预期的值,提供了更多的价值。

    一位同事还指出,这有点像Scott Bellware的specunit.net: http://code.google.com/p/specunit-net/

        14
  •  1
  •   Peter Mortensen icecrime    15 年前

    非常精细的单元测试的另一个实际缺点是它破坏了 DRY principle 我参与过一些项目,其中的规则是类的每个公共方法都必须有一个单元测试([TestMethod])。显然,每次创建公共方法时,这都会增加一些开销,但真正的问题是,它给重构增加了一些“摩擦”。

    它类似于方法级文档,拥有它很好,但这是另一件必须维护的事情,它使更改方法签名或名称变得更加繁琐,并减缓了“floss重构”(如中所述 "Refactoring Tools: Fitness for Purpose" 爱默生·墨菲-希尔和安德鲁·P·布莱克。PDF,1.3mb)。

    与设计中的大多数事情一样,“测试应该只测试一件事”这句话并没有捕捉到一种权衡。

        15
  •  1
  •   Esko Luontola    15 年前

    当测试失败时,有三种选择:

    1. 执行已中断,应予以修复。
    2. 测试已损坏,应进行修复。
    3. 不再需要该测试,应将其删除。

    细粒度试验 描述性名称 帮助读者了解 为什么? 编写了测试,这反过来又使得更容易知道选择上述选项中的哪一个。测试名称应描述测试指定的行为,并且仅限于 每次测试一个行为 -这样,读者只需阅读测试的名称,就能知道系统的功能。看见 this article 了解更多信息。

    另一方面,如果一个测试做了很多不同的事情,并且它有一个非描述性的名称(例如以实现中的方法命名的测试),那么很难找出测试背后的动机,也很难知道何时以及如何更改测试。

    以下是它的样子(与 GoSpec ),当每个测试只测试一件事时:

    func StackSpec(c gospec.Context) {
      stack := NewStack()
    
      c.Specify("An empty stack", func() {
    
        c.Specify("is empty", func() {
          c.Then(stack).Should.Be(stack.Empty())
        })
        c.Specify("After a push, the stack is no longer empty", func() {
          stack.Push("foo")
          c.Then(stack).ShouldNot.Be(stack.Empty())
        })
      })
    
      c.Specify("When objects have been pushed onto a stack", func() {
        stack.Push("one")
        stack.Push("two")
    
        c.Specify("the object pushed last is popped first", func() {
          x := stack.Pop()
          c.Then(x).Should.Equal("two")
        })
        c.Specify("the object pushed first is popped last", func() {
          stack.Pop()
          x := stack.Pop()
          c.Then(x).Should.Equal("one")
        })
        c.Specify("After popping all objects, the stack is empty", func() {
          stack.Pop()
          stack.Pop()
          c.Then(stack).Should.Be(stack.Empty())
        })
      })
    }
    
        16
  •  1
  •   Nicolas Barbulesco    12 年前

    真正的问题是,为什么要对所有方法进行测试或更多测试,因为涵盖许多方法的测试更少更简单。

    好吧,这样当一些测试失败时,你就知道哪种方法失败了。

    当你必须修理一辆无法正常工作的汽车时,知道发动机的哪个部分出了故障会更容易。

    示例:一个列表类。为什么我要对添加和删除进行单独的测试?一个先添加后删除的测试听起来更简单。

    让我们假设加法方法已损坏,无法添加,而删除方法已损坏且无法删除。您的测试将检查添加和删除后的列表是否与最初的大小相同。你的测试会成功的。虽然你的两种方法都会被打破。

        17
  •  0
  •   Dimos    8 年前

    免责声明:这是一个深受《xUnit Test Patterns》一书影响的答案。

    每次测试只测试一件事是提供以下好处的最基本原则之一:

    • 缺陷定位 :如果测试失败,您会立即知道失败的原因(如果您对所使用的断言做得很好,最好不进行进一步的故障排除)。
    • 测试作为规范 :测试不仅是一个安全网,而且可以很容易地用作规范/文件。例如,开发人员应该能够阅读单个组件的单元测试并理解其API/合同,而无需阅读实现(利用封装的好处)。
    • TDD的不可行性 TDD基于具有小规模的功能块并完成渐进迭代(编写失败的测试、编写代码、验证测试成功)。如果测试必须验证多个东西,这个过程会受到严重干扰。
    • 无副作用 :与第一个测试有点关系,但当一个测试验证了多个东西时,它更有可能也与其他测试联系在一起。因此,这些测试可能需要有一个共享的测试夹具,这意味着一个测试夹具会受到另一个的影响。因此,最终你可能会有一个测试失败,但实际上是另一个测试导致了失败,例如通过更改夹具数据。

    我只能看到一个原因,为什么你可能会从验证多个东西的测试中受益,但这实际上应该被视为一种代码气味:

    • 性能优化 :在某些情况下,您的测试不仅在内存中运行,还依赖于持久存储(例如数据库)。在其中一些情况下,进行测试验证多个东西可能有助于减少磁盘访问次数,从而减少执行时间。然而,理想情况下,单元测试应该只能在内存中执行,所以如果你偶然发现这种情况,你应该重新考虑你是否走错了路。在单元测试中,所有持久依赖关系都应该用模拟对象替换。端到端功能应该由一套不同的集成测试来覆盖。这样,您就不需要再关心执行时间了,因为集成测试通常是由构建管道而不是开发人员执行的,因此稍高的执行时间几乎不会影响软件开发生命周期的效率。