QuickCheck基于属性的测试简介

2020-12-18 15:38:13

2月,我将在TU Delft教授函数编程的新课程。该课程将主要使用格雷厄姆·赫顿(Graham Hutton)的绝妙著作涵盖Haskell,尽管最后还将介绍有关在Agda中依赖类型的基本用法的部分内容。对于练习,我们将使用由Delft开发的Weblab平台,让学生编写代码并使用QuickCheck运行自动化测试。对我来说不幸的是,这本书根本没有谈论QuickCheck。对您来说幸运的是,这意味着我决定亲自编写一个教程,您可以在这里阅读。

当然,已经有许多出色的QuickCheck教程。但是,我发现他们要么都假设了太多的Haskell知识(因为我想尽早引入QuickCheck),或者跳过了有趣的部分(例如条件和量化属性),或者只是不了解最新知识。最新版本的QuickCheck(例如使用Fun生成随机函数的新方法)。因此,我希望这篇文章能弥补当前教程菜单中至少有几个人的空白。

如果您发现任何错误或改进的机会,请告诉我。代尔夫特理工大学的学生将不胜感激!

当您第一次学习编程时,有时会被告知编写单元测试的重要性:小型测试用例,每个用例都测试代码的一小部分功能。尽管编写单元测试确实很重要,但同时也很无聊且困难。这很无聊,因为您需要为每个功能编写许多单独的单元测试,这些功能看起来或多或少都是相同的。这很困难,因为很容易错过程序崩溃的某些输入组合。如果我们只需写下程序的行为方式并自动生成测试用例,那会不好吗?这正是基于属性的测试方法。

简而言之,基于属性的测试是一种测试方法,您作为程序员可以写下您希望保留程序的属性。在运行测试时,测试运行程序将生成许多不同的随机输入值,然后检查该属性对于所有这些(输入值)组合是否成立。与编写单个单元测试相比,基于属性的测试具有以下优点:

您可以减少编写测试代码的时间:单个属性通常可以替换许多手写的测试用例。

您可以获得更好的覆盖范围:通过随机生成输入,QuickCheck可以测试许多您不会手工测试的组合。

您可以花更少的时间诊断错误:如果某个属性无法保留,QuickCheck将自动产生一个最小化的反例。

