一套快速检查测试与实现相匹配是好事还是坏事?

Is it a good or a bad thing that a suite of quickcheck tests match the implementations?

我正在尝试开始使用 Haskell 的 QuickCheck,虽然我熟悉测试方法背后的概念,但这是我第一次尝试将其用于超越测试 reverse . reverse == id 之类的东西的项目。我想知道将其应用于业务逻辑是否有用(我认为很有可能)。

所以我想测试的几个现有业务逻辑类型函数如下所示:

shouldDiscountProduct :: User -> Product -> Bool
shouldDiscountProduct user product =
  if M.isNothing (userDiscountCode user)
     then False
     else if (productDiscount product) then True
                                       else False

对于此功能,我可以编写如下所示的 QuickCheck 规范:

data ShouldDiscountProductParams
  = ShouldDiscountProductParams User Product

instance Show ShouldDiscountProductParams where
  show (ShouldDiscountProductParams u p) =
    "ShouldDiscountProductParams:\n\n" <>
    "- " <> show u <> "\n\n" <>
    "- " <> show p

instance Arbitrary ShouldDiscountProductParams where
  arbitrary = ShouldDiscountProductParams <$> arbitrary <*> arbitrary

shouldDiscountProduct :: Spec
shouldDiscountProduct = it behavior (property verify)
  where
    behavior =
      "when product elegible for discount\n"
      <> " and user has discount code"

    verify (ShouldDiscountProductParams p t) =
      subject p t `shouldBe` expectation p t

    subject =
      SUT.shouldDiscountProduct

    expectation User{..} Product{..} =
      case (userDiscountCode, productDiscount) of
        (Just _, Just _) -> True
        _ -> False

我最终得到的是一个函数 expectation,它可以更优雅地验证 shouldDiscountProduct 的当前实现。所以现在我有一个测试,我可以重构我原来的功能。但我的自然倾向是将其更改为 expectation:

中的实现
shouldDiscountProduct User{..} Product{..} =
  case (userDiscountCode, productDiscount) of
    (Just _, Just _) -> True
    _ -> False

但这很好吧?如果我想在未来再次更改此功能,我会准备好相同的功能来验证我的更改是否合适并且不会无意中破坏某些东西。

或者这是矫枉过正/双重簿记?我想我已经从 OOP 测试中根深蒂固,你应该尽量避免镜像实现细节,这简直就是实现!

然后我认为,当我检查我的项目并添加这些类型的测试时,我将有效地添加这些测试,然后重构到我在 expectation 断言中实现的更清晰的实现。显然,对于比这些更复杂的功能,情况不会是这样,但我认为总的来说会是这样。

人们对使用基于 属性 的业务逻辑类型功能测试有什么经验?这种事情有什么好的资源吗?我想我只是想验证我是否以适当的方式使用 QC,这只是我的 OOP 过去让我对此产生怀疑...

不,这不是一件好事,因为您实际上是在将代码的结果与同一代码的结果进行比较。

为了解决这个先有鸡还是先有蛋的问题,测试基于以下原则:

  • 测试提供预定义的输入并检查预定义的输出。没有 "random"。所有随机性来源都被视为额外输入,并被模拟或以其他方式强制产生特定值。
    • 有时,妥协是可能的:您单独留下一个随机源,不检查输出的确切值,而只检查 "correctness"(例如,它具有特定格式)。但是你并没有测试负责你不检查的部分的逻辑(尽管你可能不需要,见下文)。
  • 完全测试函数的唯一方法是详尽地尝试所有可能的输入
  • 因为这几乎总是不可能的,所以只有少数 "representative" selected
    • 并且假设代码它以相同的方式处理所有其他可能的输入
      • 这就是测试覆盖率指标很重要的原因:它会告诉您代码何时以这种假设不再成立的方式发生变化

到select最佳"representative"输入,按照函数的界面。

  • 如果输入数据中有一些范围会触发不同的行为,边缘值通常是最有用的
  • 根据接口的承诺检查输出
    • 有时,接口不承诺给定输入的特定值,变体被视为实现细节。然后,您不测试特定值,而只测试接口保证的内容。
      • 测试实现细节只有在其他组件依赖它们时才有用——那么它们并不是真正的实现细节,而是单独的私有接口的一部分。

基本上,属性检查比较同一函数的两个实现唯一有意义的时间是:

  1. 这两个函数都是API的一部分,它们应该各自实现某个功能。比如我们一般要liftEq (==) = (==)。所以我们应该测试我们定义的类型的 liftEq 满足这个 属性.

  2. 一种实现方式明显正确,但效率低下,另一种实现方式虽然高效,但明显不正确。在这种情况下,测试套件应该定义明显正确的版本并检查有效版本。

对于典型的 "business logic",这些都不适用。但是,在某些特殊情况下它们可能会这样做。例如,您可以有两个在不同情况下调用的不同函数,它们应该在特定条件下一致。

很抱歉几个月后才插话,但由于这个问题很容易出现 Google 我认为它需要一个更好的答案。

Ivan 的回答是关于单元测试,而你在谈论 属性 测试,所以让我们忽略它。

Dfeuer 会告诉您什么时候镜像实现是可以接受的,但不会告诉您为您的用例做什么。

基于 属性 的测试 (PBT) 最开始重写实现代码是一个常见的错误。但这不是 PBT 的用途。它们的存在是为了检查您的函数的属性。嘿,别担心,我们在写 PBT 的前几次都会犯这个错误 :D

一种类型的 属性 您可以在此处检查您的函数响应是否与其输入一致

if SUT.shouldDiscountProduct p t 
then isJust (userDiscountCode p) && isJust (productDiscount t) 
else isNothing (userDiscountCode p) || isNothing (productDiscount t)

这在您的特定用例中很微妙,但请注意,我们颠倒了逻辑。您的测试检查输入,并基于此对输出进行断言。我的测试检查输出,并基于此对输入进行断言。在其他用例中,这可能不太对称。大部分代码也可以重构,我让你做这个练习;)

但您可能会发现其他类型的房产!例如。 不变性 属性:

SUT.shouldDiscountProduct p{userDiscountCode = Nothing} t == False
SUT.shouldDiscountProduct p{productDiscount = Nothing} t == False

看到我们在这里做了什么吗?我们固定了输入的一部分(例如,用户折扣代码始终为空),并且我们断言无论其他一切如何变化,输出都是不变的(始终为假)。产品折扣也是如此。

最后一个示例:您可以使用 类比 属性 来检查您的旧代码和新代码的行为是否完全相同:

shouldDiscountProduct user product =
  if M.isNothing (userDiscountCode user)
     then False
     else if (productDiscount product) then True
                                       else False

shouldDiscountProduct' user product
  | Just _ <- userDiscountCode user
  , Just _ <- productDiscount product
  = True
  | otherwise = False

SUT.shouldDiscountProduct p t = SUT.shouldDiscountProduct' p t

读作 "No matter the input, the rewritten function must always return the same value as the old function"。重构的时候太酷了!

我希望这能帮助您理解基于 属性 的测试背后的想法:不要太担心函数返回的值,而开始思考函数的某些行为。

请注意,PBT 不是单元测试的敌人,它们实际上可以很好地结合在一起。如果让您对实际值感觉更安全,您可以使用 1 或 2 个单元测试,然后编写 属性 测试来断言您的函数具有某些行为,无论输入如何。