最近,越来越多的人引用了某种神奇的工具-基于属性的测试(如果需要使用Google英语文学,则可以进行基于属性的测试)。 关于该主题的大多数文章都谈论这是一种很酷的方法,然后在一个基本示例中展示了如何使用特定框架编写这样的测试,充其量只是建议了几个共同的属性,而且……仅此而已。 此外,惊讶而又热情的读者试图将所有这些都付诸实践,并基于不以某种方式发明属性的事实。 不幸的是,它常常对此屈服。 在本文中,我将尝试对优先级进行一些区别。 不过,我还是会从一个或多或少的具体示例开始,以说明它是哪种动物。 但我希望,对于此类文章而言,这不是一个典型的例子。 然后,我将尝试分析与该方法相关的一些问题,以及如何解决这些问题。 在下文中-属性,属性和仅属性,以及可以推送它们的示例。 有意思吗
在三个简短测试中测试键值存储
因此,由于某种原因,我们需要实现某种键值存储。 它可以是基于哈希表的字典,也可以基于某些树,可以完全存储在内存中,也可以与磁盘一起使用-我们不在乎。 最主要的是它应该具有允许您执行以下操作的接口:
- 按键写值
- 检查是否存在带有所需密钥的条目
- 按键读取值
- 获取记录的项目列表
- 获取存储库的副本
在基于示例的经典方法中,典型的测试如下所示:
storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42
大概:
storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73
通常,此类测试可以而且将需要比dofiga多编写一些。 而且,内部实现越复杂,无论如何都会错过更多机会。 简而言之,是一项漫长而乏味的工作,而且常常令人费解。 把它推到某人身上真是太好了! 例如,使计算机为我们生成测试用例。 首先,尝试执行以下操作:
storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value
这是第一个基于属性的测试。 它看起来与传统的几乎一样,尽管已经有了惊人的收获-它没有上限的取值,而是使用返回任意键和值的函数。 还有另一个更严重的优点-您可以执行很多次,并检查不同输入数据上的协定,如果您尝试将一些元素添加到空存储中,则实际上会将其添加到其中。 好的,这很好,但是到目前为止,与传统方法相比,它不是很有用。 让我们尝试添加另一个测试:
storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy)
在这里,我们不使用空存储,而是使用某些数据生成任意存储,并检查其副本与原始副本相同。 是的,生成器需要使用可能有错误的公共API编写,但是通常这并不是一件困难的任务。 同时,如果在实现中存在任何严重的错误,那么在生成过程中跌倒的可能性很高,因此这也可以视为一种额外的冒烟测试。 但是现在我们可以确定发生器可以提供的所有内容都可以正确复制。 由于有了第一个测试,我们可以确定生成器可以创建包含至少一个元素的存储。 时间下一次测试! 同时,我们重用生成器:
storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup)
我们采用任意存储,并检查是否可以在其中添加另一个元素。 因此,生成器可以创建包含两个元素的存储库。 您也可以为其添加元素。 依此类推(我马上回想起数学归纳法)。 结果,通过编写的三个测试和生成器,可以可靠地验证可以向存储库中添加任意数量的不同元素。 只有三个简短的测试! 这基本上就是基于属性的测试的整个思想:
顺便说一句,这种方法并不与TDD的原理相抵触-测试可以在代码之前以相同的方式编写(至少个人而言,我通常这样做)。 另一个问题是,使这种测试变为绿色可能比传统的测试困难得多,但是当测试成功通过后,我们将确保代码确实符合合同的特定部分。
这一切都很好,但是...
尽管基于属性的测试方法具有所有吸引力,但仍然存在许多问题。 在这一部分中,我将尝试找出最常见的那些。 除了发现有用属性的实际复杂性的问题(我将在下一节中返回)之外,我认为对于初学者而言,最大的麻烦通常是对良好覆盖率的错误信心。 实际上,我们编写了一些测试,这些测试生成了数百个测试用例-可能出什么问题了? 如果您看一下上一部分中的示例,实际上有很多事情。 首先,书面测试不能保证
storage.copy()确实可以进行“深层”复制,而不仅仅是复制指针。 另一个漏洞-如果要查找的密钥不在商店中,则无法正常验证
存储中的
密钥将返回
False 。 然后继续。 好吧,我最喜欢的示例之一-假设我们编写了一个排序,由于某种原因,我们认为检查元素顺序的测试就足够了:
input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:]))
这样的实现将完美地通过
def sort(input): return [1, 2, 3]
我希望这里的道义是明确的。
下一个问题在某种意义上可以称为前两个的结果,那就是使用基于属性的测试通常很难真正实现全面覆盖。 但是我认为这很简单地解决了-您不必只基于属性编写测试,没有人可以取消传统测试。 另外,人们的安排如此安排,以致他们更容易通过具体的例子来理解事物,这也表明了同时使用两种方法。 通常,我为自己开发了大约以下算法-编写一些非常简单的传统测试,理想情况下,它们可以用作应该如何使用API的示例。 一旦感觉到“用于文档”测试就足够了,但是还远远不能覆盖全部内容-开始基于属性添加测试。
现在到框架的问题,对框架的期望以及为什么需要它们-毕竟,没有人禁止您动手进行一个周期的测试,从而导致内部的随机性并享受生活。 实际上,直到第一个测试失败为止,这种快乐才是快乐的,如果在本地而不是在某些CI中,这是件好事。 首先,由于基于属性的测试是随机的,因此您肯定需要一种可靠地重现掉例的方法,并且任何自重的框架都可以做到这一点。 最受欢迎的方法是将特定的种子输出到控制台,您可以在测试运行器中手动将其种子移出并可靠地播放掉掉的大小写(方便调试),或在磁盘上创建带有“错误” sid的缓存,在测试开始时将首先自动对其进行检查(有助于提高CI的可重复性)。 另一个重要方面是数据最小化(外国资源的缩减)。 由于数据是随机生成的,也就是说,使用1000个元素的容器来进行下降的测试用例是完全不伪造的机会,这仍然是调试的“乐趣”。 因此,找到费劲的案例之后的良好框架会应用许多启发式方法来尝试找到更为紧凑的输入数据集,但这些输入数据将继续使测试崩溃。 最后-测试功能的一半通常是输入数据生成器,因此内置生成器和基元的存在使您能够从简单生成器快速构建更复杂的生成器也很有帮助。
偶尔也有人批评基于属性的逻辑测试过多。 然而,这通常伴随着
data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut)
实际上,它是很普通的(对于初学者)反模式,不要这样做! 最好将这样的测试分为两个不同的测试,并在不大可能的情况下跳过不适当的输入数据(在许多框架中甚至有专用的工具),或者使用更专业的生成器立即仅生成合适的数据。 结果应该是这样的
data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut)
和
data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut)
有用的属性及其栖息地
好的,对于基于属性的测试有什么用,似乎很明显,已经解决了主要陷阱...尽管没有,主要的问题仍然不清楚-这些属性从何而来? 让我们尝试搜索。
至少不要跌倒
最简单的选择是将任意数据推送到被测系统中,并验证它不会崩溃。 实际上,这与流行的名称模糊测试是一个完全独立的方向,为此有专门的工具(例如AFL aka American Fuzzy Lop),但在某种程度上可以认为它是基于属性进行测试的一种特殊情况,并且如果根本没有思想的话如果它没有爬上,那么您可以从它开始。 但是,通常来说,这样的测试通常很少有意义,因为当检查其他属性时,潜在的跌落通常会非常好。 我之所以提到此“属性”,主要是为了使读者了解模糊测试,尤其是AFL(有关该主题的许多英语文章),以使图片更完整。
测试甲骨文
这是最无聊的属性之一,但实际上它是一种非常强大的功能,可以比看起来更频繁地使用。 这个想法是,有时会有两段代码以不同的方式完成相同的任务。 然后,您可能尤其不理解生成任意输入数据,将它们推入这两个选项并验证结果是否匹配。 最常引用的应用程序示例是在编写函数的优化版本时留下缓慢但简单的选项并针对它运行测试。
input = arbitrary_list() assert quick_sort(input) == bubble_sort(input)
但是,该特性的适用性不限于此。 例如,很多时候事实证明,我们要测试的系统实现的功能是已经实现的功能的超集,即使在标准语言库中也是如此。 特别是,通常可以使用标准标准字典来测试某些键值存储(在内存或磁盘上,基于树,哈希表或某些其他奇异数据结构,例如merkle patricia树)的大多数功能。 测试各种CRUD-也在那里。
我个人使用的另一个有趣的应用程序-有时在实现系统的数值模型时,可以对某些特殊情况进行解析计算,并将其与仿真结果进行比较。 在这种情况下,通常,如果您尝试将完全任意的数据推入输入中,那么即使采用正确的实现,由于数值解的准确性(以及相应的适用性)有限,测试仍将开始下降,但是在修复过程中,通过对生成的输入数据施加限制,这些限制也相同。成为知名。
要求和不变式
这里的主要思想是,需求本身经常被表述为易于使用的特性。 在有关此类主题的某些文章中,不变量被单独突出显示,但我认为这里的边界太不稳定了,因为这些不变量中的大多数是需求的直接结果,因此我可能会将所有内容都一起转储。
一小部分来自各个领域的示例,适用于检查属性:
- class字段必须具有先前分配的值(getter-setter)
- 存储库应该能够读取以前记录的项目
- 将先前不存在的项目添加到存储库不会影响先前添加的项目
- 在许多词典中,无法存储具有相同键的几个不同元素
- 平衡树高不再 K cdot日志(N) 在哪里 N -记录的项目数
- 排序结果是订购商品的列表
- base64编码结果应仅包含base64字符
- 路线建立算法应返回从A点到B点的一系列允许的运动
- 对于构造的等值线的所有点都应满足 f(x,y)=常量
- 如果签名是真实的,则电子签名验证算法应返回True ,否则返回False。
- 正交归一化的结果,基础中的所有向量都必须具有单位长度和零互标量积
- 矢量传递和旋转操作不得更改其长度
原则上,可以说一切都已完成,文章已完成,使用测试Oracle或在需求中查找属性,但是我想另外指出一些更有趣的“特殊情况”。
归纳和状态测试
有时您需要用状态测试某些东西。 在这种情况下,最简单的方法是:
- 编写测试以检查初始状态的正确性(例如,刚创建的容器为空)
- 写一个生成器,使用一组随机操作将系统带入某种任意状态
- 使用生成器的结果作为初始状态编写所有操作的测试
与数学归纳非常相似:
另一种方法(有时会提供有关它在何处中断的更多信息)是生成可接受的事件序列,将其应用于测试中的系统,并在每个步骤之后检查属性。
来回
如果突然需要测试一些用于直接和反向转换某些数据的功能,那么请考虑您很幸运:
input = arbitrary_data() assert decode(encode(input)) == input
非常适合测试:
- 序列化-反序列化
- 加密解密
- 编码解码
- 将基本矩阵转换为四元数,反之亦然
- 正反坐标变换
- 正和逆傅立叶变换
一个特殊但有趣的情况是反转:
input = arbitrary_data() assert invert(invert(input)) == input
一个明显的例子是矩阵的求逆或转置。
幂等
某些操作不会改变重复使用的结果。 典型示例:
- 分类
- 向量和碱基的任何归一化
- 将现有项目重新添加到集合或字典中
- 在对象的某些属性中重新记录相同的数据
- 将数据转换为规范形式(例如,JSON中的空格会导致统一样式)
如果通常的
解码(编码(输入))==输入方法由于等效输入数据的可能表示形式不同(同样,某些JSON中有多余空格)而不合适,则幂等性也可以用于测试序列化/反序列化:
def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input)
不同的方式,一个结果
这里的想法归结为利用这样的事实:有时有几种方法可以做同一件事。 这看起来像是测试oracle的特例,但实际上并非如此。 最简单的示例是使用某些运算的可交换性:
a = arbitrary_value() b = arbitrary_value() assert a + b == b + a
这看似微不足道,但这是一种很好的测试方法:
- 非标准表示形式中数字的加法和乘法(bigint,理性,仅此而已)
- 有限域中的椭圆曲线上的点的“加法”(您好,密码学!)
- 集的并集(内部可以具有完全非平凡的数据结构)
另外,添加到字典中的元素具有相同的属性:
A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B
该选项更加复杂-很长时间以来,我一直在考虑如何用语言来描述它,但是我想到的只是数学符号。 通常,这种转换很常见
f(x) 财产持有的
f(x+y)=f(x) cdotf(y) ,函数的参数和结果都不一定只是数字,而是运算
+ 和
cdot -仅对这些对象执行一些二进制操作。 您可以用此测试什么:
- 各种奇数,向量,矩阵,四元数的加法和乘法( a cdot(x+y)=a cdotx+a cdoty )
- 线性算子,尤其是各种积分,微分,卷积,数字滤波器,傅里叶变换等( F[x+y]=F[x]+F[y] )
- 例如,对具有不同表示形式的相同对象的操作
- M(qa cdotqb)=M(qa) cdotM(qb) 在哪里 qa 和 qb 是单四元数,并且 M(q) -将四元数转换为等效基矩阵的操作
- F[a circb]=F[a] cdotF[b] 在哪里 一 和 b 是信号 \约 -卷积 cdot -乘法,以及 F -傅立叶变换
一个稍微“普通”任务的示例-要测试一些棘手的字典合并算法,您可以执行以下操作:
a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b)
而不是结论
这基本上就是我在本文中要讲的。 我希望这很有趣,并且会有更多的人开始将所有这些付诸实践。 为了使这项工作更轻松一些,我将为您提供不同语言的不同有效性框架的列表:
并且,当然要特别感谢曾经写过精彩文章的人们,正是由于这些人,我几年前才了解了这种方法,因此不再烦恼并开始基于属性编写测试: