百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程网 > 正文

Haskell入门教程:6 快速检查和随机测试

yuyutoo 2024-12-01 06:09 3 浏览 0 评论


本章涵盖

  • 使用随机值
  • 构建小型属性测试框架
  • 使用流行的测试框架快速检查
  • 为特殊测试用例定制随机值生成器
  • 为软件项目配备测试套件

前面的章节已经讨论了为各种应用程序编写Haskell代码。我们忽略了一个问题:我们编写的代码是否正确?它做正确的事情吗?我们的算法是否产生了正确的结果?在软件工程中,这个问题通常通过测试来回答,这就是我们在本章中要探讨的内容。

为了熟悉测试,我们介绍了流行的测试库QuickCheck,并在Haskell的上下文中探索属性测试,学习如何形式化纯函数式代码的正确性属性。

本章将首先介绍属性测试的概念,之后我们将实现我们自己的测试框架。同时,我们将学习如何在 Haskell 中使用随机值。然后,引入了快速检查。本章介绍如何编写属性、使用任意类型类以及如何根据自己的喜好修改测试行为。本章将展示如何将QuickCheck测试套件合并到Stack项目中。

6.1 如何测试

测试可以在不同的级别进行。集成测试着眼于不同软件组件的相互作用,系统测试检查是否满足已完成系统的要求,而单元测试则更多地关注较小的代码“单元”,这些代码“单元”的行为是单独测试的。 这使我们能够编写自包含的测试,以验证较小的单个组件的正确功能。如图 6.1 所示。

图 6.1.测试金字塔


集成和系统测试的有效性取决于单元测试已经暗示的正确性。如果我们不能确定系统中最小的组件是否按预期工作,那么从外部测试软件是没有用的。因此,我们试图通过使用测试在我们的软件中获得的信任取决于最深层次的独立测试的彻底性。在本章中,我们将重点介绍单元测试和为单个函数构造这些测试。

但是,这就提出了我们如何测试代码的问题。编写测试的一种简单方法是基于示例的,这意味着我们为代码指定某些输入,并测试运行它是否产生预先确定的结果。虽然这是一种简单的测试方法,但它存在许多缺陷:

  • 每个测试用例都必须手工制作
  • 测试覆盖率与编写的测试用例数量相关
  • 必须手动指定用于测试的边缘情况

这很容易出错。如果我们能够想到一个完整的测试集,那么每次编写代码时,我们无论如何都不需要指定测试。最后,我们只是为我们已经知道运行良好的功能编写测试用例(特别是如果测试的设计者也实现了测试的功能)。这样,测试就是防止回归的保险。我们很少做的是在测试时发现错误

这就是属性测试发挥作用的地方。当我们将正确的功能形式化为一个数学属性时,对于输入和输出应该是真的,我们可以通过随机生成输入并简单地检查每个输入的属性来测试我们的代码。这样,我们发现错误的机会更高,因为我们不必指定测试输入。

6.1.1 嬉皮士,霍佩蒂,什么是财产?

首先,让我们考虑一下我们在谈论属性时的意思。属性是可以计算和验证的数据特征。简单地说,接收一些参数并返回布尔值的函数可以被视为一个属性。当使用它们来测试软件功能时,它们可能会变得任意复杂。我们可以对基本值进行相当简单的比较,检查数据是否符合某个规则集,或者使用任何其他属性的组合来构造一个新规则集。基于相等的测试,其中某些代码的输出被检查为具有预定义值的相等性,这是一种非常简单的属性形式。

让我们首先提供一个示例来开始探索这种测试技术。我们从一个新项目开始 堆栈 .假设我们要测试某种排序函数。此类函数的属性可以简单地说,该函数的输出始终必须是排序列表。我们可以构造一个简单的函数来检查某个列表是否被排序(按升序),并构造一个函数来检查一个函数是否根据某个输出生成一个排序列表。如清单 6.1 所示。

清单 6.1.列表排序的谓词

sorted :: Ord a => [a] -> Bool
sorted [] = True #1
sorted [x] = True #1
sorted (x : y : xs) = x <= y && sorted (y : xs) #2

sorts :: Ord a => ([a] -> [a]) -> [a] -> Bool
sorts f input = sorted $ f input #3

现在我们可以使用 sort 属性来测试某些输入上的函数。排序是一个属性,而排序测试给定输入上的排序属性函数。让我们用它测试来自 Data.List 的排序函数。

ghci> import Data.List
ghci> sort `sorts` [5,4,3,2,1]
True
ghci> sort `sorts` [1,3,2,5,4]
True
ghci> sort `sorts` []
True

似乎效果很好!但是,对于构建测试套件,我们对自己写下这样的测试用例不感兴趣。我们想随机化输入,但随机性将要求我们离开纯函数的世界并创造其他东西。

6.1.2 随机做

由于属性测试是一种随机测试技术,我们现在将花费大量时间处理随机输入的生成。重要的是,不仅要支持存在随机生成器的各种类型,还要支持它们的修改。为复杂数据类型构建新的生成器应该很简单,需要一个好的框架来促进这一点。

现在,我们有兴趣为排序的属性测试创建随机列表。随机值是我们尚未接触的东西,在像 Haskell 这样的语言中也有些特殊,仅仅是因为随机生成器需要跟踪状态以计算新的随机数。在有副作用的语言中,随机值很容易实现(使用一些全局管理的状态),但在Haskell中,这有些不同。函数输出由其输入决定!

那么随机值是如何工作的呢?为了感受它,我们想先检查一个小例子。假设想要创建一个生成随机值的函数,我们知道我们必须输入一些随机状态才能返回随机值。但是,之后需要返回新的随机状态。否则,相同的随机状态将用于连续的函数调用。如果状态被纯函数修改(例如,执行伪随机变换),我们可以在之后使用新状态。当反复将状态循环回函数时,我们可以创建一个随机值序列。如清单 6.2 所示。

清单 6.2.显式处理随机状态的函数

newtype RandomState = RandomState Int deriving (Eq, Show) #1

randomInt :: RandomState -> (Int, RandomState)
randomInt (RandomState rs) = (newRs, RandomState newRs) #2
  where
    newRs = (1103515245 * rs + 12345) `mod` (2 ^ 31) #3

randomIntList :: RandomState -> Int -> [Int]
randomIntList rs n
  | n <= 0 = [] #4
  | otherwise =
    let (v, rs') = randomInt rs #4
     in v : randomIntList rs' (n - 1) #4

randomInt 函数使用线性全余生成器来计算新的伪随机值。通过将状态反复馈送到此函数中,我们可以创建随机值流。这就是 randomIntList 用来创建随机整数列表的方法。

ghci> randomIntList (RandomState 100) 5
[829870797,1533044610,1478614675,1357823696,413847241]

虽然这是生成随机数的一种方法,但它不够通用。我们需要能够描述数字范围之外的随机值。此外,我们实际上不必自己实现任何这一点,因为它已经存在。我们可以使用 System.Random 模块中的各种函数来获取不同类型的随机值。为此,我们需要将随机添加到 package.yaml 中的依赖项中。

System.Random 模块为我们提供了大量的函数、类型和类型类来生成随机值。但是,它的某些部分已弃用,不应使用。最现代的方法(在撰写本文时)可以在System.Random.Stateful模块中找到。它提供了对许多不同类型的随机生成器的访问以及与它们交互的方式,但我们只会处理 StdGen ,这是一个典型的伪随机生成器。

6.1.3 随机性标准

使用StdGen与我们之前创建的RandomState非常相似:

ghci> import System.Random
ghci> import System.Random.Stateful
ghci> g = mkStdGen 100
ghci> random g
(9216477508314497915,StdGen {unStdGen = SMGen 712633246999323047 2532601429470541125})
ghci> import System.Random
ghci> import System.Random.Stateful
ghci> g = mkStdGen 100
ghci> g
StdGen {unStdGen = SMGen 16626775891238333538 2532601429470541125}
ghci> random g
(9216477508314497915,StdGen {unStdGen = SMGen 712633246999323047 2532601429470541125})

如我们所见,我们可以创建一个带有种子的 StdGen(在本例中为 100),并使用随机函数生成一个由随机数和修改后的 StdGen 组成的元组。随机是从生成器创建随机值的四个重要函数之一:

  • 随机 :: (随机 a, 随机生成 g) => g -> (a, g)
  • randomR :: (Random a, RandomGen g) => (a, a) -> g -> (a, g)
  • 制服 :: (制服 a, 随机生成 g) => 克 -> (a, g)
  • uniformR :: (UniformRange a, RandomGen g) => (a, a) -> g -> (a, g)

随机生成随机值,其中类型的可能值的分布未知,而统一生成给定类型的均匀分布的随机值。这就是两个函数的类型类不同的原因。这两个函数也有一个范围等价物,后缀为 R ,在给定范围内生成随机值。

由于随机和均匀都有不同值的许多实例,我们可以使用它来编写多态随机函数。我们不必专门为 Int 值或一般的数值编写它们。这样,我们可以概括随机值的列表。此外,我们可以使用生成器为我们的随机列表生成一个随机长度(有上限,因此它保持可管理)。如示例 6.3 所示。

清单 6.3.生成随机大小的随机列表的函数

randomListN :: (Random a) => StdGen -> Int -> ([a], StdGen)
randomListN gen n
  | n <= 0 = ([], gen)
  | otherwise =
    let (v, gen') = random gen #1
        (xs, gen'') = randomListN gen' (n - 1) #2
     in (v : xs, gen'')

randomList :: (Random a) => StdGen -> Int -> ([a], StdGen)
randomList gen max = randomListN gen' n #3
  where
    (n, gen') = uniformR (0, max) gen #4

randomList' :: (Random a) => StdGen -> ([a], StdGen)
randomList' = flip randomList 100 #5

就像之前使用 RandomState 一样,我们将修改后的生成器推进到递归函数调用以生成新的随机值。最后,我们返回修改后的生成器,以便可以进一步使用。但是,总是传递和返回这些生成器有些麻烦。我们可能想要做的是大多数编程语言的典型特征:使用全局随机值生成器。

6.1.4 全球随机

有状态的全局值在像 Haskell 这样的语言中似乎是不可能的,但幸运的是,System.Random 为我们提供了完全执行这两个函数的功能:

  • getStdGen :: IO StdGen
  • setStdGen :: StdGen -> IO ()

两者都允许在IO操作中访问和设置全局StdGen。全局 StdGen 也在程序启动时预先初始化。但是,这里有一个陷阱在等着我们!

ghci> g <- getStdGen
ghci> g
StdGen {unStdGen = SMGen 14856804714289562730 4490181912637266113}
ghci> random g
(-8399429758986648677,StdGen {unStdGen = SMGen 900242553217277227 4490181912637266113})
ghci> getStdGen
StdGen {unStdGen = SMGen 14856804714289562730 4490181912637266113}

getStdGen 不返回对全局生成器的引用,而是返回副本。因此,当我们修改它时,我们必须确保将修改后的生成器写回全局状态。

重要

全局资源可以在IORef的帮助下在IO操作中实现,就像getStdGen和setStdGen一样。这些是可变引用,可用于读取、存储和修改全局可访问的数据(在 IORef 是全局的情况下)。但是,该实现对于并发使用并不安全,因此不建议使用它,仅出于演示目的而显示。人们应该使用 applyAtomicGen 和 globalStdGen,如本章后面将要做的那样。

使用全局生成器,我们可以编写一个不需要 StdGen 作为参数的 IO 操作,并且通过首先检索全局生成器,然后应用 randomList 函数,然后在返回随机列表之前写回修改后的生成器来独立存在。其代码如示例 6.4 所示。

清单 6.4.生成随机列表的 IO 操作

randomListIO :: (Random a) => IO [a]
randomListIO = do
  g <- getStdGen #1
  let (xs, g') = randomList' g #2
  setStdGen g' #3
  return xs #4

现在,我们可以使用此操作生成不同类型的随机值,而不必担心提供或初始化随机生成器。

ghci> randomListIO :: IO [Bool]
[True,False,True,True,True,True,True,False,False,False,True,True,True, True,False,False,True,True,False,False,False,True,False,False,False, True,False,False,True,True,False,False,False,True,True,False,True, True,True,False,True,True,True,False,False,False,True,True,False, False,True,False,False,False,False,False,False,False,False,False, False,False,False,False,False,False,True,False,True,False,False,True, True,True,True,False,True,True,False,False,False,True,True,False, False,True,False]
ghci> randomListIO :: IO [Int]
[3691255068706279388,-2800187626527368700,3917553492683069837, 4227660806806667542,3268527085094962027,-5741538607687626546, 2150401921944371087,-94788782940232908,-7272037394668750655, 6615906112287910112,-7876828434837980022,-3967934342698301492, -8761894913785559922,3300716267348171691,2746716191340705260, 501540442220827077,7580997570714960595,4369396272232835442, 4464186059485273848,-1170916352399186521,-9175236237513240717, -3497815391797361407,-8229136869197540017,7239141001949992062, 6119772250924788401,-4616451576842552626,-3121301812412732082, 1811224167447568155,1914103012390535081]

但是,我们编写的代码使用了不推荐使用的函数,这些函数不应在并发的情况下使用。虽然许多在线教程和较旧的资源会教他们System.Random.Stateful提供了一个使用起来更安全的新界面。为此,我们必须使用类型AtomicGenM和globalStdGen,幸运的是,它们具有AtomicGenM StdGen类型。要使用像随机这样的函数,我们可以使用 applyAtomicGen 函数。

ghci> applyAtomicGen random globalStdGen :: IO Int
-8749052575918541620
ghci> applyAtomicGen (uniformR (0, 100)) globalStdGen :: IO Float
60.164364
ghci> applyAtomicGen randomList' globalStdGen :: IO [Bool]
[True,False,False,True,True,False,False,False,True,True,True,True,True, False,True,False,False,False,False,True,True,True,False,True,True, False,False,False,True,False,False,True,False,True,True,False,True, True,True,False,False,False,False,True,True,True,False,False,False, True,False,False,False,True,False,False,True,True,True]

此函数本质上执行的操作与我们对 随机列表IO .它读取全局生成器,在修改时执行生成,并将其写回全局引用。但是,它是原子方式这样做的,因此得名。这意味着在并发上下文中使用此生成器是安全的。由于 globalStdGen 是一个常数,我们可以编写一些方便的函数来更轻松地生成随机值。此函数如清单 6.5 所示。

清单 6.5.将函数应用于全局伪随机生成器的 IO 操作

applyGlobalStdGen :: (StdGen -> (a, StdGen)) -> IO a
applyGlobalStdGen f = applyAtomicGen f globalStdGen #1

现在,我们可以轻松(且安全地)在 IO 操作中生成随机值,这使我们能够编写一个简单的属性测试。

6.1.5 基本性能试验

构造属性测试的最后一部分是将所有部分放在一个 IO 操作中,该操作生成一定数量的测试用例并在其上测试给定函数,在找到计数器示例的情况下打印输入。如清单 6.6 所示。

清单 6.6.检查函数是否正确排序其输入的示例属性测试

propertyTestSorts :: ([Int] -> [Int]) -> Int -> IO ()
propertyTestSorts f n
  | n <= 0 = putStrLn "Tests successful!" #1
  | otherwise = do
    xs <- applyGlobalStdGen randomList' #2
    if f `sorts` xs #3
      then propertyTestSorts f $ n - 1 #4
      else putStrLn $ "Test failed on: " <> show xs #5

我们可以完全推广属性测试的概念,方法是使要测试的函数、测试函数的谓词和生成随机值的 IO 操作成为参数。其代码如示例 6.7 所示。

清单 6.7.通用属性测试操作

propertyTest :: Show a => (a -> b) -> (b -> Bool) -> IO a -> Int -> IO ()
propertyTest fun predicate random n
  | n <= 0 = putStrLn "Tests successful!" #1
  | otherwise = do
    testCase <- random #2
    if predicate $ fun testCase #3
      then propertyTest fun predicate random $ n - 1 #4
      else putStrLn $ "Test failed on: " <> show testCase #5

在这里,我们得出了一个非常通用的包装器的非常通用和可用的定义,我们可以用来构造各种属性测试。让我们做一个微不足道的假设,即排序函数确实排序而 id 没有排序:

ghci> propertyTest sort sorted (applyGlobalStdGen randomList' ::  IO [Int]) 100
Tests successful!
ghci> propertyTest id sorted (applyGlobalStdGen randomList' ::  IO [Int]) 100
Test failed on: [8872,9320,331,9174,7295,7971,7136,4134,7692,1235,8030, 3595,9078,4121,2722,23,3092,998,1005,2172,795,8495,1960,7359,7353, 16,3706,4893,6819,3984,7179,8933,8109,951,6330,542,4480,2864,2411, 2531,8971,2380,4653,1649,224,3466,5091,1834,5436,2803,7251,445,2795, 8274,7546,3241,3971,3624,955,6299,7857,1152,5028,2899,686,355,6695, 7098,6713]

现在,我们对如何实现属性测试有一个非常基本的了解,让我们讨论一下我们的方法的一些问题:

  1. 我们必须为我们要测试的每种数据类型构建一个专用的随机值生成器
  2. 执行测试时需要显式定义和引用每个随机值生成器
  3. 测试不允许在完成测试之前必须保留的输入数据的前提条件
  4. 测试没有指示成功或失败的结果

精明的读者可能会很快看到第一个问题的解决方案:类型类!第二个问题可以通过提供可组合的随机值生成器来解决,这意味着我们有某种方法可以获取现有的生成器并从中创建新的生成器。让我们专注于这一点。

6.1.6 封装熵

与第 5 章中显示的默认值非常相似,System.Random 模块已经为我们提供了 Random 类型类,该类可以与我们已经看到的 StdGen 一起使用以生成随机值。

ghci> applyGlobalStdGen random :: IO Int
-7583503057972408353
ghci> applyGlobalStdGen random :: IO Bool
False
ghci> applyGlobalStdGen random :: IO Double
0.5396729243902469

Haskell将根据我们要测试的类型自动使用它的正确实现。这是返回类型多态性的一个示例。就像上一章中的 HasDefault 类型类一样,正确的实现是根据调用站点上预期的类型选择的。函数的调用方可以选择类型!虽然许多其他语言具有参数多态性,但类型的选择通常发生在创建值时,而不是在消耗值时发生。

随机类型类已经具有许多基本类型的实例。但是,列表没有。此外,类型类不允许我们轻松地组合生成器,例如通过为生成的值提供前提条件。为了解决这些问题,我们希望将通用 IO 操作包装为表示结果是随机生成的新类型。为此,我们创建了一个新类型,其中唯一的字段是 IO 操作。然后,我们可以将以前的 randomListIO 函数重写为更通用的版本。如清单 6.8 所示。

清单 6.8.一种用于构造随机值生成器的类型和智能构造函数

newtype RandomIO a = RandomIO {runRandomIO :: IO a} #1

one :: Random a => RandomIO a
one = RandomIO $ applyGlobalStdGen random #2

some :: Random a => RandomIO [a]
some = RandomIO $ do
  n <- applyGlobalStdGen $ uniformR (0, 100) #3
  replicateIO n $ runRandomIO one #3

该类型称为 RandomIO,因为它封装了一个能够生成随机值的 IO 操作。 一个和一些是智能构造函数,能够为具有随机类型类实例的类型生成 RandomIO 值。 replicaIO 是一个函数,用于将 IO 操作复制指定的次数并将结果聚合到列表中。它的代码如清单 6.9 所示。

清单 6.9.将 IO 操作复制指定次数的聚合函数

replicateIO :: Int -> IO a -> IO [a]
replicateIO n act
  | n <= 0 = return [] #1
  | otherwise = do
    x <- act #2
    xs <- replicateIO (n - 1) act #3
    return $ x : xs #4

现在,RandomIO 可以通过访问 runRandomIO 字段,然后简单地像这样调用 IO 操作来嵌入到任何 IO 操作中。

ghci> runRandomIO one :: IO Float
0.12762898
ghci> runRandomIO some :: IO [Bool]
[True,True,True,True,False,False,False,False,False,False,False,True,False, True,False,False,True,False,True,True,True,True,False,True,False, False,True,False,True,False,True]

同样,使用返回类型的多态性,我们可以使用 RandomIO 生成不同类型的值。但是,出于测试目的,也许我们希望更精确地指定应该生成哪种值。

注意

当操作或计算被包装到 newtype 中时,新构造函数中的字段通常具有这样的运行前缀。由于记录仅包含一个字段,因此可以单独使用构造函数包装操作(无需指定字段)。通常,这使代码更易于阅读。

为此,我们可以编写一个函数,通过过滤我们不希望看到生成的值来修改现有的 RandomIO。通过这种方式,我们可以使用指定随机值外观的函数来组合我们的智能构造函数。为此,创建一个函数,该函数无限期地重新运行随机生成器,直到满足某些谓词。使用它,我们已经可以为非负值和非空值形成新的生成器。如清单 6.10 所示。

示例 6.10.修改随机值生成器输出的函数

suchThat :: RandomIO a -> (a -> Bool) -> RandomIO a
suchThat rand pred = RandomIO $ do #1
  val <- runRandomIO rand #2
  if pred val #3
    then return val #3
    else runRandomIO $ suchThat rand pred #3

nonNegative :: (Num a, Ord a, Random a) => RandomIO a
nonNegative = one `suchThat` (> 0) 

nonEmpty :: Random a => RandomIO [a]
nonEmpty = some `suchThat` (not . null)

这样这是一个很好的例子,说明我们如何解开 RandomIO 以在 IO 操作中使用它,然后通过使用构造函数包装整个 do-block 将其放回 RandomIO。使用它,我们可以为人类可读的字符串值创建一个生成器。可悲的是,随机字符的默认实例会产生许多我们无法打印的字符。因此,我们可以使用 isAscii 和 isControl from Data.Char 来检查字符串中的字符是否可打印且人类可读。使用 all 函数,我们可以检查该谓词是否适用于 字符串 的所有元素 .但是,运行此生成器会给我们带来一个问题。

ghci> import Data.Char
ghci> asciiString = some `suchThat`  all (\c -> isAscii c && not (isControl c))
ghci> runRandomIO asciiString
""
ghci> runRandomIO asciiString
""
ghci> runRandomIO asciiString
""

这是怎么回事?事实证明,我们给出的谓词非常具体,以至于它更有可能只生成一个空列表,而不是具有正确值的列表。这是因为 Char 值仍然是随机生成的。只要一个错误的夏亚就让我们重蹈整整一代的考验!

锻炼

这样这或多或少就像我们随机生成器上的过滤器。我们尚未构建的等价于随机值的映射函数。为了使事情尽可能通用,我们可能希望为 RandomIO 的函子类型类实现一个实例,其中 fmap 将一些函数应用于随机值。实现此实例!

幸运的是,有一种方法可以解决这个问题,首先为人类可读的 Char 值创建一个生成器,然后使用此生成器从中构建 asciiString 生成器。为此,我们将自己限制为 ASCII 字符。普通(非扩展)字符是人类可读的或控制字符,因此在另外检查字符是否为 ASCII 时将它们过滤掉就足够了。为了从 Char 生成器构建字符串,我们创建了一个函数,该函数的行为与某些函数类似,但使用作为参数传递的生成器而不是 randomIO .如清单 6.11 所示。此外,我们可以以这种方式为仅包含字母的字符串值创建一个生成器。

清单 6.11.用于随机 ASCII 字符和字符串的生成器

asciiChar :: RandomIO Char
asciiChar = one `suchThat` (\c -> isAscii c && not (isControl c)) #1

letterChar :: RandomIO Char
letterChar = asciiChar `suchThat` isLetter #2

manyOf :: RandomIO a -> RandomIO [a]
manyOf rio = RandomIO $ do #3
  n <- applyGlobalStdGen $ uniformR (0, 100) #3
  replicateIO n (runRandomIO rio) #3

asciiString :: RandomIO String
asciiString = manyOf asciiChar #4

letterString :: RandomIO String
letterString = manyOf letterChar #4

现在,我们可以生成随机字符串值用于测试目的!

ghci> runRandomIO asciiString
"N:=P!RVyo7O{=QU6"
ghci> runRandomIO asciiString
"&q~,=Dm:"
ghci> runRandomIO letterString
"EdgcdiBHQcwIJAkCKlhemiaUWBMeNxrHOkWigRuktokfMyxXzYklFBO"
ghci> runRandomIO letterString
"uEETBtLDCIneVmHFNvICxPLuEwSoSnj"

我们现在能够修改我们的 propertyTest 函数,不仅使用我们的 RandomIO 作为生成器,而且还简化函数,只使用返回 Bool 的单个谓词来确定测试的正确性。这将允许我们测试更复杂的情况,在这些情况下,我们希望观察一些更一般的计算的正确性,而不仅仅是观察某个函数的输出。完成的函数如清单 6.12 所示。

示例 6.12.使用自定义随机值生成器进行属性测试的操作

propertyTest :: Show a => (a -> Bool) -> RandomIO a -> Int -> IO ()
propertyTest predicate random n
  | n <= 0 = putStrLn "Tests successful!" #1
  | otherwise = do
    testCase <- runRandomIO random #2
    if predicate testCase #3
      then propertyTest predicate random $ n - 1 #3
      else putStrLn $ "Test failed on: " <> show testCase #3

现在,我们已经成功地构建了随机生成器和辅助器来构造和修改它们,我们终于可以使用 propertyTest 函数来测试我们之前编写的一些代码,看看我们将如何表现。

锻炼

我们定义letterString的方式效率非常低。与其从一组想要的 Char 中随机选择,我们宁愿从一个巨大的集合中进行选择,然后通过我们不想看到的无效元素进行过滤。为了使它更有效率,也更通用,编写一个函数,该函数接收一个非空的元素列表,随机选择一个。然后,使用此函数构建新的 ASCII 和字母生成器,这些生成器这次速度更快。

elements :: [a] -> RandomIO a
elements [] = error "elements cannot work with an empty list!"
elements xs = ...

6.2 剪裁测试

在我们对随机值生成器进行了广泛的研究之后,我们现在可以更多地考虑如何正确处理属性。这具体意味着,我们需要弄清楚如何编写属性,使我们在程序的正确性上安全。

在我们开始之前,我们应该构建我们的项目。为此,我们将到目前为止创建的所有测试代码放入一个名为 测试.简单检查 .这将作为我们的测试框架。完成此操作后,我们可以开始将第 2 章中 Caesars 密码项目的代码复制到我们的源代码中,然后我们可以开始对其进行编写测试。

我们应该在代码上测试什么样的属性?当然,这在很大程度上取决于软件的规格。在我们的例子中,我们只想确保我们关于代码的核心假设成立。我们所做的一个假设是,应用 ROT13 密码两次将产生原始值。因此,ROT13 是对称的。这可以在我们的测试框架中表达。该函数的属性是,对于任何输入 s,它必须保持 s == rot13 (rot13 s) 。这可以通过一个函数简单地表达,我们可以插入我们的属性测试函数!如示例 6.13 所示。

示例 6.13.ROT13 函数对称性的属性检验

propRot13Symmetrical :: IO ()
propRot13Symmetrical =
  propertyTest
    (\s -> s == rot13 (rot13 s)) #1
    asciiString #2
    100 #3

为此属性选择随机值生成器很重要!在第2章中,我们说对称性质适用于字母。但是,在此测试中,我们正在测试更一般的情况。不仅要评估字母,还要评估所有人类可读的ASCII字符。因此,此测试不仅测试 rot13 是否对称,还测试此对称属性相对于输入中的 Char 值是否可靠。幸运的是,我们以这种方式构建 rot13,因此测试成功!

ghci> propRot13Symmetrical
Tests successful!

我们刚刚看到的测试在某种程度上是一个规范测试。ROT13 是对称的是我们规范的一部分。此属性可确保完成的函数 rot13 正确实现此规范。现在,我们可以愉快地重构 rot13 和所有底层机制,而不必担心破坏规范。如果我们做错了什么,测试有望告诉我们!

注意

最好为代码中的属性测试指定一个特殊名称。在某些代码库中,此类函数以 prop 甚至 prop_ 为前缀(尽管某些 linters 不赞成在标识符中使用下划线)。当代码库包含属性测试以及基于示例的单元测试时,有时可以看到后一种情况带有测试或test_前缀的函数。

我们可以添加更多测试,更彻底地测试我们构建的帮助程序函数。当然,对于这个项目来说,编写测试套件不仅仅是矫枉过正,但它是熟悉这种测试技术的好机会。为此,我们希望更仔细地检查一些帮助程序函数,看看是否可以验证它们的功能。

首先,让我们研究一下字母腐烂函数。它的目的是通过字母表旋转Char。然后必须保留的一个属性是,在旋转之后,从函数返回的新 Char 必须是原始字母表的元素。这是我们可以测试和验证的东西!但是,此函数的输入比我们之前看到的要复杂一些。该函数接收一个字母表(它是字符串的类型别名),一个Int和一个Char,它们需要成为第一个参数的元素。正如我们已经看到的,我们没有准备好的发电机,可以提供。但是,我们可以专门为此测试创建一个新表,这将创建一个非空字母表,并专门选择 Char 参数作为该创建字母表中的元素。其代码如示例 6.14 所示。

清单 6.14.字母旋转函数闭合性的属性检验

propAlphabetRotClosed :: IO ()
propAlphabetRotClosed = propertyTest prop gen 100 #1
  where
    prop (alphabet, n, c) = #2
      let c' = alphabetRot alphabet n c #2
       in c' `elem` alphabet #2

    gen = RandomIO $ do
      alphabet <- #3
        runRandomIO $ #3
          asciiString `suchThat` (not . null) #3
      n <- runRandomIO $ elements [-100 .. 100] #4
      c <- runRandomIO $ elements alphabet #5
      return (L.nub alphabet, n, c) #6

在此测试中,发电机完成所有繁重的工作,为测试提供合理的基础。在将参数传递给测试之前,生成器会过滤字母表中的重复项,因为我们可以假设它们不包含任何元素两次。重要的是,Char 参数是从随机字母表中随机选择的。这意味着,即使相同的随机字母表被生成两次,也很有可能从中选择另一个 Char。

6.2.1 无效果的有效性

我们想简要介绍一下为什么属性测试在Haskell中特别有效。到目前为止,我们已经利用了语言的一个非常重要的方面,以使我们的测试有用:Haskell是纯粹的。这意味着我们编写的函数本质上没有副作用。函数的结果不会因输入之外的任何内容而改变。这意味着对输入和输出的简单观察足以确定函数的正确性,因为系统中没有其他输出或行为可以观察。此外,为函数设计一个随机值生成器就足够了,因为我们必须设置人工环境才能使测试有效。检验有效性仅由正确选择随机值和对所测属性的正确解释来确定。

作为一个例子,让我们看一下第2章中的isMisc函数。这是一个简单的函数,用于确定字符不是字母还是数字。此外,我们的项目中还有其他一些谓词(isLower , isUpper, isDigit),用于确定字符是小写字母、大写字母还是数字。暗示 isMisc 的计算结果为 True,当且仅当其他谓词的计算结果均未为 True 时。在编写我们的凯撒密码时,我们只是假设这是真的。但是,它可以很容易地通过属性测试进行测试,如清单 6.15 所示。

清单 6.15.用于检查字符谓词之间的不变性的属性测试

propIsMiscIsDefault :: IO ()
propIsMiscIsDefault =
  propertyTest
    (\c -> isMisc c == not (isLower c || isUpper c || isDigit c)) #1
    one #2
    10000 #3

所测试的属性是简单的谓词,用于检查 isMisc 与逻辑项的等效性。同样,多亏了哈斯克尔的性,可以通过一个简单的 == 来检查项的等价性。重要的是要注意,这些术语仅在观察上是等价的。它们为给定输入生成相同的输出,但它们确实具有不同的技术行为和不同的内存占用。但是,这很好,因为在纯代码中,当涉及到程序正确性时,观察等价性才是最重要的。我们的函数仅由它们的输入控制,它们唯一能做的就是计算一些输出。

现在,我们已经掌握了一些属性测试,我们已经准备好通过从我们自己的小框架升级到一个真正的强大的测试:QuickCheck来成熟我们的测试方法。

6.3 快速检查利润

我们从开发自己的小测试框架SimpleCheck开始了这一章。但是,它面临着一些难以忽视的问题:

  • 构建随机值生成器既复杂又费力
  • 很难组合属性
  • 无法深入了解被测数据(正在测试哪种值或测试频率)
  • 不可能通过给出前提条件来修改测试
  • 我们的随机值生成器具有固定的行为,无法轻易更改
  • 失败的测试用例没有被最小化,可能会变得很大且难以理解

这些问题将使我们的框架难以在现实世界中使用。但是,所有这些问题都有解决方案。一个名为QuickCheck的测试框架。QuickCheck最初由Koen Claessen和John Hughes于1999年在查尔姆斯理工大学开发,它普及了属性测试和重新实现的概念,存在于从函数式(OCaml,Clojure,Scala)到非函数式(C++,Java,TypeScript)语言的各种语言中。它的设计非常像我们的SimpleCheck框架。它提供

  • 用于测试属性的函数
  • 用于组合和修改属性的组合器
  • 用于调试测试行为的帮助程序功能
  • 用于随机值生成的类型和类型类

现在,我们想回顾一下这个库,并在深入研究一些属性测试案例研究之前快速概述其功能。

在开始之前,让我们看看如何将 QuickCheck 合并到我们的项目中。由于QuickCheck位于Stackage存储库中,因此我们可以简单地将其添加到依赖项下的package.yaml文件中。我们要添加行 - QuickCheck >= 2.0 以强制 QuickCheck 的版本为 2.0 或更高版本。在构建应用程序或启动 REPL 时,堆栈将自动解析此依赖项。

现在我们知道了如何运行框架,让我们来概述一下。让我们首先回顾一下快速检查中最基本的概念。

6.3.1 从布尔值到属性

第一个是最简单的构建块:快速检查功能。它接收一些属性作为其输入并为我们进行测试,从而创建一个 IO 操作。这种属性的两个重要实例是 Bool 和 QuickCheck 的类型 属性 。此外,这样的属性也可以是返回属性的函数!因此,返回 Bool(如简单谓词)的函数是一个属性。

注意

在内部,属性必须具有可测试类型类的实例。此类定义如何将某种类型转换为属性。这里不会介绍它,因为我们不需要为它构建自定义实例,但是当在 QuickCheck 文档中的某处遇到可测试的 prop 时,考虑“属性”会有所帮助。

让我们像之前一样通过验证 rot13 函数的对称性来测试这一点:

ghci> import Test.QuickCheck
ghci> prop_rot13Symm s = s == rot13 (rot13 s)
ghci> :t prop_rot13Symm
prop_rot13Symm :: String -> Bool
ghci> quickCheck prop_rot13Symm
+++ OK, passed 100 tests.

QuickCheck 能够成功测试此属性。值得注意的是,它能够以某种方式神奇地为我们的函数创建输入数据。这是我们稍后将探讨的内容。现在,让我们进一步探讨属性检查。

注意

快速检查属性通常使用prop_前缀编写。这样,QuickCheck 能够自动对模块中找到的所有属性运行测试。一些 Haskell linter 不允许在标识符中使用 _,但我们对 QuickCheck 属性例外。

如果我们想使用此属性检查不正确的实现怎么办?会发生什么?

ghci> symbols = upperAlphabet ++ lowerAlphabet ++ digits
ghci> rot13' = map $ (\ch -> if ch `elem` symbols then alphabetRot symbols 13 ch else ch)
ghci> prop_rot13Symm s = s == rot13 (rot13 s)
ghci> quickCheck prop_rot13Symm
*** Failed! Falsified (after 2 tests and 1 shrink):
"a"

在这个例子中,我们故意创建了一个错误的 rot13 实现,并在同一属性中使用它。本质上,我们在重构时创建了一个错误(而被测试的属性保持不变)。QuichCheck 告诉我们测试失败(在完成 2 次测试后,其中一次成功)并且输入“a”失败。这与我们旧的属性测试功能非常相似!

值得注意的是收缩的概念。当测试用例失败时,QuickCheck 将自动尝试使其变小,因为数值变小,列表和映射的元素更少等等。当然,缩小的值仍然需要作为一个反例,否则QuickCheck只会告诉我们更大的价值。这样做是为了让我们得到一个更容易理解的反例。在我们的示例中,属性的问题在于单个字符的旋转。但是,也可以通过使用具有各种字符的巨大字符串来伪造它。我们在使用 SimpleCheck 实现测试同一属性时看到了这一点:

ghci> propertyTest prop_rot13Symm asciiString 100
Test failed on: "JQCj_s&S>mOSLnPW5XXIqi9+*IF4WLbTWamp;hW?4FTg\\: !a-vo_QfR/cy4,w2.Xg~5R0sCsX==-KPyD^Wy\\"

在这种情况下,没有必要问哪个测试结果更容易调试的问题。QuickCheck的另一个优点是它的随机值生成和收缩在某种程度上是确定性的。多次运行测试每次都会给我们相同的计数器示例!

ghci> quickCheck prop_rot13Symm
*** Failed! Falsified (after 2 tests and 1 shrink):
"a"
ghci> quickCheck prop_rot13Symm
*** Failed! Falsified (after 5 tests and 1 shrink):
"a"
ghci> quickCheck prop_rot13Symm
*** Failed! Falsified (after 4 tests and 2 shrinks):
"a"

这确保了更好的测试可重复性,并有助于对函数中的常见错误进行分类。

6.3.2 发电机太笼统

现在,是时候测试一些更精细的东西了。我们可以在 alphabetRot 函数的封闭性上写下我们的属性。该属性如清单 6.16 所示。

清单 6.16.字母旋转函数闭合度的快速检查属性

prop_alphabetRotClosed :: Alphabet -> Int -> Char -> Bool
prop_alphabetRotClosed alphabet n c =
  let c' = alphabetRot alphabet n c #1
   in c' `elem` alphabet #1

让我们快速检查此属性:

ghci> quickCheck prop_alphabetRotClosed
*** Failed! Falsified (after 1 test and 1 shrink):
""
0
'a'

这里有两点需要注意。首先,QuickCheck 能够为具有三个参数的函数生成参数!事实上,我们可以在函数中使用任意数量的参数,所有这些参数都将独立生成。第二件要注意的事情是,我们的财产是错误的!但是为什么?我们看到这个属性的参数是 “” 表示字母表,0 表示 n,'a' 表示 c。这里的问题是c需要成为字母表的一个元素,更重要的是,字母表不应该是空的!我们如何解决这个问题?

在我们的框架中,我们通过修改随机值生成器以仅生成正确的值来解决它。这就是现在在快速检查中必须做的事情。

6.4 处理任意随机性

随机值生成是QuickCheck的核心,因为没有它,属性测试就毫无意义。此外,该库能够仅从我们看到的类型中自动推断出正确的生成器。这是如何实施的?

与我们在第 5 章中解决默认值问题的方式类似,本例中的解决方案是类型类。我们感兴趣的类型类称为 任意 :

ghci> :i Arbitrary
type Arbitrary :: * -> Constraint
class Arbitrary a where
  arbitrary :: Gen a
  shrink :: a -> [a]
  {-# MINIMAL arbitrary #-}

它定义了 任意 ,用于产生随机值,并缩小,我们将在后面看一下。从我们看到的最小定义来看,我们实际上现在可以完全忽略收缩!那么什么是任意的,什么是世代?

6.4.1 另一个生成器

Gen与我们的RandomIO类型非常相似。它定义了如何为特定类型生成随机值。我们可以使用 generate 函数在 IO 操作中从 Gen 生成随机值:

ghci> generate arbitrary :: IO Int
15
ghci> generate arbitrary :: IO [Bool]
[True,True,True,False,True,False]
ghci> generate arbitrary :: IO (Maybe Float)
Just (-9.993277)
ghci> generate arbitrary :: IO (Either String Char)
Right '.'

在这里,我们可以看到生成器如何能够为各种类型的值生成值。我们还可以使用 QuickCheck 提供的一些辅助函数自行定义一些。在这里,我们想回顾其中的一小部分:

  • 选择 :: 随机 a => (a, a) -> 世代 a
    • 行为 与 randomRIO 类似, 可用于 在 区间 内 生成 数值
  • 选择任意 :: 随机 a => 世代 a
    • 将随机值从随机实例转换为相同类型的生成
  • 之一 :: [A 代] -> A 代
    • 在给定列表中随机选择一个生成器
  • 元素 :: [a] -> A 代
    • 生成一个生成器,该生成器从给定列表中随机选取一个元素
  • 这样 :: 世代 a -> (a -> 布尔) -> 世代
    • 修改生成器以基于布尔谓词跳过不需要的元素
  • 列表 :: 世代 a -> 世代 [a]
    • 从生成器生成随机长度列表
  • 列表1 :: 世代 -> 世代 [a]
    • 喜欢列表,但不生成空列表
  • vectorOf :: Int -> Gen a -> Gen [a]
    • 生成给定长度的随机值列表
  • 向量 :: 任意 a => Int -> Gen [a]
    • 与vectorOf类似,但列表的生成器是在类型级别推断的
  • 洗牌 :: [a] -> Gen [a]
    • 生成给定列表的随机排列
  • 子列表 :: [a] -> Gen [a]
    • 生成给定列表的随机子列表

这些不是 QuickCheck 提供的所有帮助程序函数,但可能是最有用的。在这里,我们也看到了我们的老朋友这样!使用这些函数,我们可以定义新的生成器。但究竟是怎么回事呢?

锻炼

我们已经在我们的 SimpleCheck 框架中实现了这样的。现在是时候从 QuickCheck 复制更多函数了,以便更好地了解它如何计算它使用的值。确保类型签名相同(只是使用 RandomIO 而不是 Gen ),并尽量保持性能。

定义生成器与定义 RandomIO 非常相似。为了创建它们,我们可以使用我们已经熟悉的 IO 操作中的 do 表示法。

我们可以使用它来重新创建我们之前用于字母属性的生成器。在生成器的do表示法中不需要使用runRandomIO。我们可以简单地使用其他生成器,例如任意或选择并获取它们各自的值。最后,我们返回完成的值。就像以前一样,我们需要返回一个元组,因为值的生成是相互依赖的。如清单 6.17 所示。

清单 6.17.用于字母旋转功能的测试数据的快速检查生成器

gen :: Gen (Alphabet, Int, Char)
gen = do
  size <- getSize #1
  alphabet <- arbitrary `suchThat` (not . null) #2
  n <- choose (-size, size) #3
  c <- elements alphabet #4
  return (alphabet, n, c) #5

这个生成器与我们的最后一个生成器的一个小区别是 getSize 生成器的用法以及它在确定 n 中的用法。快速检查中的生成器具有内部大小。大小决定了生成的值的某些参数。例如,Int 的标准生成器将生成具有该生成器内部大小的绝对值的值。生成器的默认大小为 30,但可以使用调整大小功能进行更改。下面是一个快速示例:

ghci> sample = replicateM 10
ghci> sample $ generate (arbitrary :: Gen Int)
[0,-8,12,9,-9,29,-15,19,-28,-26]
ghci> sample $ generate (resize 100 $ arbitrary :: Gen Int)
[-69,-38,13,-29,40,79,-33,-59,-28,-33]
ghci> sample $ generate (resize 10000 $ arbitrary :: Gen Int)
[270,-5063,-121,-5645,-6974,9861,-1830,-5653,4493,9132]

这解决了有时由于性能原因我们想要外部控制发电机大小的问题。listOf 就是这样一个例子,其中内部大小决定了列表可以变得多大。字符串的任意性也是如此。在构建我们自己的生成器时,我们应该尊重这个大小,并尽可能在内部使用 getSize。当在生成器内部调用生成器时,大小会传播,因此我们可以愉快地从其他生成器构建生成器,而不必担心在内部调整生成器的大小(除非我们明确想要这样做)。

注意

快速检查中已存在示例函数。但是,它以不同的大小(从 0 开始,到 20 结束)对生成器进行采样。您可以使用它来快速了解生成的值的外观以及生成器的行为如何随不同大小而变化。

现在,我们已经为我们的任务创建了一个生成器,是时候使用它了。当我们想在某个属性上运行特定的生成器时,我们可以使用 forAll,它显式允许您为给定属性上的测试用例设置生成器。此函数的结果又是一个属性。这使我们能够完全使用QuickCheck的功能重写关于alphabetRot封闭性的属性测试。如示例 6.18 所示。

清单 6.18.字母旋转函数闭合性的快速检查属性

prop_alphabetRotClosed :: Property
prop_alphabetRotClosed =
  forAll gen prop #1
  where
    prop :: (Alphabet, Int, Char) -> Bool
    prop (alphabet, n, c) = #2
      let c' = alphabetRot alphabet n c #2
       in c' `elem` alphabet #2

    gen :: Gen (Alphabet, Int, Char)
    gen = do
      size <- getSize #3
      alphabet <- arbitrary `suchThat` (not . null) #4
      n <- choose (-size, size) #5
      c <- elements alphabet #6
      return (L.nub alphabet, n, c) #7

这与我们的 SimpleCheck 实现非常相似,但它允许比以前更具表现力的测试。

我们已经看到了如何创建自己的生成器,但显然我们不想在每个测试的基础上执行此操作。如果我们有自定义类型,我们将希望为 Arbitrary type 类创建一个实例,以便更轻松地组合我们的生成器。这是我们接下来应该看的东西。

6.4.2 自定义关联任意性

让我们回想一下第4章,其中我们介绍了AssocMap类型,我们自己的多态映射类型。这是我们接下来要测试的内容。但是,为此,我们需要能够生成这种类型的随机值。任意类型类正是为了这个目的,所以我们要为它创建一个这种类型的实例!

让我们回顾一下该类型是什么以及它是如何工作的。AssocMap 是一种使用元组列表 [(k, v)] 来保存映射的类型。它的完整类型定义如清单 6.19 所示。

清单 6.19.第4章中的AssocMap定义

newtype AssocMap k v = AssocMap [(k, v)]
  deriving (Show)

作为一个映射,它的不变性是每个键只能出现一次,所以如果我们想生成这种类型的任意值,我们需要尊重这个属性。我们可以利用 Gen 也有一个函子实例的事实,所以我们可以使用 fmap 和 <gt; 来修改生成的值。使用它,我们可以生成一个没有重复项的键列表,并在此基础上创建同样多的值,然后在映射中组合这些值。AssocMap 生成器的代码如清单 6.20 所示。

示例 6.20.用于关联地图的快速检查生成器

import qualified Data.List as L #1
import Test.QuickCheck #1

...

genAssocMap :: (Eq k, Arbitrary k, Arbitrary v) => Gen (AssocMap k v)
genAssocMap = do
  keys <- L.nub <gt; arbitrary #2
  vals <- vectorOf (L.length keys) arbitrary #3
  return $ AssocMap (L.zip keys vals) #4

此类型的约束很重要。由于我们要使用任意键和值,因此类型必须具有任意类型类的实例。在我们的生成器中使用任意函数使我们能够使用在我们的 AssocMap 生成器中存在任意生成器的任何类型的类型。我们现在可以测试这个生成器,看看它为不同类型的生成器产生了什么。

ghci> generate genAssocMap :: IO (AssocMap Int Bool)
AssocMap [(0,True),(-1,False),(-23,True),(19,False),(14,False),(2,False)]
ghci> generate genAssocMap :: IO (AssocMap Int (Either Bool Int))
AssocMap [(-10,Left True),(-20,Right 19)]
ghci> generate genAssocMap :: IO (AssocMap Bool [Float])
AssocMap [(True,[7.2917347,17.285803,-12.867395,-3.3579175])]

现在,我们已经完成了编写任意实例的第一个拼图。但是,第二部分,即收缩功能,不应被忽视。

6.4.3 亲爱的,我缩小了我们的测试用例!

在测试时,尤其是在伪造测试用例时,我们通常对复杂和过于复杂的属性反例不感兴趣。但是,如果最终随机选择属性测试的输入,我们将生成较大的反例。QuickCheck有一个称为收缩的内置功能,它试图缩小找到的计数器示例。一旦找到属性测试失败的某些输入,就会缩小输入并重新检查它是否仍然构成反例。重复此操作,直到该值无法进一步缩小,而不会停止作为反例。在这一点上,我们可以在某种程度上确定我们已经找到了最小的伪造输入。图6.2强调了这一想法。

图 6.2.快速检查、属性和收缩的相互作用


QuickCheck 知道如何生成随机数据的每种数据类型,它还知道是否以及如何从此类型收缩值。它知道由于任意类型类,它定义了一个执行收缩的函数,称为收缩。它的类型签名 a -> [a] 告诉我们一些非常重要的事情:它是一个纯函数,从它的输入中生成一个收缩值列表!因此,它的输出仅依赖于输入,这意味着该函数是确定性的!它没有随机的部分。这就是为什么 QuickCheck 生成的反例有时似乎是确定性的,因为根据输入,我们可以返回预先确定的候选项供 QuickCheck 进一步尝试。一个很好的例子是 Char 上的收缩函数:

ghci> shrink 'H'
"abchABC"
ghci> shrink 'A'
"abc"
ghci> shrink 'h'
"abc"
ghci> shrink '\n'
"abcABC123 "
ghci> shrink '1'
"abcABC"

在这里,我们看到 Char 值的持续缩小将如何在某个时候将“abc”添加到组合中。这是有目的地完成的,因为如果“H”是一个反例,那么“abchABC”的任何值也可能是。另一个重要的注意事项是,收缩不应将其输入作为收缩值的一部分返回。

现在,我们如何缩小我们的 AssocMap ?如果我们指定键和值类型以具有任意实例,这也意味着它们的元组以及它们的元组列表具有实例。这意味着我们可以简单地缩小 AssocMap 类型的列表,但我们必须小心。收缩可能会更改值,这可能会使我们的不变性无效,即没有键可以出现两次。因此,在收缩时,我们必须确保密钥只出现一次。收缩的实现如清单 6.21 所示。

清单 6.21.关联映射的收缩函数

shrinkAssocMap ::
  (Eq k, Arbitrary k, Arbitrary v) =>
  AssocMap k v ->
  [AssocMap k v]
shrinkAssocMap (AssocMap xs) =
  L.map #1
    (AssocMap . L.nubBy (\(k1, _) (k2, _) -> k1 == k2)) #1
    (shrink xs) #2

我们可以在任意的 AssocMap 上测试这个收缩函数:

ghci> am <- generate (arbitrary :: Gen (AssocMap Int Int))
ghci> am
AssocMap [(-26,11),(6,3),(12,-24),(-24,-4),(30,12),(-5,27),(-18,9)]
ghci> shrinkAssocMap am
[AssocMap [],AssocMap [(-24,-4),(30,12),(-5,27),(-18,9)],...

这将使我们能够获得更简单的反例,如果我们要编写一个在 AssocMap 中失败的属性!为了做到这一点,最后一个难题是使用我们创建的函数构造任意(AssocMap k v)实例。代码如示例 6.22 所示。

清单 6.22.关联映射的任意类型类的实例

instance (Eq k, Arbitrary k, Arbitrary v) => Arbitrary (AssocMap k v)
  where
    arbitrary = genAssocMap #1
    shrink = shrinkAssocMap #2

这个实例使我们能够在属性中为我们的 AssocMap 类型自由生成随机值。此外,我们的类型现在可以在更大的生成器中使用,以产生更复杂的值。

锻炼

由于我们已经为 AssocMap 实现了任意类型类的实例,因此轮不到您为可以测试的类型编写属性。将这种类型的基本功能作为映射进行检查很重要,因此我们需要检查:

  • 插入值后可以查找该值
  • 多次插入同一键会覆盖已经存在的值
  • 删除密钥按预期工作
  • 空就是空
  • 更改映射不会使不变性无效

实现彻底检查此类型的快速检查属性。如果您能想到更多要测试的属性,请这样做!

现在,我们知道了QuickCheck的基础知识,如何编写属性,生成器以及收缩的工作原理,我们可以将注意力转向实际使用QuickCheck并将其合并到我们的项目中。

6.5 使用支票

到目前为止,我们已经在QuickCheck中学习了不同的概念,如何测试属性以及如何生成随机值。我们现在希望将注意力集中在使用库来测试代码以及在设计测试时要注意什么。

让我们从我们看到的快速检查功能的交互式用法开始。为此,我们想回到排序函数的测试,主要是我们的排序谓词。让我们用快速检查来测试一下:

ghci> quickCheck $ sorts sort
+++ OK, passed 100 tests.
ghci> quickCheck $ sorts id
+++ OK, passed 100 tests.

等等,什么?有些不对劲。ID不可能对列表进行排序!我们需要调查

6.5.1 说得啰嗦而自豪

为此,我们可以使用许多修饰符来更改属性测试的执行方式。第一个是冗长的,它将为我们提供测试用例的详细打印输出:

ghci> quickCheck . verbose $ sorts sort
Passed:
[]

Passed:
[()]

Passed:
[()]

Passed:
[(),()]

Passed:
[(),()]

...

+++ OK, passed 100 tests.

这看起来已经不对了。测试中的列表是单位类型!显然,任何仅包含此类型的列表都很容易排序,因为该类型只有一个居民。我们需要做的是显式命名 QuickCheck 应该用于测试的类型。我们可以通过显式命名属性并为其提供类型签名来做到这一点:

ghci> :{
ghci| prop_sortSorts :: [Int] -> Bool
ghci| prop_sortSorts xs = sort `sorts` xs
ghci| :}
ghci> quickCheck . verbose $ prop_sortSorts
Passed:
[]

Passed:
[]

Passed:
[]

Passed:
[]

Passed:
[0,3]

Passed:
[4,-3]

...

+++ OK, passed 100 tests.

这些值现在看起来不错!但是,由于 quickCheck 只是缓慢地增加生成器的大小,因此它通过多次测试相同的平凡值来开始测试:空列表!当然,这不是一个好的测试。我们应该更深入地了解我们正在测试什么样的数据。为此,我们可以使用 collect 函数来获取我们想要在输入上计算的某个值的统计数据。在我们的例子中,我们可以使用它来检查有多少测试用例是空的。

ghci> :{
ghci| prop_sortSorts :: [Int] -> Property
ghci| prop_sortSorts xs = collect (null xs) $ sort `sorts` xs
ghci| :}
ghci> quickCheck prop_sortSorts
+++ OK, passed 100 tests:
95% False
 5% True

有趣!我们 5% 的测试用例似乎是空的。当我们使用 label 函数时,我们可以得到更好的输出,它让我们为不同的统计数据提供一个标签。

ghci> :{
ghci| prop_sortSorts :: [Int] -> Property
ghci| prop_sortSorts xs = label (if null xs then "empty" else "not empty") $ sort `sorts` xs
ghci| :}
ghci> quickCheck prop_sortSorts
+++ OK, passed 100 tests:
95% not empty
 5% empty

请注意,在这两种情况下,我们属性的类型签名都已从 [Int] -> Bool 更改为 [Int] -> 属性。那是因为收集和标记都会产生属性。现在,我们的问题是:我们如何改进这个测试?在琐碎的案例上测试它是行不通的。我们希望在至少具有两个元素的列表上测试属性。我们可以更改测试用例生成器,但这对于我们正在寻找的解决方案来说是矫枉过正的。使用前提条件更容易完成。就像我们在 SimpleCheck 中添加了前置条件一样,我们在 QuickCheck 中也有前置条件。可以使用 ==> 运算符向属性添加前提条件。

ghci> :{
ghci| prop_sortSorts :: [Int] -> Property
ghci| prop_sortSorts xs = length xs >= 2 ==> sort `sorts` xs
ghci| :}
ghci> quickCheck prop_sortSorts
+++ OK, passed 100 tests; 43 discarded.

这会在输出中做出有趣的更改。QuickCheck 现在告诉我们,43 个测试用例被丢弃了!这些案件不符合前提条件,被完全忽略。我们这样做主要是为了提高我们财产的测试覆盖率。使用微不足道的输入测试函数是没有用的,因为它发现错误的可能性很低。但是,完全排除它们可能不是最佳选择。当我们想要绝对排除测试用例时,使用前提条件是好的,因为它们不反映规范或与所测试的功能无关。测试覆盖率完全是另一回事。

QuickCheck足够有用,可以提供确切的功能。其中之一是封面,它报告了一些关于覆盖范围的信息。

ghci> :{
ghci| prop_sortSorts :: [Int] -> Property
ghci| prop_sortSorts xs = cover 25 (length xs >= 2) "non-trivial" $  sort `sorts` xs
ghci| :}
ghci> quickCheck prop_sortSorts
+++ OK, passed 100 tests (93% non-trivial).

封面期望三个参数。首先是一个介于 0 和 100 之间的数字,它指定所需的最小传递事例量,然后是要传递的值的条件,然后是传递值的标签。然后,Cover 将收集有关有多少测试用例通过的统计信息,并相应地标记它们。

重要

封面不会使属性测试失败!它仅在未满足所需覆盖范围时发出警告。如果你想让测试在这种情况下失败,你必须用checkCoverage函数包装它,这会将警告变成实际失败的测试。

6.5.2 取得最大成功

最后,我们想再次看看如何修改快速检查行为。我们已经看到了冗长如何使 QuickCheck 更加冗长,但是我们可以对使用其他修饰符的测试行为产生更深远的影响:

  • 详细 :: 可测试的道具 =>道具 ->属性
    • 使属性测试更加详细,可以与快速检查结合使用或作为帮助程序函数详细检查使用
  • 详细收缩 :: 可测试道具 =>道具 ->属性
    • 包括输出中的收缩
  • no收缩 :: 可测试道具 =>道具 ->属性
    • 禁用属性收缩
  • 与最大成功 :: 可测试的道具 => Int -> prop -> 属性
    • 配置完成和接受测试的成功次数,默认值为 100
  • 内 :: 可测试道具 => Int -> 道具 -> 属性
    • 如果属性测试未在指定的微秒数内完成,则使该测试失败

特别是MaxSuccess和内部对于测试很重要。有时 100 个测试用例是不够的,有时我们希望确保及时执行时间关键型代码。但是,这在很大程度上取决于测试用例。接下来,我们要研究如何将所有这些属性和检查合并到我们的项目中。

6.5.3 试验时的堆叠试验

当使用 stack new 初始化一个新项目时,Stack 所做的不仅仅是为我们的库和可执行文件创建一个结构。它还在一个名为 test 的子文件夹中创建一个 Spec.hs 文件。项目的这个子结构是为应该测试我们的库和可执行文件的代码保留的。这个测试套件可以用堆栈测试来执行,但是Stack如何知道测试是否失败呢?默认情况下,这是通过使用 Spec.hs 作为主模块编译的程序的退出代码确定的。那么我们如何用这个来编写测试套件呢?

首先,我们需要某种模板来构造测试套件,如果测试不成功,程序将返回失败退出代码。一个非常简单的模板如清单 6.23 所示。

清单 6.23.用于将测试套件与堆栈一起使用的模板

module Main where

import System.Exit (exitFailure, exitSuccess) #1

main :: IO ()
main = do
  success <- ...
  if success #2
    then exitSuccess #2
    else exitFailure #2

现在,问题是,如何运行实际测试。为此,QuickCheck没有给我们一个清晰的结构,但再次为我们提供了一些非常有用的功能。最有帮助的是 快速检查全部 .它收集模块中定义的所有属性,并对所有这些属性使用快速检查。你怎么问?借助模板Haskell的强大功能和我们属性的命名方案。

模板Haskell是GHC的扩展,允许使用元编程并直接操作我们程序的抽象语法树。这可用于自动生成函数,否则需要手动构造。枚举带有prop_后缀的所有属性并对其调用 quickCheck 是 QuickCheck 使用的一种转换。这样,模块可以定义一堆属性,并另外提供某种运行其中所有测试的方法(使用quickCheckAll)。在我们的主模块中,我们可以从测试套件中收集这些测试,然后检查它们的结果。

为此,我们希望与 Spec.hs 一起创建两个新模块,这将是我们的测试套件。SuiteOne.hs 和 SuiteTwo.hs 将包含一些具有不同修饰符和行为的不相关属性。两者都导出一个名为runTests的操作,该操作使用Template Haskell来收集模块中的所有属性。然后,我们可以从主模块引用这些套件。第一个测试套件的代码如清单 6.24 所示。

清单 6.24.示例测试套件

{-# LANGUAGE TemplateHaskell #-} #1

module SuiteOne where

import Test.QuickCheck

prop_true :: Int -> Bool #2
prop_true = const True

prop_false :: Int -> Bool #3
prop_false = const False

return [] #4

runTests :: IO Bool
runTests = $quickCheckAll #5

请注意,我们必须为这两项工作激活 TemplateHaskell 语言扩展。quickCheckAll 之前的美元符号是用于 Template Haskell 的特殊语法。有点奇怪的是,我们的代码中有一个杂散的返回 []。这是由于模板中的错误,是当前建议的解决方法。

与此套件类似,我们可以实现另一个套件,这次涉及更多属性。如清单 6.25 所示。

清单 6.25.另一个示例测试套件

{-# LANGUAGE TemplateHaskell #-} #1

module SuiteTwo where

import Test.QuickCheck

prop_addPos :: Int -> Int -> Property #2
prop_addPos x y =
  withMaxSuccess 500 $
    x > 0 && y > 0 ==> x + y > 0

prop_multZero :: Int -> Property #3
prop_multZero x =
  noShrinking $
    cover 95 (x /= 0) "non-zero" $ x * 0 == 0

return [] #4

runTests :: IO Bool
runTests = $quickCheckAll #5

当然,房产的数量可以达到数百个,我们永远不需要自己写下他们的支票。如果我们想扩展测试套件,我们只需写下另一个属性,下次运行堆栈测试时会自动检查该属性。现在,我们可以将这些套件合并到我们的主模块中。如清单 6.26 所示。

清单 6.26.将示例测试套件与 Stack 配合使用的示例主模块

module Main where

import qualified SuiteOne as S1 #1
import qualified SuiteTwo as S2 #1
import System.Exit (exitFailure, exitSuccess) #2

main :: IO ()
main = do
  s1success <- S1.runTests #3
  s2success <- S2.runTests #3
  if s1success && s2success #4
    then exitSuccess #4
    else exitFailure #4

runTests 返回一个布尔值,告诉我们测试是否失败。更重要的是,当我们运行测试时,它们还会产生一些输出。因此,我们让这些测试运行的顺序很重要。测试我们要先出现,也应该在我们的主IO动作中排在第一位。

锻炼

在我们的实现中,我们不会在逻辑中使用任何类型的短路来过早结束测试,如果一个套件在另一个套件之前失败。在决定测试是否成功之前,我们会测试所有属性。这可能不是我们想要的开发行为。向测试添加一个参数,以便在测试套件失败时启用或禁用快速失败。

您可以使用 --test-arguments 将参数传递给测试:

shell $ stack test --test-arguments "--my-cool-flag"

是时候最终执行我们的测试了。在我的情况下,Stack 项目被称为属性,这就是为什么该套件被称为 属性测试 .

shell $ stack test
properties> test (suite: properties-test)

=== prop_true from test/SuiteOne.hs:7 ===
+++ OK, passed 100 tests.

=== prop_false from test/SuiteOne.hs:10 ===
*** Failed! Falsified (after 1 test):
0

=== prop_addPos from test/SuiteTwo.hs:7 ===
+++ OK, passed 500 tests; 1758 discarded.

=== prop_multZero from test/SuiteTwo.hs:12 ===
+++ OK, passed 100 tests (97% non-zero).


properties> Test suite properties-test failed
Test suite failure for package properties-0.1.0.0
    properties-test:  exited with: ExitFailure 1
Logs printed to console

这是一种相当粗糙的测试方式。我们还可以从我们的package.yaml文件创建更多套件,或者使用像Tasty这样的库,用于将QuickCheck,SmallCheck(另一个测试库)和手动单元测试组合成可以彼此独立列出的测试套件。然而,对于较小的项目,走那么远是没有意义的,我们的方法很好。

锻炼

现在我们知道了如何将测试套件整合到我们的项目中,轮到您为我们的项目这样做了!想想我们到目前为止看到的各种情况的拟合属性,并验证一切是否按预期工作!这里没有真正的对错,只需发挥创意,为我们的类型创建任意实例,看看你能想出哪些属性。

值得一提的是,Haskell的其他测试框架也存在,例如Hedgehog,它类似于QuickCheck或HTF,它具有在编译时收集测试的更自动化的方式。每个框架都有自己独特的功能,根据您的项目大小和功能,您可能更喜欢其中一个。

6.5.4 为什么以及如何?

虽然我们已经介绍了 Haskells 纯函数在可测试性方面的好处,但我们想通过对这个主题进行更多讨论来结束本章。我们为什么要为我们的软件编写测试?我们要实现什么目标?我们想要确保的程序最明显的属性是正确性。我们希望程序根据某些规范产生结果。例如,质数生成器应该只产生素数。但是,我们还希望确保这些属性不会随时间而更改。在代码重构之后,我们的软件仍然应该通过我们编写的所有测试套件。

这确保了对我们计划的信心。我们可以确信它们会产生正确的结果,并且我们仍然可以在不添加回归的情况下处理代码。如果说哈斯克尔有什么大处的强项,那就是:信心。静态类型、纯函数以及将副作用与无副作用编程明确分离,使Haskell不仅对测试非常友好,而且使这些测试有效。由于纯函数没有副作用,它们只是对数据的转换。对这些转换的属性测试必须确保有关这些转换的属性成立,这意味着如果数据满足某些属性,则必须满足某些传出的属性。在本章中,我们看到了如何实现这些测试:

  • 我们使用前提条件或特制生成器来确保输入数据的属性
  • 我们想要确保的属性在转换结果上检查
    • 测试函数的正确结果
    • 在转换期间测试数据类型的正确构造和不变量
  • 我们将属性捆绑在测试套件中,以便在模块内或系统范围内对函数和数据类型的相互作用充满信心

虽然其他编程语言也使开发人员能够编写测试,但很少能像我们在本章中看到的那样从如此少的测试中获得如此高的信心。纯函数式代码中的属性测试类似于形式化验证,因为它们使用形式化的属性而不是观察程序的行为。这为我们留下了更容易重构、扩展和扩展的软件。在持续集成持续交付等实践中,这些测试是成功运营的支柱,通常可以保护您和您的公司免受厄运和悲观情况的影响。

6.6 小结

  • 纯函数中的随机值要求随机值生成器是参数和返回值的一部分
  • 全局随机值生成器可以使用AtomicGenM和globalStdGen从IO操作访问。
  • 随机和统一类型类为我们提供了多种类型,可以随机生成
  • 属性测试使用形式化特征和随机输入来查找实现中的错误
  • 随机生成器应该是可组合的,以便于编写
  • QuickCheck 通过使用类型系统和类型类自动选择随机值生成器
  • Arbitrary type 类不仅为类型提供随机值生成器,还提供将值缩小到更小或更简单的函数
  • 快速检查生成器使用内部大小来创建不同复杂性的随机值
  • quickCheckAll 可用于自动收集模块中的属性并对其执行检查
  • 纯函数代码中的属性测试可确保数据类型、函数和模块的形式化属性

相关推荐

GitHub精选 | 基于go开发的定时任务管理系统

《GitHub精选》是我们分享Github中优质项目的栏目,包括技术、学习、实用与各种有趣的内容。本期推荐的是gocron-定时任务管理系统之前有过一期...

linux定时任务,让你更深入的了解系统

cron的软件包cronierpm-qlcronierpm–qlcrontabs最关键的是一个叫做crontab的命令,我们要书写的非系统定时任务就是通过此程序来编写的;要保证crond.se...

linux定时任务详解 linux定时任务执行

配置方式:(记忆口诀:分、时、日、月、星)#Fordetailsseeman4crontabs#Exampleofjobdefinition:#.---------------...

我终于会写 Java 的定时任务了 java定时任务指定时间执行

前言学过定时任务,但是我忘了,忘得一干二净,害怕,一直听别人说:...

如何用Windows计划任务设置:定时关机

定时关机其实是一个小功能,很多时候还真的需要它。你在网上找还真不是那么好找,下面我们用Windows计划任务设置:定时关机,而且,我还在计划任务中看到了几个不该有的计划任务。?打开:计划任务点击Co...

定时任务优化总结(从半个小时优化到秒级)

整体优化思路:1.按需查询、2.分小批次游标查询、3.JED场景下按数据库分片分组更新、4.精准定位要处理的数据、5.负载均衡业务背景:...

信创终端操作系统上定时任务crontab详解 | 统信 | 麒麟 | 中科方德

原文链接:信创终端操作系统上定时任务crontab详解|统信|麒麟|中科方德...

VIVO手机定时任务功能_定时开关机、定时振动模式

手机在生活中是必不可少的,有些功能我们想要他定时切换变更,本文就来分享一下Vivo手机的定时任务功能。Vivo手机如何进入定时任务界面...

下班总是忘记关电脑!添加任务自动定时关机,不用再跑一趟!

我们每个人都有过这样的经历,下班后、或者出差,单位里的电脑总是忘记关机!很是烦恼!经常忘关电脑的话,一是费电,二是不安全,会给网络攻击留有足够的时间,会导致信息泄露,存在安全隐患。其实只要我们在电脑...

为什么定时任务到时间不执行?带你深入源码找答案

前言早上研发经理给我分配了一个开发任务:每5秒统计一次APP在线人数,并问我啥时能上线?我心想这需求用Spring的定时任务完美解决啊!作为一个萌新正好借此机会在经理面前表现一番,于是我拍着胸脯跟经理...

我来总结下 几种定时任务的执行方式

首先带入我们的业务场景:我们买火车票或者叫外卖的时候,下完单之后会跳转到支付页面,页面会有一个计时器,要求在指定时间内完成支付,否则订单自动取消。这是延时任务的一个典型场景,分析这个场景,就是如何在订...

聊聊定时任务的六种模式 定时任务详解

这篇文章,我们聊聊实现定时任务的六种策略。1自定义单线程上图中,我们启动一个线程,该线程无限循环执行,每隔20毫秒执行业务代码。...

windows如何实现定时任务?配合脚本使用结局很满意

序言:作为一名程序员,通过定时任务去实现各种所需的功能是必须要掌握的,本文来讲解一下在windows服务器上如何创建定时任务。有需要的小伙伴赶紧收藏转发吧。第一步:打开控制面板-》系统和安全-》管理工...

电脑(计算机)如何定时执行任务 电脑定时器怎么定时

电脑想定时执行程序,任务计划步骤如下:1,系统服务里,确保"TaskScheduler"服务启动。2,在控制面板->系统和安全里,找到任务计划,并点击:  或在“管理工具”里点...

分布式定时任务最全详解(图文全面总结)

分布式定时任务是非常核心的分布式系统,下面我就全面来详解分布式定时任务以及分布式定时任务框架@mikechen本篇已收于mikechen原创超30万字《阿里架构师进阶专题合集》里面。什么是分布式定时任...

取消回复欢迎 发表评论: