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

时间:2009-09-23 02:09:16

标签: tdd numerical

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

  1. 编写测试(伪代码)

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

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

    assertEqual(1, sine(pi))
    
  4. 此时,我应该:

    1. 实现适用于pi和其他值的智能代码,或
    2. 实现最简单的代码,该代码仅适用于0和pi?
    3. 如果您选择第二个选项,我什么时候可以跳转到第一个选项?我最终必须这样做......

9 个答案:

答案 0 :(得分:9)

此时,我应该:

  1. 实现在两个简单测试之外工作的实际代码?

  2. 实现更多最愚蠢的代码,只适用于两个简单的测试?

  3. 都不是。我不确定你从哪里得到“一次只编写一个测试”的方法,但这肯定是一个缓慢的方法。

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

    因此,编写足够的测试来实际验证正弦函数。两项测试显然不足。

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

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

    您无法测试0到2 * pi之间的所有浮点值。你无法测试一些随机值。

    在连续功能的情况下,“严格,不假思索的TDD”不起作用。这里的问题是你知道你的正弦函数实现将基于一堆对称性。您必须根据您正在使用的那些对称规则进行测试。虫子隐藏在裂缝和角落里。边缘情况和角落情况是实施的一部分,如果你不假思索地遵循TDD,你无法测试。

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

    这并不意味着TDD被破坏或不足。它表示,如果不考虑一下你真正的目标是什么,那么对“先测试”的盲目奉献就无法发挥作用。

答案 1 :(得分:5)

在严格的婴儿步TDD中,您可以实现哑方法以恢复绿色,然后重构哑代码中固有的重复(测试输入值是测试之间的一种重复)代码)通过生成一个真正的算法。使用这种算法感受TDD的难点在于你的验收测试真的就在你旁边(表格S. Lott建议),所以你总是要密切注意它们。在更典型的TDD中,单元与整体分离得足以使接受测试不能直接插入到那里,因此您不会开始考虑测试所有场景,因为所有场景都不明显。

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

答案 2 :(得分:5)

编写用于验证身份的测试。

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

打开信号处理教科书。查找相关章节并将这些定理/推论中的每一个实现为适用于您的函数的测试代码。对于大多数信号处理功能,必须坚持输入和输出的标识。编写验证这些身份的测试,无论这些输入是什么。

然后考虑输入。

  • 将实施过程分为不同的阶段。每个阶段都应该有一个目标。每个阶段的测试将是验证目标。 (注1)
    1. 第一阶段的目标是“大致正确”。对于sin(x)示例,这就像使用二进制搜索和一些数学身份的天真实现。
    2. 第二阶段的目标是“足够准确”。您将尝试不同的方法来计算相同的功能,看看哪一个得到了更好的结果。
    3. 第三阶段的目标是“高效”。

(注1)让它工作,使其正确,使其快速,使其便宜。 - 归功于Alan Kay

答案 3 :(得分:1)

我相信跳到第一个选项的步骤是当你看到你的代码中有太多“ifs”“只是为了通过测试”。情况并非如此,只有0和pi。

你会觉得代码开始闻起来,并愿意尽快重构。我不确定这是纯TDD所说的,但恕我直言,你在重构阶段(测试失败,测试通过,重构循环)这样做。我的意思是,除非你的失败测试要求不同的实现。

答案 4 :(得分:1)

您应该在一次点击中编写所有单元测试(在我看来)。虽然只创建专门涵盖必须测试内容的测试的想法是正确的,但是您的特定规范需要一个正常运行的sine()函数,而不是一个适用于0的sine()函数和PI。

找到你信任的来源(数学家朋友,数学书背面的表或已经实现了正弦函数的其他程序)。

我选择了bash/bc因为我懒得手工输入所有内容:-)。如果 sine()函数,我只需运行以下程序并将其粘贴到测试代码中。我也会把这个脚本的副本放在那里作为评论,所以我可以重新使用它,如果有什么变化(例如在这种情况下超过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.

显然,您需要将此答案映射到您的真实功能的目的。我的观点是测试应该完全验证此迭代中代码的行为。如果这个迭代产生的sine()函数仅适用于0和PI,那就没问题。但在我看来,这将是对迭代的严重浪费。

可能是你的功能太复杂了,必须在几次迭代中完成。然后你的方法二是正确的,测试应该在 next 迭代中更新,你可以在其中添加额外的功能。否则,找到一种方法快速添加此迭代的所有测试,然后您不必担心经常在实际代码和测试代码之间切换。

答案 5 :(得分:0)

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

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

如果您实施的测试超出了测试的绝对要求,那么您的测试将无法完全涵盖您的实施。例如,如果你只使用上面的两个测试来实现整个sin()函数,你可能会通过返回一个三角函数(几乎看起来像一个正弦函数)意外“破坏”它,你的测试将无法检测错误。

对于数值函数,您将不得不担心的另一件事是“相等”的概念,并且必须处理浮点计算中固有的精度损失。这就是我想到的在阅读标题后你的问题将会是什么。 :)

答案 6 :(得分:0)

请注意(在NUnit中)您也可以

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

当你处理浮点相等时。

我记得读过的一条建议就是尝试重构你实施中的神奇数字。

答案 7 :(得分:0)

我不知道你使用的语言是什么,但是当我处理数字方法时,我通常会先编写一个像你一样的简单测试,以确保大纲正确,然后我提供更多的值来覆盖案例我怀疑事情可能会出错。在.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;   
}

答案 8 :(得分:0)

简短回答。

  • 一次写一个测试。
  • 一旦失败,首先回到绿色。如果这意味着做最简单的事情,那就去做吧。 (选项2)
  • 进入绿色区域后,您可以查看代码并选择进行清理(选项1)。或者你可以说代码仍然没有那么多味道,并编写后续测试,让人们关注气味。

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