如果数字没有任何含义,魔术数字在单元测试中可以接受吗?


59

在单元测试中,我经常在代码中抛出任意值以查看其作用。例如,如果我知道foo(1, 2, 3)应该返回17,则可以这样写:

assertEqual(foo(1, 2, 3), 17)

这些数字纯粹是任意的,没有更广泛的含义(例如,它们不是边界条件,尽管我也对此进行了测试)。我很难为这些数字想出好名字,而写类似的东西const int TWO = 2;显然是无济于事的。这样编写测试是否可以,还是应该将数字分解为常量?

在中,所有魔幻数字是否都相同?,我们了解到,如果从上下文可以明显看出含义,则魔术数字是可以的,但是在这种情况下,数字实际上根本没有任何意义。


9
如果您要输入值并希望能够读取相同的值,那么我想说魔术数字是可以的。因此,如果说1, 2, 33D数组索引是您先前存储值的地方17,那么我认为此测试会很花哨(只要您也有一些否定的测试)。但是,如果这是计算的结果,则应确保阅读此测试的任何人都将理解为什么foo(1, 2, 3)应该这样做17,并且幻数可能不会达到该目标。
乔·怀特

24
const int TWO = 2;比仅使用更糟糕2。它违反了规则的精神,旨在违反其精神。
Agent_L

4
什么是“不代表什么”?如果什么都没有,为什么会出现在您的代码中?
Tim Grant

6
当然。在一系列此类测试之前发表评论,例如“一小部分手动确定的示例”。与您的其他测试(明确测试边界和例外)相关,这将很明显。
davidbak

5
您的示例具有误导性-当您的函数名称为true时foo,它就没有任何含义,因此也就没有参数了。但在现实中,我敢肯定的功能不具有名称和参数没有名字bar1bar2bar3。举一个更实际的示例,其中的名称具有 含义,然后讨论测试数据值是否也需要名称就更有意义了。
布朗

Answers:


81

您什么时候真正有没有任何意义的数字?

通常,当数字具有任何含义时,应将其分配给测试方法的局部变量,以使代码更具可读性和解释性。变量的名称至少应反映变量的含义,而不必反映其值。

例:

const int startBalance = 10000;
const float interestRate = 0.05f;
const int years = 5;

const int expectedEndBalance = 12840;

assertEqual(calculateCompoundInterest(startBalance, interestRate, years),
            expectedEndBalance);

请注意,第一个变量未命名为HUNDRED_DOLLARS_ZERO_CENT,而是startBalance表示变量的含义,但其值在任何方面都不是特殊的。


3
@Kevin-您正在使用哪种语言进行测试?一些测试框架可让您设置数据提供程序,该提供程序返回一组值数组进行测试
HorusKol,2016年

10
虽然我的想法一致,提防,这种做法可以带来新的错误也一样,如果你不小心提取的值等0.05f来的int。:)
Jeff Bowman

5
+1-好东西。只是因为您不在乎特定的值是什么,并不意味着它仍然不是一个神奇的数字……
Robbie Dee

2
@PieterB:AFAIK是C和C ++的错,这使const变量的概念形式化。
史蒂夫·杰索普

2
您是否将变量的命名与的命名参数相同calculateCompoundInterest?如果是这样,那么额外的输入就是工作量证明,您已经阅读了要测试的功能的文档,或者至少复制了IDE给您提供的名称。我不确定这能告诉读者多少有关代码的意图,但是如果您以错误的顺序传递参数,至少它们可以告诉您意图。
史蒂夫·杰索普

20

如果您只是使用任意数字来查看它们的作用,那么您真正要寻找的可能是随机生成的测试数据或基于属性的测试。

例如,Hypothesis是用于这种测试的一个很棒的Python库,它基于QuickCheck

将正常的单元测试想像如下:

  1. 设置一些数据。
  2. 对数据执行一些操作。
  3. 声明有关结果的信息。

假设使您可以编写如下的测试:

  1. 对于与某些规范匹配的所有数据。
  2. 对数据执行一些操作。
  3. 声明有关结果的信息。

