编写最少的代码以通过单元测试-不作弊!


36

在进行TDD并编写单元测试时,如何在编写要测试的“实现”代码的第一次迭代时抵制“作弊”的冲动?

例如:
让我们需要计算一个数字的阶乘。我从一个单元测试开始(使用MSTest),例如:

[TestClass]
public class CalculateFactorialTests
{
    [TestMethod]
    public void CalculateFactorial_5_input_returns_120()
    {
        // Arrange
        var myMath = new MyMath();
        // Act
        long output = myMath.CalculateFactorial(5);
        // Assert
        Assert.AreEqual(120, output);
    }
}

我运行此代码,但由于该CalculateFactorial方法甚至不存在而失败。因此,我现在编写代码的第一次迭代以实现被测方法,并编写通过测试所需的最少 代码

问题是,我一直很想写以下内容:

public class MyMath
{
    public long CalculateFactorial(long input)
    {
        return 120;
    }
}

从技术上讲,这是正确的,因为它确实是“作弊”,因为它实际上甚至没有尝试执行计算阶乘的功能,尽管它确实进行特定测试通过(变为绿色)所需的最少代码。当然,现在重构部分成为“编写正确的功能”的练习,而不是真正的实现重构。显然,添加具有不同参数的其他测试将失败并强制进行重构,但是您必须从该测试开始。

因此,我的问题是,如何在“编写最少的代码以通过测试”同时又保持其功能与您实际试图实现的精神之间取得平衡?


4
这是人类的事情:您必须抵制作弊的冲动。没什么了。您可以添加比要测试的代码更多的测试和编写更多的测试代码,但是如果您没有那么多的奢侈,就只需要抵抗。在编码中有很多地方,我们必须抵制黑客或作弊的冲动,因为我们知道,尽管它可能在今天起作用,但以后不会起作用。
Dan Rosenstark 2010年

7
当然,在TDD中,反之则是作弊-即返回120是正确的方法。我发现很难做到这一点,不能争先恐后开始编写阶乘计算。
Paul Butcher 2010年

2
我认为这是一个骗局,仅是因为它可以通过测试,但并不能增加任何真正的功能,也不能使您更接近最终解决问题的方法。
GrumpyMonkey,2010年

3
如果事实证明客户端代码仅通过5,则返回120不仅是骗子,而且实际上是合法的解决方案。
克拉莫伊(Kramii)恢复莫妮卡(Monica)2010年

我同意@PaulButcher-实际上,文本和文章中的许多单元测试示例都将采用这种方法。
HorusKol 2011年

Answers:


45

完全合法。红色,绿色,重构。

第一次测试通过。

使用新输入添加第二个测试。

现在,快速变为绿色,您可以添加if-else,它工作正常。它通过了,但是您还没有完成。

红色,绿色,重构的第三部分是最重要的。 重构以消除重复。您现在将在您的代码中有重复项。两个返回整数的语句。 删除重复项的唯一方法是正确编码该函数。

我并不是说第一次就写不正确。我只是说如果不是这样就不会作弊。


12
这就提出了一个问题,为什么不首先正确地编写函数?
罗伯特·哈维

8
@Robert,阶乘数很简单。TDD的真正优势在于,您可以编写非平凡的库,并且编写测试首先会迫使您在实现之前设计API,以我的经验,这会带来更好的代码。

1
@Robert,是您关心解决问题而不是通过测试的人。我告诉你,对于非平凡的问题,将硬设计推迟到有适当的测试之前会更好。

1
@ThorbjørnRavn Andersen,不,我不是说您只能获得一次回报。有多个合理的理由(即保护声明)。问题是,两个返回语句都是“相等的”。他们做了同样的“事情”。他们只是碰巧有不同的价值观。TDD与刚性无关,并遵循特定大小的测试/代码比率。这是关于在代码库中创建舒适度级别。如果您可以编写失败的测试,那么可以在该功能的未来测试中使用的功能就很棒。做到这一点,然后编写边缘案例测试以确保您的功能仍然有效。
CaffGeek 2010年

3
不立即编写完整(尽管很简单)的实现的观点是,您根本无法保证测试甚至会失败。在测试通过之前看到测试失败的关键是,您可以实际证明对代码所做的更改能够满足您对测试所做的断言。这就是TDD之所以能如此出色地构建回归测试套件并从某种意义上说采用“事后测试”方法彻底抹平了地板的唯一原因。
2016年

25

显然,需要了解最终目标以及实现满足该目标的算法。

