有什么好的单元测试来覆盖滚动模具的用例?


18

我正在努力掌握单元测试。

假设我们有一个模具,其默认面数可以等于6(但可以是4、5面,等等):

import random
class Die():
    def __init__(self, sides=6):
        self._sides = sides

    def roll(self):
        return random.randint(1, self._sides)

以下是有效/有用的单元测试吗?

  • 测试6面模具的1-6范围内的辊
  • 测试6面模具的0卷
  • 测试6面模具的7卷
  • 测试3面模具的1-3范围内的辊
  • 测试3面模具的0卷
  • 测试一卷4的3面模具

我只是认为这些都是浪费时间,因为随机模块已经存在了很长时间,但是我认为如果随机模块得到更新(比如我更新了我的Python版本),那么至少我会被覆盖。

另外,在这种情况下,我是否还需要测试模具辊的其他变化,例如3,还是覆盖另一个已初始化的模具状态好吗?


1
减5面的模具或零面的模具呢?
JensG 2014年

Answers:


22

没错,您的测试不应验证该random模块是否在执行其工作;单元测试应该只测试类本身,而不测试它与其他代码的交互方式(应单独测试)。

当然,您的代码完全有可能使用random.randint()错误的代码。否则您会打电话给random.randrange(1, self._sides)您,而您的死也永远不会抛出最高价值,但这将是另一种错误,没有一个单元测试可以捕获的错误。在这种情况下,您的die 设备按设计工作,但是设计本身存在缺陷。

在这种情况下,我将使用模拟来替换randint()函数,并仅验证它已被正确调用。该unittest.mock模块附带Python 3.3及更高版本来处理这种类型的测试,但是您可以在旧版本上安装外部mock软件包以获得完全相同的功能

import unittest
try:
    from unittest.mock import patch
except ImportError:
    # < python 3.3
    from mock import patch


@patch('random.randint', return_value=3)
class TestDice(unittest.TestCase):
    def _make_one(self, *args, **kw):
        from die import Die
        return Die(*args, **kw)

    def test_standard_size(self, mocked_randint):
        die = self._make_one()
        result = die.roll()

        mocked_randint.assert_called_with(1, 6)
        self.assertEqual(result, 3)

    def test_custom_size(self, mocked_randint):
        die = self._make_one(sides=42)
        result = die.roll()

        mocked_randint.assert_called_with(1, 42)
        self.assertEqual(result, 3)


if __name__ == '__main__':
    unittest.main()

通过模拟,您的测试现在非常简单;真的只有2种情况。6面模具的默认情况和自定义面情况。

还有其他方法可以临时替换的randint()全局命名空间中的功能Die,但该mock模块使此操作最容易。该@mock.patch装饰这里适用于所有的测试用例的测试方法; 每个测试方法都传递了一个额外的参数,即random.randint()模拟函数,因此我们可以对模拟进行测试,以查看它是否确实已被正确调用。该return_value参数指定了在调用模拟时返回的内容,因此我们可以验证该die.roll()方法确实向我们返回了“随机”结果。

我在这里使用了另一种Python单元测试的最佳实践:将被测类作为测试的一部分导入。该_make_one方法在测试中完成导入和实例化工作,因此即使您犯了语法错误或其他会阻止原始模块导入的错误,测试模块仍将加载。

这样,如果您在模块代码本身中犯了一个错误,则测试仍将运行;它们只会失败,告诉您代码中的错误。

需要明确的是,以上测试在极端情况下过于简单。例如,此处的目标不是测试random.randint()使用正确参数调用的对象。相反,目标是测试在给定某些输入的情况下该单元正在产生正确的结果,其中这些输入包括测试的其他单元的结果。通过模拟该random.randint()方法,您可以仅控制代码的另一个输入。

实际测试中,被测单元中的实际代码将变得更加复杂。与传递给API的输入的关系以及随后如何调用其他单元的关系仍然很有趣,而模拟将使您能够访问中间结果,并让您设置这些调用的返回值。

例如,在针对第三方OAuth2服务(多阶段交互)对用户进行身份验证的代码中,您想要测试代码是否将正确的数据传递给该第三方服务,并让您模拟出不同的错误响应,第三方服务将返回,使您可以模拟不同的场景,而不必自己构建完整的OAuth2服务器。在这里,重要的是测试来自第一个响应的信息是否已正确处理并已传递给第二阶段调用,因此您确实希望看到模拟服务已正确调用。


1
您有2个以上的测试用例...结果检查默认值:下限(1),上限(6),下限(0),下限(7)以及用户指定的数字(如max_int)的结果等输入也未经验证,这可能需要在某些时候进行测试……
James Snell 2014年

2
不,这些是针对的测试randint(),而不是中的代码Die.roll()
马丁·皮耶特

