89

我在 MVCStoreFront 应用程序上观看 Rob Connerys 的网络广播,我注意到他正在对最平凡的事情进行单元测试,例如:

public Decimal DiscountPrice
{
   get
   {
       return this.Price - this.Discount;
   }
}

会有一个像这样的测试:

[TestMethod]
public void Test_DiscountPrice
{
    Product p = new Product();
    p.Price = 100;
    p.Discount = 20;
    Assert.IsEqual(p.DiscountPrice,80);
}

虽然,我完全支持单元测试,但我有时想知道这种形式的测试优先开发是否真的有益,例如,在实际流程中,您的代码上方有 3-4 层(业务请求、需求文档、架构文档) ,其中实际定义的业务规则(折扣价格是价格 - 折扣)可能被错误定义。

如果是这种情况,那么您的单元测试对您来说毫无意义。

此外,您的单元测试是另一个失败点:

[TestMethod]
public void Test_DiscountPrice
{
    Product p = new Product();
    p.Price = 100;
    p.Discount = 20;
    Assert.IsEqual(p.DiscountPrice,90);
}

现在测试有缺陷。显然在一个简单的测试中,这没什么大不了的,但是假设我们正在测试一个复杂的业务规则。我们在这里得到什么?

快进两年应用程序的生命,维护开发人员正在维护它。现在业务改变了它的规则,测试又中断了,一些新手开发人员错误地修复了测试……我们现在又遇到了一个故障点。

我看到的只是更多可能的失败点,没有真正的收益回报,如果折扣价格错误,测试团队仍然会发现问题,单元测试如何节省任何工作?

我在这里想念什么?请教我热爱 TDD,因为到目前为止我很难接受它是有用的。我也想,因为我想保持进步,但这对我来说没有意义。

编辑:有几个人一直提到测试有助于执行规范。根据我的经验,规范也经常出错,但也许我注定要在一个规范是由不应该编写规范的人编写的组织中工作。

4

17 回答 17

63

首先,测试就像安全——你永远不能 100% 确定你已经得到它,但是每一层都增加了更多的信心和一个框架,可以更容易地解决仍然存在的问题。

其次,您可以将测试分解为子例程,然后可以对其本身进行测试。当您有 20 个类似的测试时,制作一个(经过测试的)子例程意味着您的主要测试是对子例程的 20 次简单调用,这更有可能是正确的。

第三,有些人会争辩说TDD解决了这个问题。也就是说,如果你只写了 20 个测试并且它们通过了,你就不能完全确信它们实际上正在测试任何东西。但是,如果您最初编写的每个测试都失败了,然后您修复了它,那么您就会更有信心它确实在测试您的代码。恕我直言,这种来回花费的时间比它的价值要多,但这是一个试图解决您的问题的过程。

于 2008-10-28T18:38:40.330 回答
39

错误的测试不太可能破坏您的生产代码。至少,不比没有考试更糟糕。所以这不是一个“故障点”:为了让产品实际工作,测试不必是正确的。它们可能必须是正确的,然后才能生效,但修复任何损坏的测试的过程不会危及您的实现代码。

您可以将测试,甚至像这样的微不足道的测试视为代码应该做什么的第二意见。一种意见是测试,另一种是实施。如果他们不同意,那么你知道你有问题,你仔细看看。

如果将来有人想从头开始实现相同的接口,它也很有用。他们不必阅读第一个实现即可了解 Discount 的含义,并且测试充当您可能拥有的任何书面接口描述的明确备份。

也就是说,你正在交易时间。如果您可以使用跳过这些琐碎测试所节省的时间来编写其他测试,那么它们可能会更有价值。这实际上取决于您的测试设置和应用程序的性质。如果折扣对应用程序很重要,那么无论如何您都会在功能测试中发现此方法中的任何错误。所有单元测试所做的就是让您在测试该单元时捕获它们,此时错误的位置将立即显而易见,而不是等到应用程序集成在一起并且错误的位置可能不太明显。

顺便说一句,我个人不会在测试用例中使用 100 作为价格(或者更确切地说,如果我这样做了,我会以另一个价格添加另一个测试)。原因是将来有人可能认为折扣应该是百分比。像这样的琐碎测试的目的之一是确保纠正阅读规范时的错误。

[关于编辑:我认为不正确的规范不可避免地会成为故障点。如果您不知道该应用程序应该做什么,那么它很可能不会这样做。但是编写测试来反映规范并没有放大这个问题,它只是无法解决它。所以你没有添加新的故障点,你只是在代码中表示现有的错误,而不是华夫饼文档。]

于 2008-10-28T18:55:13.480 回答
22

我看到的只是更多可能的失败点,没有真正的收益回报,如果折扣价格错误,测试团队仍然会发现问题,单元测试如何节省任何工作?

单元测试并不真正应该节省工作,它应该帮助您发现和防止错误。这是更多的工作,但这是正确的工作。它在最低粒度级别考虑您的代码,并编写测试用例来证明它在预期条件下工作,对于给定的输入集。它隔离变量,因此您可以通过在错误确实出现时查找正确的位置来节省时间。它保存了那套测试,这样当你必须在路上做出改变时,你可以一次又一次地使用它们。

我个人认为大多数方法论并没有从货物崇拜软件工程中删除很多步骤,包括 TDD,但是您不必坚持严格的 TDD 来获得单元测试的好处。保留好的部分,丢弃没有收益的部分。

最后,您的名义问题“您如何对单元测试进行单元测试? ”的答案是您不必这样做。每个单元测试都应该非常简单。调用具有特定输入的方法并将其与预期输出进行比较。如果方法的规范发生更改,那么您可以预期该方法的某些单元测试也需要更改。这就是您在如此低的粒度级别上进行单元测试的原因之一,因此只需更改一些单元测试。如果您发现许多不同方法的测试会因需求中的一个变化而发生变化,那么您可能没有在足够精细的粒度级别上进行测试。

于 2008-10-28T22:36:24.207 回答
11

单元测试的存在是为了让您的单元(方法)达到您的预期。首先编写测试会迫使您在编写代码之前考虑您的期望。三思而后行总是一个好主意。

单元测试应该反映业务规则。当然,代码中可能存在错误,但首先编写测试允许您在编写任何代码之前从业务规则的角度编写它。我认为,之后编写测试更有可能导致您描述的错误,因为您知道代码是如何实现它的,并且很想确保实现是正确的——而不是意图是正确的。

此外,单元测试只是您应该编写的测试的一种形式——也是最低的一种形式。集成测试和验收测试也应该由客户编写,如果可能的话,以确保系统按照预期的方式运行。如果您在此测试期间发现错误,请返回并编写单元测试(失败)以测试功能更改以使其正常工作,然后更改您的代码以使测试通过。现在你有了回归测试来捕获你的错误修复。

[编辑]

我在做 TDD 时发现的另一件事。默认情况下,它几乎强制进行良好的设计。这是因为高度耦合的设计几乎不可能单独进行单元测试。使用 TDD 很快就会发现使用接口、控制反转和依赖注入(所有可以改进设计和减少耦合的模式)对于可测试代码来说非常重要。

于 2008-10-28T18:48:59.873 回答
10

如何测试一个测试突变测试是一种有价值的技术,我个人使用它的效果非常好。阅读链接的文章了解更多详细信息,以及更多学术参考的链接,但通常它通过修改源代码(例如将“x += 1”更改为“x -= 1”)“测试你的测试”,然后重新运行测试,确保至少一项测试失败。任何不会导致测试失败的突变都会被标记以供以后调查。

您会惊讶于如何通过一组看起来很全面的测试来获得 100% 的行和分支覆盖率,但是您可以从根本上更改甚至注释掉源代码中的一行,而不会引起任何测试的抱怨。这通常归结为没有使用正确的输入进行测试以涵盖所有边界情况,有时它更微妙,但在所有情况下,我都对它的结果印象深刻。

于 2009-01-08T15:22:12.980 回答
9

应用测试驱动开发 (TDD) 时,首先要失败的测试。这一步看似不必要,实际上是为了验证单元测试是否正在测试某些东西。事实上,如果测试永远不会失败,它就没有任何价值,更糟糕的是,会导致错误的信心,因为您将依赖无法证明任何事情的积极结果。

当严格遵循这个过程时,所有“单元”都受到单元测试正在建立的安全网的保护,即使是最普通的。

Assert.IsEqual(p.DiscountPrice,90);

测试没有理由朝那个方向发展——或者我在你的推理中遗漏了一些东西。当价格为 100,折扣 20 时,折扣价格为 80。这就像一个不变量。

现在假设您的软件需要支持另一种基于百分比的折扣,可能取决于购买的数量,您的 Product::DiscountPrice() 方法可能会变得更加复杂。引入这些更改可能会破坏我们最初的简单折扣规则。然后你会看到这个测试的值,它会立即检测到回归。


Red - Green - Refactor - 这是为了记住 TDD 过程的本质。

红色是指测试失败时的 JUnit 红色条。

当所有测试通过时,绿色是 JUnit 进度条的颜色。

绿色条件下重构:删除任何重复,提高可读性。


现在要解决您关于“代码上方的 3-4 层”的观点,这在传统(类似瀑布)过程中是正确的,而不是在开发过程敏捷时。敏捷是 TDD 的发源地;TDD 是极限编程的基石。

敏捷是关于直接沟通,而不是扔在墙上的需求文档。

于 2008-10-30T08:33:04.420 回答
8

虽然,我完全赞成单元测试,但我有时想知道这种形式的测试优先开发是否真的有益......

像这样的小型、琐碎的测试可以成为您代码库的“煤矿中的金丝雀”,在为时已晚之前警告危险。琐碎的测试对于保留是有用的,因为它们可以帮助您正确进行交互。

例如,考虑一个简单的测试来探究如何使用您不熟悉的 API。如果该测试与您在“真正”使用 API 的代码中所做的事情有任何相关性,那么保留该测试很有用。当 API 发布新版本并且您需要升级时。现在,您对 API 的行为方式有了自己的假设,以可执行格式记录下来,您可以使用该格式来捕获回归。

...[I] 在实际过程中,您的代码上方有 3-4 层(业务请求、需求文档、架构文档),其中实际定义的业务规则(折扣价格是价格 - 折扣)可能被错误定义。如果是这种情况,那么您的单元测试对您来说毫无意义。

如果您多年来一直在编写代码而没有编写测试,那么您可能不会立即意识到有任何价值。但是,如果您认为最好的工作方式是“尽早发布、经常发布”或“敏捷”,因为您希望能够快速/持续地部署,那么您的测试肯定是有意义的。做到这一点的唯一方法是通过测试使您对代码所做的每一次更改都合法化。无论测试多么小,一旦您拥有一个绿色测试套件,理论上您就可以部署。另见“持续生产”和“永久测试版”。

您也不必“先测试”才能拥有这种心态,但这通常是实现目标的最有效方式。当您进行 TDD 时,您会将自己锁定在 2 到 3 分钟的小红绿重构周期中。在任何时候,您都不能停下来离开,手上会弄得一团糟,这需要一个小时才能调试并重新组装起来。

此外,您的单元测试是另一个失败点......

成功的测试是证明系统出现故障的测试。失败的测试会提醒您测试逻辑或系统逻辑中的错误。您的测试目标是破坏您的代码或证明一种方案有效。

如果您在代码之后编写测试,您将冒着编写“坏”测试的风险,因为为了看到您的测试真正有效,您需要看到它既损坏又有效。当您在代码之后编写测试时,这意味着您必须“跳出陷阱”并在代码中引入错误以查看测试失败。大多数开发人员不仅对此感到不安,而且认为这是浪费时间。

我们在这里得到什么?

以这种方式做事肯定有好处。Michael Feathers 将“遗留代码”定义为“未经测试的代码”。当您采用这种方法时,您对代码库所做的每一次更改都是合法的。它比不使用测试更严格,但是在维护大型代码库时,它会为自己付出代价。

说到 Feathers,您应该查看两个很好的资源:

这两者都解释了如何将这些类型的实践和学科应用到非“绿地”项目中。它们提供了围绕紧密耦合的组件、硬连线依赖以及您不一定可以控制的事物编写测试的技术。这一切都是为了找到“接缝”并围绕这些进行测试。

[I]如果折扣价格错误,测试团队仍然会发现问题,单元测试如何节省任何工作?

像这样的习惯就像是一种投资。退货不是即时的;它们随着时间的推移而积累。不测试的替代方法本质上是承担无法捕捉回归、引入代码而不用担心集成错误或驱动设计决策的债务。美妙之处在于您将引入代码库的每一个更改都合法化。

我在这里想念什么?请教我热爱 TDD,因为到目前为止我很难接受它是有用的。我也想,因为我想保持进步,但这对我来说没有意义。

我认为这是一种职业责任。是一个为之奋斗的理想。但这非常难以理解和乏味。如果您关心它,并且觉得您不应该编写未经测试的代码,您将能够找到学习良好测试习惯的意志力。我现在(和其他人一样)经常做的一件事是给自己一个小时的时间来编写代码,而无需任何测试,然后有纪律地扔掉它。这可能看起来很浪费,但事实并非如此。这不像锻炼会花费公司的物质材料。它帮助我理解了问题以及如何以更高质量和可测试的方式编写代码。

我的建议最终是,如果你真的不想擅长它,那就不要去做。没有维护的糟糕测试,表现不佳等可能比没有任何测试更糟糕。自己学习很难,你可能不会喜欢它,但如果你没有学习的欲望,或者看不到它的足够价值,那么它几乎是不可能学习的。保证时间投入。

有几个人一直提到测试有助于执行规范。根据我的经验,规范也有错误,而且往往不是......

开发人员的键盘是橡胶与道路相遇的地方。如果规范是错误的并且你没有在上面举起旗帜,那么你很可能会因此受到指责。或者至少你的代码会。测试中涉及的纪律和严格性很难遵守。这一点都不容易。这需要练习、大量学习和大量错误。但最终它确实得到了回报。在一个快节奏、快速变化的项目中,这是你晚上睡觉的唯一方法,不管它是否会让你慢下来。

这里要考虑的另一件事是,与测试基本相同的技术在过去已被证明是有效的:“洁净室”和“按合同设计”都倾向于产生相同类型的“元”代码结构测试做,并在不同的点强制执行。这些技术都不是灵丹妙药,严格要求最终会在您可以交付的功能范围内花费您的产品上市时间。但这不是它的目的。这是关于能够维持你所交付的东西。这对于大多数项目来说非常重要。

于 2009-06-03T05:15:23.090 回答
7

单元测试的工作方式与复式簿记非常相似。您以两种截然不同的方式陈述同一事物(业务规则)(作为生产代码中的编程规则,以及作为测试中的简单、代表性示例)。你不太可能在两者中犯同样的错误,所以如果他们都同意对方,你就不太可能弄错了。

测试如何值得付出努力?根据我的经验,至少有四种方式,至少在进行测试驱动开发时:

  • 它可以帮助您提出一个良好解耦的设计。您只能对解耦良好的代码进行单元测试;
  • 它可以帮助您确定何时完成。必须在测试中指定所需的行为有助于不构建您实际上不需要的功能,并确定功能何时完成;
  • 它为您提供了重构的安全网,使代码更易于更改;和
  • 它为您节省了大量调试时间,但代价高昂(我听说过估计,传统上,开发人员花费高达 80% 的时间进行调试)。
于 2008-10-31T23:06:00.287 回答
5

大多数单元测试,测试假设。在这种情况下,折扣价格应该是价格减去折扣。如果您的假设是错误的,我敢打赌您的代码也是错误的。如果你犯了一个愚蠢的错误,测试就会失败,你会纠正它。

如果规则发生变化,测试将失败,这是一件好事。所以在这种情况下你也必须改变测试。

作为一般规则,如果测试立即失败(并且您不使用测试优先设计),则测试或代码错误(或者如果您遇到糟糕的一天,则两者都错误)。您使用常识(以及可能的规范)来纠正有问题的代码并重新运行测试。

正如 Jason 所说,测试就是安全。是的,有时他们会因为测试错误而引入额外的工作。但在大多数情况下,它们可以节省大量时间。(而且你有绝佳的机会惩罚打破测试的人(我们说的是橡皮鸡))。

于 2008-10-28T18:46:32.843 回答
4

尽可能测试一切。即使是微不足道的错误,例如忘记将米转换为英尺,也会产生非常昂贵的副作用。写一个测试,写代码让它检查,让它通过,继续前进。谁知道在未来的某个时候,有人可能会更改折扣代码。测试可以发现问题。

于 2008-10-28T18:42:59.620 回答
4

我认为单元测试和生产代码具有共生关系。简单地说:一个测试另一个。两者都测试开发人员。

