编写单元测试的好方法

18

我以前并不太习惯编写单元测试,现在我开始意识到这个重要性了,并且需要检查自己是否正确的做法。

假设你有一个处理数学运算的类。

class Vector3
{
public:  // Yes, public.
  float x,y,z ;
  // ... ctors ...
} ;

Vector3 operator+( const Vector3& a, const Vector3 &b )
{
  return Vector3( a.x + b.y /* oops!! hence the need for unit testing.. */,
                  a.y + b.y,
                  a.z + b.z ) ;
}

我想到了两种测试Vector类的方法:

1) 先手动解决一些问题,然后将答案硬编码到单元测试中,只有当答案与手动硬编码的结果相等时才通过测试

bool UnitTest_ClassVector3_operatorPlus()
{
  Vector3 a( 2, 3, 4 ) ;
  Vector3 b( 5, 6, 7 ) ;

  Vector3 result = a + b ;

  // "expected" is computed outside of computer, and
  // hard coded here.  For more complicated operations like
  // arbitrary axis rotation this takes a bit of paperwork,
  // but only the final result will ever be entered here.
  Vector3 expected( 7, 9, 11 ) ;

  if( result.isNear( expected ) )
    return PASS ;
  else
    return FAIL ;
}

2) 仔细重写单元测试中的计算代码。

bool UnitTest_ClassVector3_operatorPlus()
{
  Vector3 a( 2, 3, 4 ) ;
  Vector3 b( 5, 6, 7 ) ;

  Vector3 result = a + b ;

  // "expected" is computed HERE.  This
  // means all you've done is coded the
  // same thing twice, hopefully not having
  // repeated the same mistake again
  Vector3 expected( 2 + 5, 6 + 3, 4 + 7 ) ;

  if( result.isNear( expected ) )
    return PASS ;
  else
    return FAIL ;
}

还有其他的方法可以做类似这样的事情吗?

10个回答

9

第一种方法是进行单元测试的普遍接受方式。通过重写你的代码,你可能会将有缺陷的代码重新编写进测试中。大部分情况下,每个被测试的方法只需要一个真实的测试案例,所以并不是太耗时间。


如果期望值不明显,那么您可以在版本#1的注释中添加本质上是您#2代码的内容。 // 7 = a.X + b.X. = 2 + 5 - Mathias

4

这要根据具体情况而定。我会选择能够更清晰地表现测试想法的版本。因此,我不会使用isNear方法,而是检查

expected.x == 7;
expected.y == 9;
expected.z == 11;

使用一个好的xUnit库,你将获得一个清晰的错误信息,指出预期的哪个组件是错误的。在你的例子中,你需要搜索真正的错误源头。

2
我的做法非常简单:永远不要模仿生产代码来在测试中得到你的结果。如果你的算法有缺陷,那么你的单元测试就会重现这个缺陷并通过测试。花点时间考虑一下!有缺陷的代码和通过测试的有缺陷的代码。我认为情况不可能再糟糕了。想象一下,你发现了代码中的错误并进行了更改;现在测试将会失败,但看起来是正确的。在我看来,这不仅会导致易出错的测试,而且会让你从算法的角度思考结果。对于诸如数学之类的东西,你不应该关心算法是什么,只要答案正确即可。我甚至会说,我根本不信任那些模仿生产代码逻辑的测试。
测试应该尽可能地声明式,并且这意味着硬编码完全计算出的结果。对于数学测试,我通常会在纸上/计算器上计算出最简单的值。例如,如果我想测试一个归一化方法,我会选择一些众所周知的值。大多数人都知道45度的sin/cos是根号2的倒数,因此归一化(1,-1, 0)将给出一个容易识别的值。还有许多其他众所周知的数字/技巧可以使用。你还可以使用适当命名的常量来帮助可读性。
我还建议对于数学类型的数据驱动测试,因为你可以快速添加新的测试用例。

1

我认为写出数字(您的第二种方法)是正确的选择。这使得阅读测试的人更容易理解您的意图。

假设您没有重载+运算符,而是有一个名字可怕的函数f,它接受两个Vector3。您也没有对其进行文档化,因此我查看了您的测试以了解f应该做什么。

如果我看到Vector3 expected( 7, 9, 11 ),我必须回过头来逆向工程分析7、9和11是如何成为“期望”的结果的。但是,如果我看到Vector3 expected( 2 + 5, 6 + 3, 4 + 7 ),那么我清楚地知道f将参数的各个元素相加并生成一个新的Vector3


虽然这不是你在问题中提到的,但我想就此提出另一个观点。关于要编写哪些测试,你真的需要确保覆盖边缘情况。对于以下情况应该发生什么:

Vector3 a(INT_MAX, INT_MAX, INT_MAX);
Vector3 b(INT_MAX, INT_MAX, INT_MAX);

Vector3 result = a + b;

// What is expected?  Simple overflow?  Exception?  Default to invalid value?

如果你在做除法,一定要确保考虑到除以零的情况。试着记住这些边缘情况。


由于向量不能被除,因此可能安全地省略除以零的测试 :) - Seth

1

复制那个逻辑并不会有太大的帮助。你在 #2 上面留言已经理解了 :)

除非它是非常复杂的东西,否则我会使用方法 #1。

可能需要一些前期工作来确定一些测试数据;但这通常很容易确定。


1

在测试中使用与代码相同的计算是完全没有意义的。如果你想要更加小心谨慎,为什么不在编写代码时更加小心谨慎呢?使用手动计算的示例是最好的方法,但更好的方法是在编写代码之前先编写测试,这样你就不能偷懒并编写一个你知道会通过的测试,并避免你不完全确定的边缘情况。


0

使用向量加法时,选择哪种方法并不重要,因为这是一种非常简单的操作。一个更好的例子可能是测试规范化方法:

Vector3 a(7, 9, 11); 
Vector3 result = a.normalize(); 

Vector3 hand_solved(0.4418, 0.5680, 0.6943);
Vector3 reproduced(7/sqrt(7*7+9*9+11*11), 9/sqrt(7*7+9*9+11*11), 
    11/sqrt(7*7+9*9+11*11));

看到了吗?对于读者来说,两种方法都不够清晰。复制的计算是可验证的,但是它很混乱且难以阅读。而且在单元测试中重写每个计算也不切实际。手动解决的计算并不能向读者提供任何正确性保证(读者必须手动解决并比较答案)。

解决方案是选择更简单的输入。使用向量,您可以仅测试基向量(ijk)上的所有操作。因此,在这种特定情况下,更清晰的做法是说:

Vector3 i(1, 0, 0);
Vector3 result = i.normalize();
Vector3 expected(1, 0, 0);

在这里,你清楚地知道你正在测试什么以及你期望的结果是什么。如果读者知道normalize应该做什么,那么答案就很明显是正确的。


我绝不会选择基本单位向量作为这样一个函数的唯一测试用例。我见过很多被这种方式测试的错误数学类型。相反,我会选择众所周知的结果,比如将(1,1,0)归一化后,前两个元素为根号2的倒数,第三个元素为零。 - Mark Simpson
嗯,根据你有多少时间,我希望每个方法至少有几个测试(这只是其中之一)。重点不在于选择哪个知名的结果,而在于输入和输出对读者来说尽可能简单明了。 - Seth

0
第一种方法会是更好的选择。关键在于你如何选择用来测试代码的“魔法数据”。
另外一种方式是,有时候我们可以不把数值硬编码到单元测试中,而是将输入集(即“魔法数据”)和相应的预期结果集合起来。这样,单元测试将从输入集中读取数值,执行代码并与预期结果进行测试。

0

无论如何,您都应该执行第1步以验证代码的正确性 - 单元测试应该在创建计算的过程中进行假设。利用这些知识,您可以创建单元测试来使用已经创建的代码(即不要重复)。

单元测试应该测试已知的成功案例、已知的失败案例、边界案例(如果适用,则为上限/下限范围)和任何罕见案例(在运行时很难调试,但在构建时非常便宜,假设您知道它们是什么 :)

您会发现直接计算是最容易进行单元测试的,因为逻辑流程(希望如此)是自包含的。


0

遵循简单的规则:

  1. 始终使用排列、行动和断言(AAA模式)- 可以通过谷歌搜索了解更多信息。
  2. 在单元测试中,您永远不应该使用if/else块。
  3. 在单元测试中,您永远不应该进行任何计算/逻辑操作。
  4. 在单元测试中,不要测试超过一个事物。例如:如果我编写了一个方法:public int Sum(int number1, int number2),我将编写4-5个单元测试,看起来像这样:

    Test_Sum_Number1IsOneNumer2IsTwo_ReturnsThree

    Test_Sum_Number1IsZeroNumer2IsZero_Returns0

    Test_Sum_Number1IsNegativeOneNumer2IsNegativeThree_ReturnsNegativeFour ....等等

或者,您可以使用MBUnit中的RowTest属性或NUnit(2.5.5及以上版本)中的TestCase来进行参数化测试 - 在这里,您只需编写一个方法,并通过将它们指定为属性来传递不同的参数。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接