实际上,有一种方法可以确保不仅正确地调用了randint,而且也正确地使用了其结果:模拟它以返回一个sentinel.die示例(前哨对象也来自unittest.mock),然后验证它是否是您的roll方法返回的。实际上,这仅允许一种方法来实现测试方法。
aragaer 2014年

@aragaer:当然,如果要验证返回的值是否不变,sentinel.die这将是确保这一点的好方法。
马丁·彼得

我不明白您为什么要确保用某些值调用mocked_randint。我知道要模拟randint以返回可预测的值,但不仅担心它返回可预测的值,而不是它调用的是什么值?在我看来,检查调用的值似乎不必要地将测试与实现的详细细节联系在一起。另外,为什么我们要关心骰子返回randint的确切值?我们真的不只是在乎它返回的值> 1并且小于等于最大值吗?
bdrx 2014年

16

Martijn的答案是,如果您真的想运行一个表明您正在调用random.randint的测试,该怎么做。但是,冒着被告知“无法回答问题”的风险,我觉得这根本不应该进行单元测试。模拟randint不再是黑匣子测试-您特别地表明实施中正在进行某些事情。黑匣子测试甚至都不是选项-您无法执行任何测试来证明结果永远不会小于1或大于6。

你会嘲笑randint吗?是的你可以。但是你证明了什么?您用参数1和面调用它。是什么意思 您又回到了第一方-在一天结束时,您最终必须(正式或非正式地)证明呼叫random.randint(1, sides)正确实现了掷骰子。

我都在进行单元测试。它们是出色的完整性检查,并且可以发现错误。但是,它们永远无法证明自己的缺席,并且有些事情根本无法通过测试来断言(例如,某个特定函数从不抛出异常或总是终止。)在这种特殊情况下,我觉得您所忍受的很少获得。对于确定性的行为,单元测试很有意义,因为您实际上知道您期望的答案是什么。


实际上,单元测试不是黑盒测试。这就是集成测试的目的,以确保各个部分按照设计进行交互。当然,这是一个意见问题(大多数测试哲学是),请参阅“单元测试”属于白盒测试还是黑盒测试?黑匣子单元测试的一些观点(堆栈溢出)。
Martijn Pieters 2014年

@MartijnPieters我不同意“这就是集成测试的目的”。集成测试用于检查系统的所有组件是否正确交互。它们不是测试给定组件为给定输入提供正确输出的地方。至于黑盒与白盒单元测试,白盒单元测试最终将因实现更改而中断,并且您在实现中所做的任何假设都可能会延续到测试中。如果那是错误的事情,那么random.randint用验证调用1, sides将毫无价值。
Doval 2014年

是的,这是白盒单元测试的局限性。但是,在测试中没有任何意义random.randint()可以正确返回范围为[1,sides](包括)的值,这取决于Python开发人员来确保random单元正常工作。
Martijn Pieters 2014年

