8

我正在尝试开始使用 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 过去让我对此产生怀疑......

4

3 回答 3

2

很抱歉几个月后加入,但由于这个问题很容易出现在谷歌上,我认为它需要一个更好的答案。

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

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

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

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

于 2019-03-05T07:51:44.023 回答
2

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

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

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

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

于 2018-10-31T00:56:22.317 回答
-1

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

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

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

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

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