基于 属性 的测试是否会让您重复代码?

Does property based testing make you duplicate code?

我正在尝试用基于 属性 的测试 (PBT) 替换一些旧的单元测试,具体来说用 scalascalatest - scalacheck 替换,但我认为问题更普遍。简化的情况是,如果我有一个要测试的方法:

 def upcaseReverse(s:String) = s.toUpperCase.reverse

通常,我会编写如下单元测试:

assertEquals("GNIRTS", upcaseReverse("string"))
assertEquals("", upcaseReverse(""))
// ... corner cases I could think of

所以,对于每个测试,我都写下了我期望的输出,没问题。现在,使用 PBT,它就像:

property("strings are reversed and upper-cased") {
 forAll { (s: String) =>
   assert ( upcaseReverse(s) == ???) //this is the problem right here!
 }
}

当我尝试编写一个对所有 String 输入都成立的测试时,我发现自己不得不在测试中再次编写该方法的逻辑。在这种情况下,测试看起来像:

   assert ( upcaseReverse(s) == s.toUpperCase.reverse) 

也就是说,我必须在测试中编写实现以确保输出正确。 有办法解决这个问题吗?我是不是误解了 PBT,我应该测试其他属性,比如 :

这也是有道理的,但听起来很做作而且不太清楚。在 PBT 方面有更多经验的人可以在这里阐明一下吗?

编辑 :根据@Eric 的消息来源,我得到了 this post,这正是我的意思的一个例子(在 应用类别一更多时间): 测试方法times in (F#):

type Dollar(amount:int) =
member val Amount  = amount 
member this.Add add = 
    Dollar (amount + add)
member this.Times multiplier  = 
    Dollar (amount * multiplier)
static member Create amount  = 
    Dollar amount  

作者最终编写了如下测试:

let ``create then times should be same as times then create`` start multiplier = 
let d0 = Dollar.Create start
let d1 = d0.Times(multiplier)
let d2 = Dollar.Create (start * multiplier)      // This ones duplicates the code of Times!
d1 = d2

所以,为了测试那个方法,在测试中复制了该方法的代码。在这种情况下,一些事情就像乘法一样微不足道,但我认为它可以推断出更复杂的情况。

This presentation 提供了一些关于您可以为代码编写的属性类型而无需复制的线索。

一般来说,考虑一下当您使用其他方法编写要测试的方法时会发生什么是很有用的 class:

  • size
  • ++
  • reverse
  • toUpperCase
  • contains

例如:

  • upcaseReverse(y) ++ upcaseReverse(x) == upcaseReverse(x ++ y)

然后想想如果实现被破坏会破坏什么。 属性 会失败吗:

  1. 大小没有保留?
  2. 不是所有的字符都是大写的吗?
  3. 字符串没有正确反转?

1。实际上由 3 暗示。我认为上面的 属性 会中断 3。但是它不会中断 2(例如,如果根本没有大写)。我们可以增强它吗?怎么样:

  • upcaseReverse(y) ++ x.reverse.toUpper == upcaseReverse(x ++ y)

我认为这个没问题,但不要相信我 运行 测试!

无论如何,我希望你明白了:

  1. 与其他方法组合
  2. 查看是否存在似乎成立的等式(如演示文稿中的 "round-tripping" 或 "idempotency" 或 "model-checking")
  3. 检查您的 属性 是否会在代码错误时中断

请注意,1. 和 2. 由名为 QuickSpec and 3. is "mutation testing" 的库实现。

附录

关于您的编辑:Times 操作只是 * 的包装器,因此没有太多要测试的。但是,在更复杂的情况下,您可能需要检查操作:

  • 有一个 unit 元素
  • 是关联的
  • 可交换
  • 分配加法

如果这些属性中的任何一个失败,这将是一个很大的惊喜。如果您将这些属性编码为任何二元关系的通用属性 T x T -> T,您应该能够在各种上下文中非常轻松地重用它们(请参阅 Scalaz Monoid "laws")。

回到您的 upperCaseReverse 示例,我实际上会编写 2 个单独的属性:

 "upperCaseReverse must uppercase the string" >> forAll { s: String =>
    upperCaseReverse(s).forall(_.isUpper)
 }

 "upperCaseReverse reverses the string regardless of case" >> forAll { s: String =>
    upperCaseReverse(s).toLowerCase === s.reverse.toLowerCase
 }

这不会重复代码并说明 2 个不同的东西,如果您的代码错误,它们可能会中断。

总之,我之前和你有过同样的问题,对此感到非常沮丧,但过了一段时间后,我发现越来越多的情况下我不是复制我的代码属性,尤其是当我开始考虑

  • 将测试函数与其他函数结合(.isUpper在第一个属性)
  • 将测试函数与更简单的 "model" 计算进行比较(第二个 "reverse regardless of case" 属性)

我将此问题称为“收敛测试”,但我无法弄清楚该术语的来源或来源,所以请三思而后行。

对于任何测试,您 运行 测试代码的复杂性接近被测代码的复杂性的风险。

在您的情况下,代码最终基本相同,只是将相同的代码写了两次。有时这很有价值。例如,如果您正在编写代码来让重症监护室的人活着,您可以将其编写两次以确保安全。我不会因为你的谨慎而责怪你。

对于其他情况,测试失败的可能性会使测试捕获实际问题的好处失效。出于这个原因,即使它以其他方式违反最佳实践(枚举应该计算的东西,而不是编写 DRY 代码)我尝试编写在某种程度上比生产代码更简单的测试代码,因此不太可能失败。

如果我找不到一种方法来编写比测试代码更简单的代码,这也是可维护的(阅读:“我也喜欢”),我将该测试移至“更高”级别(例如单元测试-> 功能测试)

我刚开始玩基于 属性 的测试,但据我所知,很难让它与许多单元测试一起工作。对于复杂的单元,它可以工作,但到目前为止我发现它对功能测试更有帮助。

对于功能测试,您通常可以编写 函数必须满足的规则 比编写 更简单]满足规则的函数。这对我来说很像 P vs NP 问题。您可以在其中编写程序以 VALIDATE 线性时间解决方案,但所有已知程序 FIND 解决方案需要更长的时间。这似乎是 属性 测试的绝佳案例。