就像您自己说的那样,单元测试不能保证您的代码没有错误。如果你的代码使用其他单位错误(比如,你希望random.randint()表现得像个random.randrange()从而与调用它random.randint(1, sides + 1),那么你无论如何沉没。
的Martijn Pieters的

2
@MartijnPieters我在那里同意你的看法,但这不是我反对的意思。我反对测试用参数(1,侧面)调用random.randint。您已经在实现中假设这是正确的做法,现在您正在测试中重复该假设。如果该假设是错误的,则测试将通过,但您的实现仍然不正确。这是半确定的证据,是编写和维护过程中的全部烦恼。
Doval 2014年

6

修复随机种子。对于1、2、5和12面的骰子,请确认几千次掷骰给出的结果包括1和N,但不包括0或N + 1。覆盖预期范围,切换到其他种子。

模拟工具很酷,但是仅仅因为它们允许您做某事并不意味着就应该做。YAGNI不仅适用于功能,还适用于测试夹具。

如果您可以轻松地对未经模拟的依赖项进行测试,那么几乎总是应该这样做;这样,您的测试将专注于减少缺陷数量,而不仅仅是增加测试数量。过多的模拟风险会产生误导性的覆盖率数据,这又可能导致将实际测试推迟到以后的某个阶段,您可能根本没有时间进行解决...


3

什么是Die如果你认为呢?-只是一个包装random。它将random.randint根据您应用程序自己的词汇对其进行封装和重新标记:Die.Roll

我发现在Die和之间插入另一层抽象并不重要,random因为Die它本身已经是应用程序和平台之间的间接层

如果您想获得罐装骰子的结果,只需模拟Die,不要模拟random

通常,我不对与外部系统通信的包装对象进行单元测试,而是为它们编写集成测试。您可以为此写一些,Die但是正如您所指出的那样,由于基础对象的随机性,它们将无意义。此外,这里不涉及任何配置或网络通信,因此除了平台调用外无需进行太多测试。

=>考虑到这Die只是几行琐碎的代码,并且与random自身相比几乎没有添加任何逻辑,因此在该特定示例中将跳过测试。


2

据我所知,播种随机数生成器并验证预期结果不是有效的测试。它假设您的骰子如何在内部工作,这是顽皮的。python的开发人员可以更改随机数生成器,也可以更改模具(注意:“骰子”为复数,“模具”为单数。除非您的类在一次调用中实现了多个模具掷骰,否则应将其称为“模具”)使用其他随机数生成器。

类似地,模拟随机函数假定类实现完全按预期工作。为什么不是这样?有人可能会控制默认的python随机数生成器,为避免这种情况,将来的裸机版本可能会获取几个随机数或更大的随机数,以混入更多随机数据。当FreeBSD操作系统的制造商怀疑NSA篡改CPU中内置的硬件随机数生成器时,他们使用了类似的方案。

如果是我,我会进行6000次滚动,对它们进行计数,并确保1-6中的每个数字在500至1500次之间滚动。我还要检查是否没有返回超出该范围的数字。我可能还会检查一下,对于第二组6000卷,当按频率顺序订购[1..6]时,结果是不同的(如果数字是随机的,这将在720次运行中失败!)。如果您想更全面一点,可能会发现数字的出现频率在1之后,在2之后,以此类推;但请确保样本量足够大,并且有足够的方差。人类期望随机数具有比实际更少的模式。

对12面和2面模具重复上述步骤(最常用6面模具,因此编写此代码的任何人都应该最期待)。

最后,我将进行测试以查看1面模具,0面模具,-1面模具,2.3面模具,[1,2,3,4,5,6]面模具以及死了。当然,这些都应该失败。他们以一种有用的方式失败了吗?这些可能应该在创建时失败,而不是在滚动时失败。

或者,也许您也想以不同的方式处理这些问题-也许用[1,2,3,4,5,6]创建一个骰子应该是可以接受的-或许也可以是“ blah”;这可能是一个有4个面的骰子,每个面都有一个字母。游戏“ Boggle”和神奇的八球一样让人联想到。

最后,您可能需要考虑一下:http : //lh6.ggpht.com/-fAGXwbJbYRM/UJA_31ACOLI/AAAAAAAAAPg/2FxOWzo96KE/s1600-h/random%25255B3%25255D.jpg


2

冒着逆流而泳的风险,几年前,我使用一种至今未提及的方法解决了这个确切的问题。

我的策略只是用一个RNG模拟RNG,该RNG产生跨越整个空间的可预测值流。如果(say)side = 6并且RNG依次产生从0到5的值,则我可以预测我的班级表现并相应地进行单元测试。

这样做的理由是,仅假设RNG最终会产生这些值中的每个值,而无需测试RNG本身,就可以单独测试此类中的逻辑。

它简单,确定性,可重现,并且确实捕获了错误。我会再次使用相同的策略。


这个问题并没有说明应该进行什么样的测试,在存在RNG的情况下,什么数据可以用于测试。我的建议仅仅是通过模拟RNG进行详尽的测试。什么值得测试的问题取决于问题中未提供的信息。


假设您嘲笑RNG是可预测的。那么您接下来要测试什么?问题问“以下内容是否有效/有用的单元测试?” 模拟它返回0-5不是测试,而是测试设置。您将如何“相应地进行单元测试”?我无法理解它是如何“捕获错误”的。我很难理解我需要进行“单元”测试。
bdrx 2014年

@bdrx:这是前一段时间:我现在将以不同的方式回答。但请参阅编辑。
david.pfx 2014年

1

您在问题中建议的测试不会将模块化算术计数器检测为实现。而且它们不会在与概率分布相关的代码(例如)中检测到常见的实现错误return 1 + (random.randint(1,maxint) % sides)。或更改生成器以生成二维模式。

如果您确实要验证自己正在生成均匀分布的随机出现的数字,则需要检查各种各样的属性。要做到这一点,您可以在生成的数字上运行http://www.phy.duke.edu/~rgb/General/dieharder.php。或者编写一个类似的复杂的单元测试套件。

这不是单元测试或TDD的错,随机性恰好是很难验证的属性。并以热门话题为例。


-1

模切辊最简单的测试就是将其重复数十万次,并验证每个可能的结果是否均被击中(1 /边数)次。对于6面骰子,您应该看到每个可能的值大约在16.6%的时间内命中。如果有超过百分之一的折扣,那么您有问题。

这样做避免了让您重构生成随机数的基本机制,而最重要的是无需更改测试。


1
本次测试将通过一个完全非随机实现,只需通过双方逐一按预定顺序循环
蚊蚋

1
如果编码人员打算恶意实施某些事情(不在裸片上使用随机代理),而只是试图找到某种方法来“使红灯变成绿色”,那么您将遇到比单元测试真正解决的问题更多的问题。
ChristopherBrown
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.