11

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

  1. 编写测试(伪代码)

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

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

    assertEqual(1, sine(pi))
    

在这一点上,我应该:

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

如果选择第二个选项,我什么时候可以跳转到第一个选项?我最终将不得不这样做......

4

9 回答 9

9

在这一点上,我应该:

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

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

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

关键是编写清晰的测试并使用该清晰的测试来设计您的程序。

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

在连续函数的情况下,您最终必须提供一个已知良好值的表格。为什么要等?

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

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

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

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

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

于 2009-09-23T02:26:50.807 回答
5

在严格的婴儿步 TDD 中,您可能会实现哑方法以恢复绿色,然后重构哑代码中固有的重复(测试输入值是测试与代码之间的一种重复)通过产生一个真正的算法。使用这种算法感受 TDD 的难点在于,您的验收测试实际上就在您旁边(S. Lott 建议的表格),因此您需要始终关注它们。在更典型的 TDD 中,单元与整体足够分离,以至于验收测试不能直接插入其中,因此您不会开始考虑针对所有场景进行测试,因为并非所有场景都很明显。

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

于 2009-09-23T03:00:20.170 回答
5

编写验证身份的测试。

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

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

然后考虑输入。

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

(注1)让它工作,让它正确,让它快速,让它便宜。- 归功于艾伦凯

于 2010-07-02T06:53:19.700 回答
1

您应该一次性编写所有单元测试(在我看来)。虽然只创建专门覆盖必须测试的测试的想法是正确的,但您的特定规范需要一个功能sine()函数,而不是一个sine()适用于 0 和 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 的函数,那很好。但在我看来,这将是对迭代的严重浪费。

您的函数可能非常复杂,以至于必须经过多次迭代才能完成。那么你的方法二是正确的,测试应该在你添加额外功能的下一次迭代中更新。否则,想办法快速添加本次迭代的所有测试,就不用担心在真实代码和测试代码之间频繁切换。

于 2009-09-23T03:31:17.120 回答
1

我相信当您跳转到第一个选项时,您会看到代码中有太多“ifs”“只是为了通过测试”。情况还不是这样,只有 0 和 pi。

你会感觉到代码开始变味,并且愿意尽快重构它。我不确定这是否是纯 TDD 所说的,但恕我直言,您是在重构阶段(测试失败、测试通过、重构周期)执行的。我的意思是,除非你失败的测试要求不同的实现。

于 2009-09-23T02:14:27.923 回答
1

请注意,(在 NUnit 中)您也可以这样做

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

当您处理浮点相等时。

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

于 2009-09-23T02:21:53.187 回答
0

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

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

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

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

于 2009-09-23T02:11:39.257 回答
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;   
}
于 2009-10-12T02:27:37.910 回答
0

简短的回答。

  • 一次写一个测试。
  • 一旦失败,首先回到绿色。如果这意味着做最简单可行的事情,那就去做吧。(选项 2)
  • 一旦您处于绿色状态,您可以查看代码并选择清理(选项1)。或者您可以说代码仍然没有那么大的气味,然后编写后续测试,将焦点放在气味上。

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

于 2011-11-24T07:03:28.747 回答