代码之家  ›  专栏  ›  技术社区  ›  Jader Dias

如何正确使用TDD实现数值方法?

  •  11
  • Jader Dias  · 技术社区  · 15 年前

    我正在尝试使用测试驱动开发来实现我的信号处理库。但我有一点怀疑:假设我正在尝试实现一个正弦方法(我没有):

    1. 编写测试(伪代码)

      assertEqual(0, sine(0))
      
    2. 编写第一个实现

      function sine(radians)
          return 0
      
    3. 二次试验

      assertEqual(1, sine(pi))
      

    此时,我应该:

    1. 实现适用于PI和其他值的智能代码,或者
    2. 实现只适用于0和pi的最愚蠢的代码?

    如果选择第二个选项,我什么时候可以跳到第一个选项?我最终要做的是…

    9 回复  |  直到 13 年前
        1
  •  9
  •   S.Lott    15 年前

    此时,我应该:

    1. 在两个简单的测试之外实现真正的代码?

    2. 实现更多只对两个简单测试有效的最愚蠢的代码?

    两者都不是。我不确定你从哪里得到了“一次只写一个测试”的方法,但这确实是一个缓慢的过程。

    重点是编写清晰的测试,并使用清晰的测试来设计程序。

    所以,编写足够的测试来实际验证正弦函数。两项测试显然不充分。

    对于连续函数,最终必须提供一个已知良好值的表。为什么等待?

    但是,测试连续函数有一些问题。你不能遵循愚蠢的TDD程序。

    你不能测试 全部的 浮点值介于0和2*pi之间。不能测试几个随机值。

    在连续函数的情况下,“严格的、不思考的TDD”不起作用。这里的问题是,您知道您的正弦函数实现将基于一系列对称性。你必须根据你使用的对称性规则进行测试。虫子藏在裂缝和角落里。边缘案例和角案例是实现的一部分,如果不加思考地遵循TDD,就无法测试它。

    但是,对于连续函数,必须测试实现的边缘和角情况。

    这并不意味着TDD已损坏或不足。它说,如果不考虑你真正的目标是什么,那么对“先测试”的顺从是行不通的。

        2
  •  5
  •   Yishai    15 年前

    在严格的小步骤tdd中,您可以实现dumb方法以返回绿色,然后通过生成一个真正的算法重构dumb代码中固有的复制(对输入值的测试是测试和代码之间的一种复制)。用这种算法感受TDD的困难之处在于,验收测试实际上就在你旁边(S.Lott建议的表),所以你可以一直关注它们。在更典型的TDD中,这个单元与整个单元分离得足够远,以至于验收测试不能直接插在那里,所以您不会开始考虑针对所有场景进行测试,因为所有场景都不明显。

    通常,在一两个案例之后,您可能会有一个真正的算法。TDD最重要的是它是驱动设计,而不是算法。一旦您有足够的案例来满足设计需求,TDD中的值就会显著下降。然后测试更多地转换为覆盖角的情况,以确保您的算法在您能想到的所有方面都是正确的。所以,如果你对如何构建算法有信心,那就去做吧。只有当你不确定的时候,你所说的婴儿步才是合适的。通过采取这些幼稚的步骤,您开始构建代码必须覆盖的边界,即使您的实现实际上还不是真实的。但正如我所说,这更适用于您不确定如何构建算法的情况。

        3
  •  5
  •   rwong    14 年前

    编写验证身份的测试。

    对于sin(x)示例,请考虑双角度公式和半角度公式。

    打开信号处理课本。找到相关的章节并实现这些定理/推论中的每一个,作为适用于您的函数的测试代码。对于大多数信号处理功能,必须为输入和输出维护标识。编写验证这些标识的测试,不管这些输入可能是什么。

    然后考虑输入。

    • 将实施过程划分为不同的阶段。每个阶段都应该有一个目标。每个阶段的测试将验证该目标。(注1)
      1. 第一阶段的目标是“大致正确”。对于sin(x)示例,这类似于使用二进制搜索和一些数学恒等式的简单实现。
      2. 第二阶段的目标是“足够精确”。您将尝试不同的方法来计算同一个函数,看看哪种方法会得到更好的结果。
      3. 第三阶段的目标是“高效”。

    (注1)使其工作,使其正确,使其快速,使其便宜。-归因于艾伦·凯

        4
  •  1
  •   Samuel Carrijo    15 年前

    我相信,当您跳到第一个选项时,您会发现代码中“仅仅为了通过测试”的“ifs”太多。如果是0和π,情况就不是这样了。

    您会感觉到代码开始有味道,并愿意尽快重构它。我不确定这是否是纯TDD所说的,但我知道您在重构阶段(测试失败、测试通过、重构周期)是怎么做的。我的意思是,除非失败的测试要求不同的实现。

        5
  •  1
  •   paxdiablo    15 年前

    你应该一次完成所有单元测试的代码(在我看来)。虽然只创建特定于必须测试内容的测试的想法是正确的,但是您的特定规范需要一个功能 sine() 函数, 正弦() 适用于0和pi的函数。

    找到一个你足够信任的源(一个数学家的朋友,一本数学书后面的表格,或者另一个已经实现正弦函数的程序)。

    我选择了 bash/bc 因为我太懒了,不可能用手把它全部打进去。如果它 正弦() 函数,我只运行以下程序并将其粘贴到测试代码中。我还将把这个脚本的一个副本放在那里作为注释,这样我就可以在某些情况发生变化时重新使用它(例如,如果在这种情况下,分辨率超过20度,或者您想要使用的pi值)。

    #!/bin/bash
    d=0
    while [[ ${d} -le 400 ]] ; do
        r=$(echo "3.141592653589 * ${d} / 180" | bc -l)
        s=$(echo "s(${r})" | bc -l)
        echo "assertNear(${s},sine(${r})); // ${d} deg."
        d=$(expr ${d} + 20)
    done
    

    此输出:

    assertNear(0,sine(0)); // 0 deg.
    assertNear(.34202014332558591077,sine(.34906585039877777777)); // 20 deg.
    assertNear(.64278760968640429167,sine(.69813170079755555555)); // 40 deg.
    assertNear(.86602540378430644035,sine(1.04719755119633333333)); // 60 deg.
    assertNear(.98480775301214683962,sine(1.39626340159511111111)); // 80 deg.
    assertNear(.98480775301228458404,sine(1.74532925199388888888)); // 100 deg.
    assertNear(.86602540378470305958,sine(2.09439510239266666666)); // 120 deg.
    assertNear(.64278760968701194759,sine(2.44346095279144444444)); // 140 deg.
    assertNear(.34202014332633131111,sine(2.79252680319022222222)); // 160 deg.
    assertNear(.00000000000079323846,sine(3.14159265358900000000)); // 180 deg.
    assertNear(-.34202014332484051044,sine(3.49065850398777777777)); // 200 deg.
    assertNear(-.64278760968579663575,sine(3.83972435438655555555)); // 220 deg.
    assertNear(-.86602540378390982112,sine(4.18879020478533333333)); // 240 deg.
    assertNear(-.98480775301200909521,sine(4.53785605518411111111)); // 260 deg.
    assertNear(-.98480775301242232845,sine(4.88692190558288888888)); // 280 deg.
    assertNear(-.86602540378509967881,sine(5.23598775598166666666)); // 300 deg.
    assertNear(-.64278760968761960351,sine(5.58505360638044444444)); // 320 deg.
    assertNear(-.34202014332707671144,sine(5.93411945677922222222)); // 340 deg.
    assertNear(-.00000000000158647692,sine(6.28318530717800000000)); // 360 deg.
    assertNear(.34202014332409511011,sine(6.63225115757677777777)); // 380 deg.
    assertNear(.64278760968518897983,sine(6.98131700797555555555)); // 400 deg.
    

    显然,您需要将这个答案映射到您的实际函数的作用。我的观点是测试应该完全验证这个迭代中代码的行为。如果这个迭代产生一个 正弦() 函数只适用于0和π,那就好了。但在我看来,这将是对迭代的严重浪费。

    可能是你的函数太复杂了 必须 重复几次。然后你的方法二是正确的,测试应该在 下一个 在迭代中添加额外的功能。否则,找到一种快速为这个迭代添加所有测试的方法,那么您就不必担心频繁地在真实代码和测试代码之间切换。

        6
  •  0
  •   Greg Hewgill    15 年前

    严格遵循TDD,您可以首先实现最愚蠢的代码。为了跳转到第一个选项(实现真正的代码),请添加更多测试:

    assertEqual(tan(x), sin(x)/cos(x))
    

    如果您实现的超出了测试的绝对需求,那么您的测试将不会完全覆盖您的实现。例如,如果您实现了 sin() 函数只有上面的两个测试,您可能会通过返回一个三角形函数(看起来几乎像正弦函数)意外地“破坏”它,并且您的测试将无法检测到错误。

    对于数值函数,另一件事是“相等”的概念,并且必须处理浮点计算中固有的精度损失。那就是我 思想 你的问题是在读完标题之后。:)

        7
  •  0
  •   TrueWill    15 年前

    注意(在努尼特)你也可以

    Assert.That(2.1 + 1.2, Is.EqualTo(3.3).Within(0.0005);
    

    当你处理浮点数相等时。

    我记得读到的一条建议是尝试从您的实现中重构出神奇的数字。

        8
  •  0
  •   Jason Plank dvancouver    13 年前

    我不知道你用的是什么语言,但是当我处理一个数值方法时,我通常会先编写一个像你这样的简单测试,以确保大纲是正确的,然后我会提供更多的值来覆盖我怀疑事情可能出错的情况。在.NET中,nunit 2.5有一个很好的特性,叫做 [TestCase] ,您可以向同一个测试输入多个输入值,如下所示:

    [TestCase(1,2,Result=3)]   
    [TestCase(1,1,Result=2)]     
    public int CheckAddition(int a, int b)   
    {  
     return a+b;   
    }
    
        9
  •  0
  •   Gishu    13 年前

    简短的回答。

    • 一次写一个测试。
    • 一旦失败,首先返回绿色。如果这意味着要做最简单的事情,那就做吧。(选项2)
    • 一旦你进入绿色等级,你就可以看到代码和 选择 清除(选项1)。或者你也可以说代码仍然闻不到那么多味道,然后编写后续的测试来关注这些气味。

    另一个问题是,你应该写多少测试。你需要测试直到恐惧(功能可能不起作用)变成无聊。所以,一旦您测试了所有有趣的输入输出组合,您就完成了。