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

Posted

技术标签:

【中文标题】一套快速检查测试与实现相匹配是好事还是坏事?【英文标题】:Is it a good or a bad thing that a suite of quickcheck tests match the implementations? 【发布时间】:2019-02-25 06:32:24 【问题描述】:

我正在尝试开始使用 Haskell 的 QuickCheck,虽然我熟悉测试方法背后的概念,但这是我第一次尝试将它用于超出测试内容的项目,例如 @ 987654321@ 之类的。我想知道将其应用于业务逻辑是否有用(我认为很有可能)。

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

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 过去让我对此产生怀疑......

【问题讨论】:

我敢说业务逻辑是基于属性的检查最糟糕的应用之一,因为它经常公然无视数学的可接近性。 【参考方案1】:

很抱歉几个月后再加入,但由于这个问题很容易在 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 puserDiscountCode = Nothing t == False
SUT.shouldDiscountProduct pproductDiscount = Nothing t == False

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

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

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

读作“无论输入如何,重写的函数必须始终返回与旧函数相同的值”。重构时这太酷了!

我希望这能帮助您掌握基于属性的测试背后的理念:不要再担心函数返回的值,而开始思考您的函数的一些行为。

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

【讨论】:

【参考方案2】:

基本上,只有在以下情况下,属性检查才有意义地比较同一函数的两个实现:

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

    一种实现显然是正确的,但效率低下,而另一种实现是有效的,但并不明显正确。在这种情况下,测试套件应该定义明显正确的版本,并对照它检查有效的版本。

对于典型的“业务逻辑”,这些都不适用。但是,可能会有一些特殊情况。例如,您可以在不同的情况下调用两个不同的函数,这些函数在某些条件下应该是一致的。

【讨论】:

【参考方案3】:

不,这不是一件好事,因为您正在有效地将代码的结果与相同代码的结果进行比较。

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

测试提供预定义的输入并检查预定义的输出。没有什么“随机”的。所有随机性来源都被视为附加输入,并被模拟或以其他方式强制产生特定值。 有时,妥协是可能的:您不理会随机源并检查输出的不是精确值,而是“正确性”(例如,它具有特定格式)。但是,您并没有测试负责您不检查的部分的逻辑(尽管您可能不需要,见下文)。 完整测试函数的唯一方法是彻底尝试所有可能的输入 因为这几乎总是不可能的,所以只选择了几个“有代表性的” 并且对代码进行了假设,它以相同的方式处理所有其他可能的输入 这就是测试覆盖率指标很重要的原因:它会告诉您代码何时发生更改,使得该假设不再成立

要选择最佳的“代表性”输入,请按照函数的界面进行操作。

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

【讨论】:

这个答案似乎与更传统的单元测试风格的测试方法有关,您的答案与基于属性的测试(例如在快速检查/类似库中)有何关系? @danbroooks 我以前从未听说过基于属性的测试,但这是最终学习一些全新原理的绝佳机会。从我现在看到的情况来看,是一些 AI 逻辑为您选择那些“代表性”输入的工作的一部分——但一般原则是相同的,检查的条件是静态的并且表示接口而不是实现。

以上是关于一套快速检查测试与实现相匹配是好事还是坏事?的主要内容,如果未能解决你的问题,请参考以下文章

股票解禁是好事还是坏事?

使用Laravel的语义版本控制进行包版本控制

东北育才 第4天

8年软件测试工程师的感悟:与薪资相匹配的永远是实力

8年软件测试工程师的感悟:与薪资相匹配的永远是实力!

微笑和鼓励不一定是好事,冷漠或打压也未必是坏事