这个想法不是要约束自己使用自己的值,而是选择可以用来检查您的函数是否符合其规范的随机值。需要重点注意的是,这些系统通常会记住任何失败的输入,然后确保将来始终对这些输入进行测试。

第3点可能会使某些人感到困惑,因此让我们澄清一下。这并不意味着您要断言确切的答案-对于任意输入,这显然是不可能的。相反,您可以声明有关结果属性的信息。例如,您可以断言在将某些内容添加到列表之后,该列表将变为非空,或者实际上是一个自平衡的二进制搜索树处于平衡状态(使用特定数据结构具有的任何条件)。

总体而言,您自己选择任意数字可能会很糟糕-并不能真正带来一大堆价值,而且会使阅读它的其他人感到困惑。自动生成一堆随机测试数据并有效地使用它是很好的。为您选择的语言找到一个假设或类似QuickCheck的库可能是实现目标并让其他人理解的更好方法。


11
随机测试可能会发现难以重现的错误,但随机测试却很难发现可重现的错误。确保使用特定的可复制测试用例捕获所有测试失败。
JBR威尔金森'16

5
当您“对结果提出一些建议”(在这种情况下,重新foo计算计算内容)时,如何知道单元测试没有出错?如果您100%确定您的代码给出了正确的答案,那么您只需将该代码放入程序中而不进行测试即可。如果不是,那么您需要测试测试,我想每个人都可以知道测试的方向。

2
是的,如果您将随机输入传递给函数,则必须知道输出将是什么,才能断言它是否正常工作。使用固定/选择的测试值,您当然可以手动进行计算,等等,但是可以肯定的是,确定结果是否正确的任何自动确定方法都会遇到与您要测试的功能完全相同的问题。您可以使用已有的实现(因为正在测试它是否可行而无法使用),或者编写一个新的实现,该实现可能有很多错误(或者,另外,您将使用更可能是正确的实现) )。
克里斯,

7
@NajibIdrissi-不一定。例如,您可以测试将要测试的运算的逆运算应用于结果会返回初始值。或者,您可以测试预期的不变性(例如,对于所有按d天计算的利息,按天计算d+ 1个月的计算应为已知的每月更高的百分比率),等等
。– Jules

12
@Chris-在许多情况下,检查结果是否正确比生成结果更容易。尽管并非在所有情况下都如此,但仍有很多地方。示例:将条目添加到平衡的二叉树中应该会导致新的树也处于平衡状态...易于测试,在实践中很难实现。
Jules

11

您的单元测试名称应提供大部分上下文。不是来自常量的值。测试的名称/文档应提供适当的上下文,并说明测试中存在的幻数。

如果这还不够,那么应该可以提供一些文档(无论是通过变量名还是通过文档字符串)。请记住,函数本身具有希望具有有意义名称的参数。将它们复制到测试中以命名参数是毫无意义的。

最后,如果单元测试过于复杂以至于很难/不实用,那么您的功能可能太复杂了,可能会考虑为什么会这样。

您编写的测试越草率,实际代码就越糟糕。如果您需要命名测试值以使测试清晰明了,则强烈建议您的实际方法需要更好的命名和/或文档编制。如果您发现需要在测试中命名常量,那么我会研究为什么需要这样做-可能问题不是测试本身,而是实现


这个答案似乎与推断测试目的的难度有关,而实际的问题与方法参数中的幻数有关……
Robbie Dee,

@RobbieDee测试的名称/说明应提供适当的上下文,并说明测试中存在的任何幻数。如果不是,请添加文档或重命名测试以使其更加清晰。
enderland

最好给魔术数字起个名字。如果要更改参数数量,则文档可能会过时。
罗比·迪

1
@RobbieDee请记住,函数本身具有希望具有有意义名称的参数。将它们复制到测试中以命名参数是毫无意义的。
enderland '16

“希望”是吗?为什么不正确地编码事物并消除表面上像Philipp已经概述的魔术数字呢?
Robbie Dee

9

这在很大程度上取决于您要测试的功能。我知道很多情况下,单个数字本身并没有特殊含义,但是整个测试用例都是经过深思熟虑构造的,因此具有特定的含义。那就是应该以某种方式记录的内容。例如,如果foo确实是一种testForTriangle确定三个数字是否可能是三角形边缘的有效长度的方法,则测试可能如下所示:

// standard triangle with area >0
assertEqual(testForTriangle(2, 3, 4), true);

// degenerated triangle, length of two edges match the length of the third
assertEqual(testForTriangle(1, 2, 3), true);  

// no triangle
assertEqual(testForTriangle(1, 2, 4), false); 

// two sides equal
assertEqual(testForTriangle(2, 2, 3), true);

// all three sides equal
assertEqual(testForTriangle(4, 4, 4), true);

// degenerated triangle / point
assertEqual(testForTriangle(0, 0, 0), true);  

等等。您可能会对此进行改进,并将注释变成message参数,assertEqual如果测试失败,将显示该参数。然后,您可以进一步改进它,并将其重构为数据驱动的测试(如果您的测试框架支持此功能)。不过,如果您在代码中添加注释,则说明您选择了这个数字的原因以及您要针对个别案例测试的各种行为中的哪一个,会对您有所帮助。

当然,对于其他函数,参数的各个值可能更重要,因此使用无意义的函数名(例如,foo当询问如何处理参数的含义时)可能不是最好的主意。


明智的解决方案。
user1725145

6

为什么我们要使用命名常量而不是数字?

  1. DRY-如果需要在3个地方设置值,我只想定义一次,所以如果改变了,可以在一个地方更改它。
  2. 给数字赋予意义。

如果编写多个单元测试,每个单元测试包含3个数字(startBalance,interest,years),那么我会将这些值作为局部变量打包到单元测试中。它们所属的最小范围。

testBigInterest()
  var startBalance = 10;
  var interestInPercent = 100
  var years = 2
  assert( calcCreditSum( startBalance, interestInPercent, years ) == 40 )

testSmallInterest()
  var startBalance = 50;
  var interestInPercent = .5
  var years = 1
  assert( calcCreditSum( startBalance, interestInPercent, years ) == 50.25 )

如果使用允许命名参数的语言,那么这当然是多余的。在那里,我只是将原始值打包在方法调用中。我无法想象有什么重构可以使该语句更简洁:

testBigInterest()
  assert( calcCreditSum( startBalance:       10
                        ,interestInPercent: 100
                        ,years:               2 ) = 40 )

或使用一个测试框架,它将允许您以某种数组或Map格式定义测试用例:

testcases = { {
                Name: "BigInterest"
               ,StartBalance:       10
               ,InterestInPercent: 100
               ,Years:               2
              }
             ,{ 
                Name: "SmallInterest"
               ,StartBalance:       50
               ,InterestInPercent:  .5
               ,Years:               1
              }
            }

3

...但是在这种情况下,数字实际上根本没有任何意义

数字正被用于调用方法,因此上述前提肯定是错误的。您可能不在乎数字是什么,但这不重要。是的,您可以推断出一些IDE向导使用的数字是什么,但是如果您只给值命名,即使它们只是与参数匹配,那也会更好。


1
不过,这不一定是正确的-例如,在我编写的最新单元测试示例(assertEqual "Returned value" (makeKindInt 42) (runTest "lvalue_operators"))中。在此示例中,42只是占位符值,该值由命名为测试脚本的代码生成lvalue_operators,然后在脚本返回该值时对其进行检查。它完全没有意义,只不过相同的值出现在两个不同的位置。实际给出任何有用含义的合适名称是什么?
Jules

3

如果你想测试一个纯函数的一组是没有边界条件的投入,那么你几乎可以肯定要测试它在一大堆的组输入不在(并)边界条件。对我来说,这意味着应该有一个值来调用该函数,并有一个循环:

struct test_foo_values {
    int bar;
    int baz;
    int blurf;
    int expected;
};
const struct test_foo_values test_foo_with[] = {
   { 1, 2, 3, 17 },
   { 2, 4, 9, 34 },
   // ... many more here ...
};

