测试非确定性代码
非确定性代码是生活中的一个必然事实,但它测试起来非常痛苦。
非确定性代码是在给出相同输入时可能产生不同输出的代码。例如:要求输出三种最受欢迎水果的程序可能列出“苹果、香蕉和橙子”,但它可以给出这些水果的任何任意顺序 - 例如“橙子、香蕉和苹果”。
通常最终用户或客户实际上并不关心非确定性 - 水果的顺序可能无关紧要。
但是,您的自动化测试会关心 - 如果您编写一个期望“苹果、香蕉和橙子”的测试,那么您将得到一个易变的测试,该测试将任意地通过和失败。
我通常采用五种方法来处理使用hitchstory 测试非确定性代码,我按优先顺序列出它们。
如果您目前面临非确定性问题,您可以将此作为一种指南或教程,在遇到非确定性问题时使用。
1. 使代码确定性
除了代码随机性是所需属性的情况外,更确定性的代码是更好的代码。它是一种像 DRY 或松耦合这样的代码质量,是一个值得赞扬的目标。
更确定性不仅仅意味着更容易测试,它意味着受限的执行空间。实际上,受限的执行空间意味着更少的潜在途径会导致意外错误出现 - 这种错误会让客户在凌晨 4 点打电话给你,而当你用自己的笔记本电脑尝试时却不会遇到错误。
虽然少量的确定性对于客户来说不一定是问题,但当非确定性行为与其他非确定性行为复合时,它会迅速失控。如果非确定性行为相乘,当这种情况发生时,可能出现错误的潜在边缘情况的数量可能会失控。
但是,非确定性自然出现在代码中的各种地方,这些地方并不难消除。以下列出两个常见问题,即使客户告诉你不关心,我也会将它们视为错误。
没有 ORDER BY 的 SQL SELECT 语句
没有 order by 的 select 语句通常每次都以相同的顺序输出,但它们并不总是这样。您可以编写一个在笔记本电脑上运行良好的测试,该测试期望从 select 语句中获得特定顺序(例如,它检查页面上的第一个产品),然后该测试可能在第二天或在持续集成机器上随机失败,因为它已移动。
我遇到过很多次这样的情况。这种情况发生的次数太多了,以至于现在,如果我看到一个 `SELECT` (或 ORM 等效语句) 没有 `order by` 的 Pull Request,我都会把它视为一个需要修复的问题,即使它不太可能导致问题。
当没有 `order by` 导致测试不稳定时,这通常是最好的解决方案。
无序字典/哈希表
在 Python 中,最常用的数据结构之一是 “字典” - 它将 “键” 与 “值” 关联,例如:
my_dictionary = {
"fruit": "apple",
"car": "ford",
"coffee": "arabica",
}
如果代码只是从字典中查找一个键或值,那么永远不会出现问题。但是,如果代码试图遍历所有键值对,那么就会出现问题。例如:
for kind_of_thing, thing in my_dictionary.items():
print(kind_of_thing)
这里的问题是,字典中元素的顺序通常没有保证。虽然示例中显示的顺序是 “fruit”, “car”, “coffee”,但你可能会得到 “coffee”, “car”, “fruit” - 而且你很有可能会遇到这种情况。
你可以通过使用 “OrderedDict” (它会始终记住顺序) 或使用 Python 3.6 及更高版本来保证 Python 中的顺序。
虽然这是 Python 中的一个显著问题,但许多其他语言也存在同样的问题。它经常出现在你依赖的 **库** 中。
但有时你无法解决这个问题
虽然对于某些代码来说,这些修复可能既快又容易,尤其是在你与乐于助人的开发人员一起工作时 (或者你自己就是开发人员),但并非所有问题都那么容易解决。例如:
- 你可能正在使用一个非确定性的库,修复它根本不可行。
- 你合作的开发人员可能固执己见,不愿花时间提供帮助。
- 你可能正在处理某种本质上非确定性的代码 (例如机器学习代码)。
- 也许可以修复,但需要大量的 **工作量**,而你没有时间。
- 随机数可能是应用程序的关键功能。
如果非确定性无法修复,那么继续...
2. 隔离非确定性,并分别测试依赖它的代码
假设你在测试某种使用虚拟骰子投掷的策略游戏。使用确定性方法对这种游戏进行端到端测试几乎不可能,因为每次的结果都会不同。
你可以将代码修改为始终从同一个函数获取 “骰子投掷” 结果。然后,你可以让该函数在 “测试” 模式和 “真实” 模式下可用。在测试模式下,它可以从一个文件 (你的测试可以预先填充该文件) 中确定性地获取数字。
瞧,你已经隔离了非确定性,并且现在拥有一个易于测试的游戏,你可以在其中一致地验证不同骰子投掷的结果。
当然,有时你 **无法轻松地做到这一点** - 也许代码更改会很困难 (例如,随机数在整个代码库中以不同的方式调用) 或者,你又一次遇到了固执己见的开发人员。在这种情况下,另一种可能的做法是输出转换。
3. 输出转换
假设你正在测试的不是使用虚拟骰子投掷的策略游戏,而只是测试一个虚拟的六面骰子 - 没什么花哨的,只是一个输出以下内容的命令行应用程序:
You rolled a 6!
这里的输出始终是 “You rolled an n!” 的形式。由于这种结构是保证的,你可以编写测试代码,它接收输出,并将 “You rolled a 1!” 或 “You rolled a 2!” 转换为 “You rolled an n!”。
你可以使用正则表达式或模板来进行这种转换,然后规范可以检查转换后的版本是否为 “You rolled an n!”。
这种方法的缺点是,它需要通常相当复杂且可能存在 **自身错误** 的测试代码。当然,好处是,即使你无法访问应用程序代码,也无法轻松更改它,它仍然有效。
4. 在测试中列出多个有效输出
我曾经在一个应用程序上工作,该应用程序有一个网页,上面列出了两个产品 - 便宜版和昂贵版。我试图进行截图测试,我意识到有时便宜版会在左边出现,有时会在右边出现。
我得到了两个完全不同的截图。我通知了利益相关者,但他们无动于衷。代码本身可以更改以停止这种情况,但由于各种原因,这样做将非常困难且有风险。
我没有这样做,而是为每个版本创建了两个截图,并验证了 **至少其中一个** 被显示。这可能不是最好的解决方案,但它便宜且有效。
5. 测试输出的属性,而不是输出本身
你们中一些目光敏锐的人可能已经意识到,上面测试骰子投掷的测试遗漏了一个非常关键的细节:如果骰子投掷出 7 或更糟,或者 0,测试仍然会通过,但它会在存在 bug 的情况下通过。
这并不一定是世界上最糟糕的事情 - 为所有事情编写自动化测试是一项昂贵的 **测试投资**,有时测试投资不会带来回报。
但是,如果你致力于这样做,上面的测试可以扩展到不仅将输出从 “You rolled a 6!” 转换为 “You rolled an n!”,还可以进行转换并提取数字。然后,可以对该数字进行 **属性测试**。你可以做各种事情来进行属性测试,并且可能需要以或多或少的详细程度来执行它们。例如,在这种情况下,你可以测试以下任何一项:
- 数字为负数。
- 数字为整数。
- 数字大于 0 但小于 7。
结论
使用对你最有意义的方法,但要倾向于更改 **代码** 以使其确定性,而不是测试。