QuickCheck是用于对Haskell代码进行基于属性的测试的工具。自1999年在Haskell推出以来,QuickCheck作为一种测试框架已经非常流行,并且已移植到许多其他编程语言中,例如C,C ++,Java,JavaScript,Python,Scala以及许多其他语言(请参见https:// en .wikipedia.org / wiki / QuickCheck获取更完整的列表)。但是,QuickCheck确实得益于Haskell是一种纯语言这一事实​​,因此,该方法仍然是最强大的方法。

本简介将向您展示QuickCheck用于测试Haskell代码属性的基本用法,以及如何使用替代随机生成器。使用的所有功能均来自QuickCheck软件包中的Test.QuickCheck模块。可以使用Cabal软件包管理器为Haskell安装此软件包,方法是发出以下命令:

要编写QuickCheck测试用例,您所要做的就是定义一个Haskell函数,该函数定义您希望保留的程序属性。在最简单的情况下,属性只是Bool类型的值。例如,假设我们编写了一个简单的Haskell函数来计算两个整数之间的距离:

然后我们可以表示3和5之间的距离等于2的性质:

按照约定,QuickCheck属性的名称始终以prop_开头。我们可以通过定义一个返回布尔值的函数来表达更一般的属性:

-任何数字与其自身之间的距离始终为0 prop_dist_self :: Int-> Bool prop_dist_self x =距离x x == 0-x和y之间的距离等于y和x之间的距离prop_dist_symmetric :: Int->整数->布尔prop_dist_symmetric x y =距离x y ==距离y x

测试带有一个或多个输入的属性时,QuickCheck将随机生成多个输入(默认为100个),并检查该函数是否对所有输入都返回True。

用于调用QuickCheck的主要功能是quickCheck,它在Test.QuickCheck模块中定义。要导入它,您可以在文件顶部添加import Test.QuickCheck,如果您使用的是GHCi,则可以手动导入。假设您已经安装了QuickCheck软件包,则可以通过调用quickCheck来加载文件并运行测试:

> ghci GHCi,版本8.10。 2:https://www.haskell.org/ghc/ :?获得帮助从〜/ .ghc / x86_64 -linux-8.10加载程序包环境。 2 /环境/默认> :l QuickCheckExamples.hs> quickCheck prop_dist35 +++ OK,通过了1个测试。

QuickCheck告诉我们一切都应该是应有的:它运行了测试并得到了结果为True。由于没有测试输入,因此只能运行一次。让我们尝试更多属性!

巨大的成功!对于每个测试,QuickCheck都生成了100个随机输入,并验证了该属性对于每个测试都返回True。

要获取有关QuickCheck生成的测试输入的更多信息,可以将函数quickCheck替换为verboseCheck。这将在生成每个测试用例时将其打印出来。自己试试吧!

如果我们的代码有误怎么办?说我们忘了在距离的定义中写腹肌吗?

QuickCheck找到了一个反例:如果第一个输入x为0,第二个输入y为1,则y-x不等于x-y。

当QuickCheck找到一个反例时,它不会总是返回遇到的第一个反例。而是,QuickCheck将查找它可以找到的最小的反例。例如,让我们尝试在(false)属性上运行QuickCheck,声明每个列表都已排序。

排序:: Ord a => [a]->布尔排序(x:y:ys)= x< = y&& sorted(y:ys)sorted _ = True-一个(false)属性,说明每个列表都已排序prop_sorted :: [Int]-> Bool prop_sorted xs =排序的xs

> verboseCheck prop_sorted已通过:[]已通过:[]已通过:[0]失败:[2,1,3]已通过:[]已通过:[1,3]已通过:[2,3]已失败:[2,1]。 .. ***失败!伪造的(经过4次测试和3次收缩):[1,0]

生成的第一个未排序的列表是[2,1,3]。请注意,由于它是随机生成的,因此每次运行QuickCheck时,它都会是一个不同的列表。但是,QuickCheck并不止于此,而是尝试越来越小的列表,直到收敛到最小的反例:[1,0]。此过程称为收缩。

值得注意的是,尽管QuickCheck具有固有的随机性,但缩小通常会收敛到少数最小反例中的一个。例如,如果我们对prop_sorted多次运行quickCheck,则总是以[1,0]或[-1,0]作为反例。

QuickCheck用于缩小反例的精确策略取决于反例的类型:

对于Int这样的数字类型,QuickCheck将尝试使用绝对值较小的随机数(即接近0)。

对于列表类型,QuickCheck将尝试从列表中删除随机元素,或尝试缩小列表中的值之一。

除了可以从GHCi运行单个测试之外,您还可以将所有测试合并到一个主要函数中:

该代码利用了我们将在有关monads的章节中研究的Haskell do关键字。一旦定义了这个主要功能,就可以通过从命令行调用runghc来调用它:

> runghc QuickcheckExamples.hs +++确定,通过了1个测试。 +++ OK,通过了100次测试。 +++ OK,通过了100次测试。

请注意,一个文件只能包含一个主要功能。在实际的项目中,我们将创建一个单独的文件,该文件仅定义所有QuickCheck属性并将它们放到主函数中。

备注。在本课程的WebLab实例中编写代码时,无需编写QuickCheck测试的主要功能:WebLab将自动在“测试”选项卡中收集所有功能,其名称以prop_开头,并在每个功能上运行quickCheck。

有效使用QuickCheck的最大挑战在于提供良好的属性进行测试。因此,让我们看一些要测试的良好属性的示例。

当一个函数与另一个函数相反时,我们可以为此创建一个属性测试。例如,我们可以测试反向列表是其自身的逆:

再举一个例子,我们可以测试将一个元素插入到列表中,然后再次删除它会得到相同的列表:

插入:: Int-> [Int]-> [Int]插入x [] = [x]插入x(y:ys)| x< = y = x:y:ys |否则= y:insert x ys delete :: Int-> [Int]-> [Int]删除x [] = []删除x(y:ys)| x == y = ys |否则= y:删除x ys prop_insert_delete :: [Int]->整数-> Bool prop_insert_delete xs x =删除x(插入x xs)== xs

通常,可能需要两个以上的函数才能回到我们的起点。 f(g(...(h x)))== x形式的任何属性都称为往返属性。

当我们有两个在功能上应该等效但具有不同实现的函数时,我们可以测试确实如此。例如,我们可以测试函数qsort :: Ord a => [a]-> [a],我们可以定义一个属性来测试它具有与内置的Haskell函数sort相同的行为:

如果有可供选择的实现,则定义此类属性通常是一个好主意,因为它会在实现中捕获大量错误。

当您用新版本替换函数的实现时(可能是因为它更有效或更通用),可以应用此技术的另一种变体。在这种情况下,可以将旧代码保留为不同的名称,并添加一个属性以测试两个实现产生相同的结果。这样,您可以确保程序的行为没有意外更改!

警告。在测试诸如qsort之类的多态函数时,定义一个多态测试用例似乎很有吸引力:

prop_qsort_sort' :: Ord a => [a]->布尔prop_qsort_sort' xs = qsort xs ==排序xs

但是,这并不像您期望的那样起作用:默认情况下,quickCheck会将所有类型参数实例化为空元组类型()。因此quickCheck将实际测试的属性是prop_qsort_sort'' :: [()]->布尔由于空元组[[),(),...,()]的列表总是被排序的,因此即使函数qsort不执行任何操作,此测试也将始终返回True!因此,总的来说,始终向属性测试提供具体类型(不带类型参数)是一个好主意。

当我们有一个函数可以实现代数中的某个特定概念时,我们可以使用代数定律作为属性测试的灵感。例如,假设我们有一个函数vAdd ::(Int,Int)-> (Int,Int)-> (Int,Int)定义二维向量上的加法,我们可以测试它是可交换的,关联的并且具有中性元素:

prop_vAdd_commutative ::(Int,Int)-> (Int,Int)->布尔prop_vAdd_commutative v w = vAdd v w == vAdd w v prop_vAdd_associative ::(Int,Int)-> (Int,Int)-> (Int,Int)-> Bool prop_vAdd_associative u v w = vAdd(vAdd v)w == vAdd u(vAdd v w)prop_vAdd_neutral_left ::(Int,Int)-> Bool prop_vAdd_neutral_left u = vAdd(0,0)u == u prop_vAdd_neutral_right ::(Int,Int)->布尔prop_vAdd_neutral_right u = vAdd u(0,0)== u

再举一个例子,诸如排序函数之类的某些函数是幂等的:两次应用它们产生与一次应用相同的结果。

通常,我们想测试某个属性,但它并不适用于所有可能的输入。例如,让我们尝试测试复制n x的结果中的每个元素是否等于x(在标准序言中定义了函数copy :: Int-> a-> [a])。

prop_replicate :: Int->整数->整数->布尔prop_replicate n x i =复制n x !! i == x

不好了! QuickCheck生成了一个长度为0的列表,这意味着任何索引i都超出范围。要解决此问题,我们可以更改属性,以便在索引超出范围的情况下始终将其评估为True:

prop_replicate :: Int->整数->整数->布尔prop_replicate n x i = i< 0 ||我> = n ||复制n x! i == x

但是,这实际上给了我们一种错误的安全感:在生成的绝大多数测试用例中,索引超出范围,因此没有测试任何有关复制行为的内容。您可以通过在此属性上运行verboseCheck并计算i在0到n之间的情况来验证这一点。实际上,要测试的输入少于100个,因此很可能会发现错误。

我们可以尝试通过运行更多的测试用例来抵消这种情况。但是,多少个测试用例就足够了?我们需要1000吗? 10000?如果我们要测试一个属性(如果该属性仅适用于已排序的列表),而该属性甚至很少会生成有效的输入,该怎么办?

我们可以确保生成的测试案例始终有效,而不是运行更多的测试案例。我们将讨论QuickCheck库提供的两种方法,它们可以使我们做到这一点:条件属性和定量属性。

我们可以限制QuickCheck使用的输入的第一种方法是使用表单的条件属性

prop_replicate :: Int->整数->整数->属性prop_replicate n x i =(i> = 0& i< n)==>复制n(x :: Int)!! i == x

请注意,返回类型不再是Bool而是Property,这是QuickCheck引入的新类型。幸运的是,使用QuickCheck不需要对这种类型了解太多。我们需要知道的是,我们可以使用quickCheck来测试返回属性的函数,就像返回布尔的函数一样!

刚刚发生了什么? QuickCheck测试属性复制n(x :: Int)! i == x像以前一样,但是现在它丢弃了不满足条件i> = 0&&的所有测试用例。我< 。在输出中,我们可以看到它总共生成了795个测试用例,其中695个被丢弃,其余100个成功通过。

当随机生成的输入有可能满足条件时,条件属性会很好地起作用。但是,当有效输入很少时,它就会崩溃。例如,让我们尝试测试将元素再次插入有序列表会导致有序列表:

prop_insert_sorted :: Int-> [Int]->属性prop_insert_sorted x xs =排序的xs ==>排序(插入x xs)

这里的教训是,当极不可能满足条件时,我们不应使用条件属性。

代替先生成随机值然后过滤掉无效的值,我们可以使用其他随机生成器,该生成器仅首先生成有效输入。为此,QuickCheck允许我们使用Gen a类型的自定义随机数生成器来定义量化属性。例如,我们可以使用生成器orderedList生成随机的有序列表。我们可以使用forAll函数使用随机数生成器来定义属性:

prop_insert_sorted :: Int->属性prop_insert_sorted x = forAllorderedList(\ xs->已排序(插入x xs))

forAll的第一个参数是随机生成器。第二个参数是一个将生成器的结果映射到我们要测试的属性的函数(换句话说,forAll是一个高阶函数)。在这里,我们以lambda表达式\ xs->的形式给出了该函数。排序(插入x xs)。请注意,列表xs不再是整体属性prop_insert_sorted的输入,因为它已经是lambda表达式的参数。现在,如果再次运行quickCheck,我们将看到测试通过:

您还可以使用verboseCheck来验证所有生成的列表是否确实已排序(执行!)。

QuickCheck提供了一长串随机生成器,我们可以使用它们来测试属性。以下是一些有用的示例:

生成器选择将在两个边界之间选择一个随机元素。例如,选择(1,6)将生成一个介于1和6之间的随机数(包括两个边界)。

生成器元素将从给定的值列表中选择一个随机元素。例如,元素[' a',' e',' i',' o',' u']将生成一个随机的元音。

发生器频率就像元素一样工作,但是允许您权衡每个选项,确定它产生的可能性。例如,频率[(99,True),(1,False)]将产生99%的时间为True,而1%的时间为False。

生成器矢量生成给定长度的列表。例如,向量42生成长度为42的随机列表。

生成器混洗生成具有与给定列表相同元素的列表,但顺序随机。例如,shuffle [1..10]生成包含数字1到10的随机列表。

您可以通过查看模块Test.QuickCheck找到许多其他生成器。另外,可以定义自己的随机生成器,但这超出了本介绍的范围。

如果您想使用这些随机生成器,可以使用GHCi中的函数样本通过给定的生成器生成几个随机值:

除了能够生成基本类型(例如整数,布尔值和列表)的值之外,QuickCheck还可以生成可用于编写测试的随机函数。但是,由于与收缩有关的技术原因,QuickCheck无法直接生成类型为String-> gt;的元素。布尔取而代之的是,QuickCheck引入了具有两个参数a和b的新(通用)类型Fun a b,以及函数applyFun :: Fun a b->。 -> b。

例如,我们可以测试任何函数p :: Int->布尔,对于列表[x | x<-xs,p x]:

prop_filter :: Fun Int Bool-> [Int]->属性prop_filter p xs =-不满足p的过滤元素。令ys = [x | x<-xs,applyFun p x]-如果在ys中遗留了任何元素... / = [] ==> -...生成一个随机索引i ... forAll(选择(0,长度ys-1))-...并测试p(ys !! i)是否成立。 (\ i-> applyFun p(ys !! i))

作为另一个(愚蠢的)示例,让我们尝试测试类型为String-> gts的每个函数的属性。 Int在" banana&#34 ;、" monkey"和" elephant"的三个值中的至少两个值上产生相同的结果:

prop_bananas :: Fun String Int-> Bool prop_bananas f = applyFun f" banana" == applyFun f" monkey" || applyFun f"香蕉" == applyFun f" elephant" || applyFun f" monkey" == applyFun f" elephant"

由QuickCheck映射" banana"生成的函数{" banana"-> 0," elephant"-> 1,_-> 2}到0,&elephant&

......