TDD 用于涉及随机性的算法

Gui*_*rie 5 testing tdd unit-testing

我想尝试测试驱动开发,但是我正在从事的项目涉及很多随机性,我非常不确定如何测试它。这是我可能想要编写的算法的玩具示例:

\n
\n

编写一个不带参数并返回满足以下属性的随机整数列表的函数

\n
    \n
  • 每个整数都在 0 到 10 之间
  • \n
  • 相同的数字不会出现两次\xe2\x80\x99t
  • \n
  • 90% 的情况下列表长度为 3,10% 的情况下列表长度为 4
  • \n
  • 数字3有50%的几率出现
  • \n
\n
\n

我不需要测试精确的统计分布,但显然我希望如果有人完全删除相应的代码,测试就会失败。

\n

我正在使用一个外部 RNG,您可以认为它是正确的,并且我在如何构建代码方面非常自由,因此我可以使用依赖项注入来让测试使用假 RNG,但我仍然不\xe2\x80\x99t真的看看这会有什么帮助。例如,即使我总是使用相同的种子进行测试,一旦我重构算法以不同的顺序选择随机数,所有测试就变得毫无意义。

\n

我猜想前两点可以通过生成许多案例并检查约束是否得到满足来测试,但这并不像 TDD。

\n

对于最后两点,我\xe2\x80\x99m考虑使用不同的配置进行测试,例如90%是100%或0%,然后我可以测试列表的长度是否确实是3或4.我想它会起作用,但似乎有点弱。

\n

使用 TDD 测试涉及随机性的算法时,是否有任何指南或其他技术可供使用?

\n

Mar*_*ann 10

有多种方法可以解决这样的问题,我将来可能会添加另一个答案,但我立即发现最引人注目的方法是将测试驱动开发(TDD)与基于属性的测试相结合。

您可以使用多种语言和各种框架来完成此操作。在这里,我将使用原始的基于属性的测试库QuickCheck

前两个要求直接转化为 QuickCheck 可以执行的谓词。后两者转化为分发测试 - John Hughes 在本演示中解释的QuickCheck 的一项更高级功能。

依次一一进行。

预赛

在编写第一个测试之前,您将设置测试并导入适当的库:

module RintsProperties where

import Test.Framework (Test)
import Test.Framework.Providers.QuickCheck2
import Test.QuickCheck
import Q72168364
Run Code Online (Sandbox Code Playgroud)

其中被测系统 (SUT) 在Q72168364库中定义。SUT 本身是一个名为rints(对于Random INTS)的操作:

rints :: IO [Int]
Run Code Online (Sandbox Code Playgroud)

由于它将生成随机数,因此它必须以IO.

图像

第一个要求涉及SUT 的图像。这很容易表达为一个属性:

testProperty "Each integer is between 0 and 10" $ \() -> ioProperty $ do
  actual <- rints
  return $
    counterexample ("actual: " ++ show actual) $
    all (\i -> 0 <= i && i <= 10) actual
Run Code Online (Sandbox Code Playgroud)

如果您忽略一些涉及生成有用断言消息等的仪式,则中心断言是这样的:

    all (\i -> 0 <= i && i <= 10) actual
Run Code Online (Sandbox Code Playgroud)

它验证所有整数都iactual0 到 10 之间。

在真正的 TDD 方式中,通过测试的最简单的实现是这样的:

rints :: IO [Int]
rints = return []
Run Code Online (Sandbox Code Playgroud)

始终返回一个空列表。虽然退化了,但它满足了要求。

没有重复项

下一个要求也很容易转化为谓词:

testProperty "The same number does not appear twice" $ \() -> ioProperty $ do
  actual <- rints
  return $ nub actual === actual
Run Code Online (Sandbox Code Playgroud)

nub删除重复项,因此此断言指出nub actualactual删除重复项的情况下)应等于actual。仅当 中没有重复项时才会出现这种情况actual

不幸的是,在 TDD 方式中,实现并没有改变:

rints :: IO [Int]
rints = return []
Run Code Online (Sandbox Code Playgroud)

事实上,当我写下这个属性时,它马上就过去了。如果您遵循红绿重构清单,这是不允许的。您应该通过编写红色测试来开始每个周期,但这个测试立即变成绿色。

正确的反应应该是放弃(或隐藏)该测试,然后编写另一个测试 - 也许从转换优先级前提中获取线索来选择下一个好的测试。

然而,出于教学原因,我将坚持 OP 中规定的要求顺序。我没有遵循红绿重构清单,而是rints以各种方式进行修改,以确保断言按预期工作。

长度分布

下一个要求不是一个简单的谓词,而是关于结果分布的声明。QuickCheck 的cover功能可以实现这一点 - 这是我在其他基于属性的测试库中没有见过的功能:

testProperty "Length is and distribution is correct" $ \() -> ioProperty $ do
  actual <- rints
  let l = length actual
  return $
    checkCoverage $
    cover 90 (l == 3) "Length 3" $
    cover 10 (l == 4) "Length 4"
    True -- Base property, but really, the distribution is the test
Run Code Online (Sandbox Code Playgroud)

工作方式是cover,它需要有一个“基本属性”,但在这里我只是返回True- 基本属性总是通过,这意味着分布是实际的测试。

这两个实例说明了每个谓词 (和) 应出现的cover百分比。l == 3l == 4

使用退化实现运行测试会导致此测试失败:

  Length is and distribution is correct: [Failed]
*** Failed! Insufficient coverage (after 100 tests):
Only 0% Length 3, but expected 90%
Run Code Online (Sandbox Code Playgroud)

正如消息所述,它预计会出现 90% 的Length 3情况,但实际结果为 0%。

同样,遵循 TDD,人们可以尝试解决立即出现的错误:

rints :: IO [Int]
rints = return [1,2,3]
Run Code Online (Sandbox Code Playgroud)

然而,这现在会导致测试失败:

  Length is and distribution is correct: [Failed]
*** Failed! Insufficient coverage (after 400 tests):
100.0% Length 3

Only 0.0% Length 4, but expected 10.0%
Run Code Online (Sandbox Code Playgroud)

该酒店预计Length 4病例数为 10%,但实际病例数为 0%。

也许以下是最简单的可行方法?

import System.Random.Stateful

rints :: IO [Int]
rints = do
  p <- uniformRM (1 :: Int, 100) globalStdGen
  if 10 < p then return [1,2,3] else return [1,2,3,4]
Run Code Online (Sandbox Code Playgroud)

也许不像您想象的那么随机,但它通过了所有测试。

更多三分球

最终(明确)要求是3应出现 50% 的次数。这是另一个分布属性:

testProperty "3 appears 50% of the times" $ \() -> ioProperty $ do
  actual <- rints
  return $
    checkCoverage $
    cover 50 (3 `elem` actual) "3 present" $
    cover 50 (3 `notElem` actual) "3 absent"
    True -- Base property, but really, the distribution is the test
Run Code Online (Sandbox Code Playgroud)

运行所有测试都会导致此测试失败:

  3 appears 50% of the times: [Failed]
*** Failed! Insufficient coverage (after 100 tests):
100% 3 present

Only 0% 3 absent, but expected 50%
Run Code Online (Sandbox Code Playgroud)

毫不奇怪,它说这种3 present情况 100% 都会发生。

本着 TDD 的精神(也许有点不守纪律,但它说明了正在发生的事情),您可以尝试rints这样修改:

rints :: IO [Int]
rints = do
  p <- uniformRM (1 :: Int, 100) globalStdGen
  if 10 < p then return [1,2,3] else return [1,2,4,5]
Run Code Online (Sandbox Code Playgroud)

然而,这不起作用,因为分布仍然是错误的:

  3 appears 50% of the times: [Failed]
*** Failed! Insufficient coverage (after 100 tests):
89% 3 present
11% 3 absent

Only 11% 3 absent, but expected 50%
Run Code Online (Sandbox Code Playgroud)

也许以下是最简单的方法。至少这就是我的做法:

rints :: IO [Int]
rints = do
  p <- uniformRM (1 :: Int, 100) globalStdGen
  includeThree <- uniformM globalStdGen
  if 10 < p
    then if includeThree then return [1,2,3] else return [1,2,4]
    else if includeThree then return [1,2,3,4] else return [1,2,4,5]
Run Code Online (Sandbox Code Playgroud)

不优雅,它仍然不产生随机数,但它通过了所有测试。

随机数

虽然上面涵盖了所有明确规定的要求,但它显然不能令人满意,因为它并没有真正产生 1 到 10 之间的随机数。

这是典型的 TDD 流程。当您编写测试和 SUT 并让两者交互时,您会发现需要的测试比您最初想象的要多。

老实说,我不确定“强制”生成 0 到 10 之间所有数字的最佳方法是什么。现在我有了分布测试的锤子,我写了以下内容:

testProperty "All numbers are represented" $ \() -> ioProperty $ do
  actual <- rints
  return $
    checkCoverage $
    cover 5 ( 0 `elem` actual) " 0 present" $
    cover 5 ( 1 `elem` actual) " 1 present" $
    cover 5 ( 2 `elem` actual) " 2 present" $
    cover 5 ( 3 `elem` actual) " 3 present" $
    cover 5 ( 4 `elem` actual) " 4 present" $
    cover 5 ( 5 `elem` actual) " 5 present" $
    cover 5 ( 6 `elem` actual) " 6 present" $
    cover 5 ( 7 `elem` actual) " 7 present" $
    cover 5 ( 8 `elem` actual) " 8 present" $
    cover 5 ( 9 `elem` actual) " 9 present" $
    cover 5 (10 `elem` actual) "10 present"
    True -- Base property, but really, the distribution is the test
Run Code Online (Sandbox Code Playgroud)

我承认我对此并不完全满意,因为它似乎无法“缩放”到函数图像更大的问题。我对更好的选择持开放态度。

我也不想太具体地说明每个数字的确切分布。毕竟,3它会比其他人出现得更频繁。出于这个原因,我只选择了一个很小的百分比(5%)来表明每个数字出现的次数不应太少。

rints到目前为止,与其他发行版测试一样,这个新测试的实施失败了。

粗略地,我将实现更改为:

rints :: IO [Int]
rints = do
  p <- uniformRM (1 :: Int, 100) globalStdGen
  let l = if 10 < p then 3 else 4
  ns <- shuffle $ [0..2] ++ [4..10]
  includeThree <- uniformM globalStdGen
  if includeThree
    then do
      let ns' = take (l - 1) ns
      shuffle $ 3 : ns'
    else
      return $ take l ns
Run Code Online (Sandbox Code Playgroud)

虽然我觉得还有改进的空间,但它通过了所有测试并实际上产生了随机数:

ghci> rints
[5,2,1]
ghci> rints
[9,2,10]
ghci> rints
[8,1,3]
ghci> rints
[0,9,8]
ghci> rints
[0,10,3,6]
Run Code Online (Sandbox Code Playgroud)

此示例使用QuickCheckHaskell,但大多数想法都可以翻译为其他语言。QuickCheck 的cover函数可能是该规则的一个例外,因为我不知道它已被移植到公共语言实现中,但也许我只是落后于潮流。

在类似的情况cover不可用的情况下,您必须编写一个测试,循环遍历足够多的随机生成的测试用例,以验证分布是否符合要求。还需要做一点工作,但并非不可能。


既然 Nikos Baxevanis 提出了要求,那么实现如下shuffle

shuffle :: [a] -> IO [a]
shuffle xs = do
  ar <- newArray l xs
  forM [1..l] $ \i -> do
      j <- uniformRM (i, l) globalStdGen
      vi <- readArray ar i
      vj <- readArray ar j
      writeArray ar j vi
      return vj
  where
    l = length xs
    newArray :: Int -> [a] -> IO (IOArray Int a)
    newArray n = newListArray (1, n)
Run Code Online (Sandbox Code Playgroud)

我从https://wiki.haskell.org/Random_shuffle中提取了它,并且可能进行了一些编辑。

  • QuickCheck 的“cover”的一个很好的例子。FWIW,[haskell-hedgehog](https://hackage.haskell.org/package/hedgehog)支持[`cover`](https://hackage.haskell.org/package/hedgehog/docs/Hedgehog.html#v :封面)也是如此。这是一个[示例](https://jacobstanley.io/5-tips-for-better-hedgehog-tests/#coverage)。 (3认同)