for (size_t i = 0; i < ARRAY_SIZE(test_foo_with); i++) {
    const struct test_foo_values *c = test_foo_with[i];
    assertEqual(foo(c->bar, c->baz, c->blurf), c->expected);
}

Dannnno的答案中所建议的那样的工具可以帮助您构造要测试的值表。 barbaz并且blurf应使用Philipp答案中讨论的有意义的名称替换。

(这里可以争论的一般原理:数字并不总是需要名称的“魔术数字”;相反,数字可能是数据。如果将数字放入数组(也许是记录的数组)有意义,那么它们可能就是数据相反,如果您怀疑手头上可能有数据,请考虑将其放入数组并获取更多数据。)


1

测试与生产代码不同,至少在用Spock编写的单元测试中,它简短而直截了当,我使用魔术常数没有问题。

如果测试的长度为5行,并且遵循基本的给定/何时/然后方案,则将此类值提取为常量只会使代码更长且更难阅读。如果逻辑是“当我添加一个名为Smith的用户时,我看到该用户Smith在用户列表中返回”,则将“ Smith”提取为常量没有任何意义。

如果您可以轻松地将“给定”(设置)块中使用的值与“ when”和“ then”块中找到的值轻松匹配,则这当然适用。如果您的测试设置与使用数据的位置(在代码中)分开,则使用常量可能更好。但是由于测试最好是独立的,因此设置通常接近使用地点,并且适用第一种情况,这意味着在这种情况下魔术常数是完全可以接受的。


1

首先,让我们同意“单元测试”通常用于覆盖程序员编写的所有自动化测试,并且争论每个测试应称为什么毫无意义……。

我曾在一个系统上工作,该软件需要大量输入并制定出一个“解决方案”,该解决方案必须满足一些约束,同时还要优化其他数字。 没有正确的答案,因此该软件只需要给出合理的答案即可。

它通过使用大量随机数来获得起点,然后使用“爬山者”来改善结果。该程序运行了很多次,取得了最佳效果。可以播种一个随机数生成器,以便它总是以相同的顺序给出相同的数字,因此,如果测试设置了种子,我们知道每次运行的结果都将相同。

我们进行了许多测试,检查了结果是否相同,这告诉我们在重构等过程中我们没有错误地更改了系统那部分所做的工作。它没有告诉我们有关系统的那部分做了什么。

这些测试的维护成本很高,因为对优化代码的任何更改都会破坏测试,但它们还会在对数据进行预处理并对结果进行后处理的更大的代码中发现一些错误。

当我们“模拟”数据库时,您可以将这些测试称为“单元测试”,但是“单元”相当大。

通常,当您在没有测试的系统上工作时,您会执行上述操作,以便可以确认重构不会改变输出。希望为新代码编写更好的测试!


1

我认为在这种情况下,这些数字应被称为任意数字,而不是魔术数字,并且只需将该行注释为“任意测试用例”即可。

当然,某些魔术数字也可以是任意的,例如唯一的“句柄”值(当然应该用命名常量代替),但也可以预先计算出常量,例如“欧洲空腹麻雀的空速每两周以弗隆为单位”,插入数字值时没有注释或有用的上下文。


0

我不会冒险地说肯定的是/否,但是在确定是否可以确定时,您应该问自己一些问题。

  1. 如果这些数字没有任何意义,那么为什么它们会排在首位?可以用其他东西代替吗?您可以基于方法调用和流程而不是值断言来进行验证吗?考虑一下类似Mockito verify()方法的方法,该方法检查是否对模拟对象进行了某些方法调用,而不是实际声明值。

  2. 如果数字确实有意义,则应将其分配给适当命名的变量。

  3. 编写数2TWO可能是在某些情况下有益的,在其他情况下没有这么多。

    • 例如:对于assertEquals(TWO, half_of(FOUR))阅读代码的人来说很有意义。立即清楚您正在测试什么
    • 然而,如果你的测试assertEquals(numCustomersInBank(BANK_1), TWO),那么这不会使多大意义。为什么BANK_1包含两个客户?什么是我们的测试呢?
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.