TDD并不是设计的灵丹妙药。您仍然必须知道如何使用代码解决问题,并且仍然必须知道如何以高于几行代码的级别进行测试。

我喜欢TDD的想法,因为它鼓励良好的设计;它使您考虑如何编写代码以使其可测试,并且通常,哲学将推动代码朝着更好的总体设计方向发展。但是您仍然必须知道如何设计解决方案。

我不赞成简化派的TDD哲学,这些哲学认为您可以通过编写最少的代码以通过测试来扩展应用程序。如果不考虑体系结构,这将行不通,您的示例证明了这一点。

鲍勃·马丁叔叔说:

如果您没有进行测试驱动开发,则很难称自己为专业人员。吉姆·科普林为此打电话给我。他不喜欢我这么说。实际上,他现在的立场是“测试驱动开发”正在破坏体系结构,因为人们正在编写放弃其他任何想法的测试,并在疯狂的匆忙中将其体系结构拆散以使测试通过,而他有一个有趣的观点,这是一种滥用仪式并失去纪律背后意图的有趣方式。

如果您不考虑架构,如果您正在做的事情是忽略架构并将测试放在一起并通过测试,那么您正在破坏将使建筑物保持正常运转的事物,因为这是集中在系统的结构以及有助于系统维持其结构完整性的可靠设计决策。

您不能简单地将一大堆测试放在一起,让它们十年又十年地通过并假设您的系统能够生存。我们不想让自己陷入地狱。因此,优秀的测试驱动开发人员始终意识到制定架构决策,始终考虑全局。


不是真正的问题答案,而是1+
Nobody

2
@rmx:嗯,问题是:如何在“编写通过测试的最小代码”同时又保持其功能与您实际试图实现的精神之间取得平衡? 我们在读同样的问题吗?
罗伯特·哈维

理想的解决方案是一种算法,并且与体系结构无关。进行TDD不会使您发明算法。在某些时候,您需要根据算法/解决方案进行操作。
Joppe

我同意@rmx。本质上,这并不能真正回答我的特定问题,但是确实引起了人们的思考,即TDD通常如何适合整个软件开发过程的全局。因此,因此+1。
CraigTP

我认为您可以用“算法”和其他术语代替“架构”,而论点仍然成立。都是因为看不见树木的树木。除非您要为每个单个整数输入编写单独的测试,否则TDD将无法区分适当的阶乘实现和某些不适用于所有测试案例的硬编码,而不适用于其他情况。TDD的问题在于将“所有测试通过”和“代码良好”的难易程度混为一谈。在某些时候,需要运用大量常识。
朱莉娅·海沃德

16

一个很好的问题...我不得不同意除@Robert以外的几乎所有人。

写作

return 120;

使阶乘函数通过一个测试是浪费时间。这不是“作弊”,也不是按照字面上的“红绿重构”。这是错误的

原因如下:

  • 计算阶乘是功能,而不是“返回常数”。“ return 120” 不是计算值。
  • “重构”的论点被误导了;如果你有两个测试案例为5和6,该代码仍然是错误的,因为你不是计算阶乘

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • 如果我们从字面上遵循'refactor'参数,那么当我们有5个测试用例时,我们将调用YAGNI并使用查找表实现该功能:

    if (factorialDictionary.Contains(input)) {
        return factorialDictionary[input]; 
    }
    throw new Exception("Input failure");
    

这些都不是实际计算什么,你是。那不是任务!


1
@rmx:不,没错过!查找表可以满足“重构以消除重复”的要求。BTW单元测试编码要求的原理并不特定于BDD,它是Agile / XP的一般原理。如果要求是“回答问题'什么是5的阶乘'”,则“返回120;”。将是合法的;-)
史蒂文·A·洛

2
@Chad所有这些都是不必要的工作-第一次编写函数即可;-)
Steven A. Lowe 2010年

2
@Steven A.Lowe,按照这种逻辑,为什么要编写任何测试?“只需在第一时间编写应用程序!” TDD的重点是小的,安全的,增量的更改。
CaffGeek

1
@乍得:稻草人。
史蒂文·劳

2
不立即编写完整(尽管很简单)的实现的观点是,您根本无法保证测试甚至会失败。在测试通过之前看到测试失败的关键是,您可以实际证明对代码所做的更改能够满足您对测试所做的断言。这就是TDD之所以能如此出色地构建回归测试套件并从某种意义上说采用“事后测试”方法彻底抹平了地板的唯一原因。您绝不会意外编写无法失败的测试。另外,看看叔叔鲍勃素数卡塔。
2016年