于 2010-05-17T11:17:51.193 回答
3

请记住,随着缺陷在整个开发周期中持续存在,修复缺陷的成本(以指数方式)增加。是的,测试团队可能会发现缺陷,但与单元测试失败相比,从该点隔离和修复缺陷(通常)需要更多的工作,并且如果您在修复它时引入其他缺陷会更容易没有要运行的单元测试。

这通常比一个琐碎的例子更容易看到......并且对于琐碎的例子,如果你以某种方式弄乱了单元测试,审查它的人会发现测试中的错误或代码中的错误,或者两个都。(他们正在接受审查,对吗?)正如tvanfosson 所指出的,单元测试只是 SQA 计划的一部分。

从某种意义上说,单元测试是一种保险。他们不能保证你会发现每一个缺陷,有时你可能会在他们身上花费很多资源,但是当他们确实发现了你可以修复的缺陷时,你会花更少的钱如果您根本没有测试并且必须修复下游的所有缺陷。

于 2008-10-28T19:24:17.837 回答
3

我明白你的意思,但它显然被夸大了。

您的论点基本上是:测试引入了失败。因此测试是不好的/浪费时间。

虽然这在某些情况下可能是正确的,但几乎不是大多数情况。

TDD 假设:更多测试 = 更少失败。

测试更有可能捕捉失败点而不是引入它们。

于 2009-07-02T22:04:36.487 回答
1

更多的自动化可以在这里提供帮助!是的,编写单元测试可能需要大量工作,因此请使用一些工具来帮助您。如果您使用的是 .Net,请查看 Microsoft 的 Pex 之类的东西,它会通过检查您的代码自动为您创建单元测试套件。它将提供覆盖率良好的测试,试图覆盖代码中的所有路径。

当然,仅仅通过查看您的代码,它无法知道您实际尝试做什么,因此它不知道它是否正确。但是,它会为您生成有趣的测试用例,然后您可以检查它们,看看它的行为是否符合您的预期。

如果您再进一步编写参数化单元测试(实际上,您可以将这些视为合同),它将从中生成特定的测试用例,这一次它可以知道是否有问题,因为您在测试中的断言将失败。

于 2009-03-16T08:08:07.327 回答
1

我想了一些关于回答这个问题的好方法,并想将其与科学方法相提并论。IMO,您可以改写这个问题,“您如何进行实验?”

实验验证了关于物理宇宙的经验假设(假设)。单元测试将测试关于它们调用的代码的状态或行为的假设。我们可以谈论一个实验的有效性,但那是因为我们知道,通过许多其他实验,有些东西不适合。它没有收敛有效性经验证据。我们不会设计一个新的实验来测试或验证一个实验的有效性,但我们可能会设计一个全新的实验

所以像实验一样,我们不会根据单元测试本身是否通过单元测试来描述单元测试的有效性。与其他单元测试一起,它描述了我们对其正在测试的系统所做的假设。此外,就像实验一样,我们尝试从我们正在测试的内容中消除尽可能多的复杂性。 “尽可能简单,但不要更简单。”

与实验不同,我们有一个诀窍来验证我们的测试是否有效,而不仅仅是收敛有效性。我们可以巧妙地引入一个我们知道应该被测试捕获的 bug,然后看看测试是否确实失败了。(如果我们能在现实世界中做到这一点,我们对收敛有效性的依赖就会少得多!)一个更有效的方法是在实施之前观察你的测试失败(红色、绿色、重构中的红色步骤)。

于 2009-07-02T19:11:03.827 回答
1

编写测试时需要使用正确的范例。

  1. 首先编写测试。
  2. 确保他们无法开始。
  3. 让他们通过。
  4. 在检查代码之前进行代码审查(确保对测试进行了审查。)

你不能总是确定,但他们改进了整体测试。

于 2010-05-17T11:08:46.167 回答
0

即使您不测试您的代码,您的用户也肯定会在生产环境中对其进行测试。用户在尝试使您的软件崩溃并发现甚至是非关键错误方面非常有创意。

修复生产中的错误比解决开发阶段的问题要昂贵得多。作为副作用,您将因客户外流而失去收入。对于 1 个愤怒的客户,您可以指望 11 个失去或未获得的客户。

于 2009-08-04T06:47:03.760 回答