10

如果只编写了一个单元测试,则单行实现(return 120;)是合法的。编写一个计算值120的循环- 可能会作弊!

这样简单的初始测试是捕获边缘情况并防止一次性错误的好方法。实际上,这不是我要输入的五个输入值。

这里有用的经验法则是:零,一个,很多,很多。零和一是阶乘的重要边缘情况。它们可以采用单线实施。然后,“许多”测试用例(例如5!)将迫使您编写一个循环。“很多”(1000 !?)测试用例可能会迫使您实施替代算法来处理非常大的数字。


2
“ -1”的情况很有趣。因为定义不明确,所以编写测试的人和编写代码的人都必须首先同意应该发生什么。
gnasher729 '16

2
+1实际指出这factorial(5)是一个不好的第一次测试。我们从最简单的情况开始,在每次迭代中,我们使测试更加具体,敦促代码变得更加通用。这是Bob大叔调用改造优先的前提下(blog.8thlight.com/uncle-bob/2013/05/27/...
萨拉

5

只要您只有一个测试,那么通过该测试所需的最少代码就是真正的return 120;,只要您没有其他测试,就可以轻松地保留它。

这使您可以推迟进一步的设计,直到您实际编写使用此方法的其他返回值的测试为止。

请记住,测试是您规范的可运行版本,并且如果规范说明的仅是f(6)= 120,则该声明非常合适。


认真吗 按照这种逻辑,每次有人提出新输入时,您都必须重写代码。
罗伯特·哈维

6
@Robert,在某些时候添加新的案例将不再导致最简单的代码,此时您将编写一个新的实现。由于已经进行了测试,因此您确切地知道新的实现何时与旧的实现相同。

1
@ThorbjørnRavn Andersen正是Red-Green-Refactor最重要的部分。
CaffGeek

+1:从我所知这也是一般的想法,但是关于履行隐含合同(即方法名称阶乘),需要说些什么。如果您只指定(即测试)f(6)= 120,则只需要“返回120”。一旦开始添加测试以确保f(x)== x * x-1 ... * xx-1:upperBound> = x> = 0,那么您将得到一个满足阶乘方程的函数。
史蒂文·埃弗斯

1
@SnOrfus,“隐含合同”的位置在测试用例中。如果您签约购买阶乘,则测试已知阶乘是否存在,是否已知非阶乘。他们很多。很快就可以将十个第一个阶乘的列表转换为for循环测试每个数字,直到第十个阶乘。

4

如果您能够以这种方式“作弊”,则表明您的单元测试存在缺陷。

与其测试单个值的阶乘方法,不如测试一个值的范围。数据驱动的测试可以为您提供帮助。

将单元测试视为需求的体现-它们必须共同定义所测试方法的行为。(这被称为行为驱动的开发 -它的未来;-)

因此,问问自己-如果有人将实现更改为不正确的内容,您的测试是否仍会通过,或者他们会说“稍等片刻!”?

请记住,如果您唯一的测试是问题中的测试,那么从技术上讲,相应的实现是正确的。然后,该问题被视为定义不明确的要求。


正如nanda指出的那样,您总是可以向添加无休止的一系列case语句switch,并且不能为OP的示例的每个可能的输入和输出编写测试。
罗伯特·哈维

您可以从技术上测试从Int64.MinValue到的值Int64.MaxValue。运行将花费很长时间,但是它将明确定义需求,而没有出错的余地。使用当前的技术,这是不可行的(我怀疑它将来可能会变得越来越普遍),并且我同意,您可以作弊,但我认为,OP的问题不切实际(没人会以这种方式作弊)在实践中),但只是理论上的一种。
没人

@rmx:如果可以这样做,则测试将是算法,您将不再需要编写算法。
罗伯特·哈维

这是真的。我的大学论文实际上涉及使用单元测试作为指导,使用遗传算法作为TDD的辅助手段来自动生成实现,并且只有通过固体测试才能实现。不同之处在于,将您的需求绑定到代码上通常比体现单元测试的单一方法难得多。然后是一个问题:如果您的实现是单元测试的体现,而单元测试是需求的体现,那么为什么不完全跳过测试呢?我没有答案。
没人2010年

而且,作为人类,我们难道不像在实现代码中一样会在单元测试中犯错误吗?那么为什么要进行单元测试呢?
没人2010年

3

只需编写更多测试。最终,写起来会更短

public long CalculateFactorial(long input)
{
    return input <= 1 ? 1 : CalculateFactorial(input-1)*input;
}

public long CalculateFactorial(long input)
{
    switch (input) {
       case 0: return 1;
       case 1: return 1;
       case 2: return 2;
       case 3: return 6;
       case 4: return 24;
       case 5: return 120;
    }
}

:-)


3
难道不是一开始就正确编写算法?
罗伯特·哈维

3
@Robert,这是计算从0到5的数字的阶乘正确算法。此外,“正确”是什么意思?这是一个非常简单的示例,但是当它变得更加复杂时,“正确”的含义就会变成许多等级。需要根访问权限的程序是否足够“正确”?使用XML是“正确的”,而不是使用CSV吗?你不能回答这个。只要满足某些业务需求,任何算法都是正确的,这些需求在TDD中被表述为测试。
P Shved

3
应该注意的是,由于输出类型很长,因此函数只能正确处理少量输入值(大约20个),因此,较大的switch语句不一定是最糟糕的实现-如果速度更高比代码大小重要,取决于您的优先级,switch语句可能是可行的方法。
user281377 2010年

3

对于“ OK”的足够小的值,编写“作弊”测试是可以的。但是,只有在所有测试均通过且无法编写将失败的新测试时,召回单元测试才完成。如果您真的想拥有一个包含一堆if语句(甚至更好的是一个大的switch / case语句:-)的CalculateFactorial方法,可以这样做,并且由于您要处理的是固定精度的数字,因此所需的代码实现此操作是有限的(尽管可能相当大且难看,并且可能受编译器或系统对过程代码最大大小的限制所限制)。此时,如果您真的坚持所有开发都必须由单元测试来驱动,您可以编写一个测试,该测试要求代码在比遵循if语句的所有分支可以完成的时间短的时间内计算结果。

基本上,TDD可以帮助您编写能够正确实现需求的代码,但不能强迫您编写良好的代码。随你(由你决定。

分享并享受。


+1表示“单元测试仅在所有测试均通过且没有新的测试会失败的情况下才完成”。许多人说,返回常数是合法的,但不要紧跟着“短期”或“如果整体需求只需要这些特定情况“
胸腺嘧啶

1

我确实100%同意Robert Harveys的建议,这不仅仅意味着通过测试,还需要牢记总体目标。

作为您“只有经过验证才能与给定的一组输入配合使用”这一痛点的解决方案,我建议使用数据驱动测试,例如xunit理论。此概念的强大功能在于,它使您可以轻松地创建输入到输出的规范。

对于阶乘,测试将如下所示:

    [Theory]
    [InlineData(0, 1)]
    [InlineData( 1, 1 )]
    [InlineData( 2, 2 )]
    [InlineData( 3, 6 )]
    [InlineData( 4, 24 )]
    public void Test_Factorial(int input, int expected)
    {
        int result = Factorial( input );
        Assert.Equal( result, expected);
    }

您甚至可以实现测试数据提供(返回IEnumerable<Tuple<xxx>>)并编码数学不变量,例如反复除以n将得到n-1)。

我发现此tp是一种非常强大的测试方法。


1

如果您仍然能够作弊,那么测试还不够。写更多测试!对于您的示例,我将尝试添加输入为1,-1,-1000、0、10、200的测试。

但是,如果您确实要作弊,则可以编写无尽的if-then。在这种情况下,除了代码审查之外,没有任何帮助。您很快就会被接受测试(由其他人撰写!

单元测试的问题有时是程序员将它们视为不必要的工作。看到它们的正确方法是作为使您的工作结果正确的工具。因此,如果创建了一个if-then,则您会不知不觉地知道还有其他情况需要考虑。这意味着您必须编写另一个测试。依此类推,以此类推,直到您意识到作弊不起作用,最好以正确的方式编写代码。如果您仍然觉得自己还没有完成,那就还没有完成。


1
因此,听起来您在说,仅编写足够的代码以供测试通过(如TDD所倡导的)是不够的。您还必须牢记合理的软件设计原则。我同意你的说法。
罗伯特·哈维

0

我建议您选择的测试不是最佳测试。

我将从以下内容开始:

阶乘(1)作为第一个测试,

第二阶乘(0)

阶乘(-ve)作为第三

然后继续处理非平凡的案件

并以溢出情况结束。


什么是-ve??
罗伯特·哈维

负值。
克里斯·库德莫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.