生成负面 Scalacheck 场景的模式:使用基于属性的测试来测试 Scala 中的验证逻辑

2024-01-06

我们正在寻找一种可行的设计模式来构建 ScalacheckGen(生成器)可以产生积极和消极的测试场景。这将使我们能够运行forAll测试以验证功能(正面案例),并通过在所有情况下失败来验证我们的案例类验证是否正常工作invalid数据的组合。

制作一个简单的、参数化的Gen一次性执行此操作非常简单。例如:

  def idGen(valid: Boolean = true): Gen[String] = Gen.oneOf(ID.values.toList).map(s => if (valid) s else Gen.oneOf(simpleRandomCode(4), "").sample.get)

通过上述内容,我可以获得有效或无效的 ID 以进行测试。有效的,我用它来确保业务逻辑成功。无效的,我用它来确保我们的验证逻辑拒绝案例类。

好吧,问题是,从大规模来看,这变得非常笨拙。假设我有一个数据容器,其中包含 100 个不同的元素。生成一个“好”的东西很容易。但现在,我想生成一个“坏”的,而且:

  1. 我想为每个数据元素生成一个错误的数据元素,其中单个数据元素是错误的(因此至少有 100 个错误实例,测试验证逻辑是否捕获每个无效参数)。

  2. 我希望能够覆盖specific元素,例如输入错误的 ID 或错误的“foobar”。不管那是什么。

我们可以寻找灵感的一种模式是apply and copy,这使我们能够在指定覆盖值的同时轻松组合新对象。例如:

val f = Foo("a", "b") // f: Foo = Foo(a,b)
val t = Foo.unapply(f) // t: Option[(String, String)] = Some((a,b))
Foo(t.get._1, "c") // res0: Foo = Foo(a,c)

上面我们看到了从另一个对象的模板创建变异对象的基本思想。这在 Scala 中更容易表达为:

val f = someFoo copy(b = "c")

以此为灵感,我们可以思考我们的目标。有几点需要考虑:

  1. 首先,我们可以为数据元素和生成的值定义一个映射或键/值容器。这可以用来代替元组来支持命名值突变。

  2. 给定一个键/值对的容器,我们可以轻松地随机选择一对(或多对)并更改值。这支持生成数据集的目标,其中一个值被更改以造成失败。

  3. 给定这样一个容器,我们可以轻松地从无效值集合中创建一个新对象(使用apply()或其他一些技术)。

  4. 或者,也许我们可以开发一种使用元组的模式,然后就可以了apply()它,有点像copy方法,只要我们仍然可以随机改变一个或多个值。

我们或许可以探索开发一个可重用的模式,它可以执行如下操作:

def thingGen(invalidValueCount: Int): Gen[Thing] = ???
def someTest = forAll(thingGen) { v => invalidV = v.invalidate(1); validate(invalidV) must beFalse }

在上面的代码中,我们有一个生成器thingGen返回(有效)Things。然后对于返回的所有实例,我们调用一个通用方法invalidate(count: Int)这将随机失效count值,返回无效对象。然后我们可以用它来确定我们的验证逻辑是否正常工作。

这需要定义一个invalidate()函数,给定一个参数(按名称或按位置),然后可以用已知的错误值替换已识别的参数。这意味着对于特定值有一个“反生成器”,例如,如果 ID 必须是 3 个字符,那么它知道创建一个长度不超过 3 个字符的字符串。

当然,要使已知的单个参数无效(将不良数据注入测试条件),我们可以简单地使用复制方法:

def thingGen(invalidValueCount: Int): Gen[Thing] = ???
def someTest = forAll(thingGen) { v => v2 = v copy(id = "xxx"); validate(v2) must beFalse }

这是我迄今为止的想法的总和。我是不是找错了树?是否有处理此类测试的良好模式?关于如何最好地解决测试我们的验证逻辑的问题有什么评论或建议吗?


我们可以组合一个有效的实例和一组无效的字段(这样every字段,如果复制,将导致验证失败)使用获取无效对象无形的 https://github.com/milessabin/shapeless图书馆。

Shapeless 允许您将类表示为键值对列表,这些键值对仍然是强类型的并支持一些高级操作,并从这种表示形式转换回原始类。

在下面的示例中,我将为提供的每个字段提供一个无效实例

import shapeless._, record._
import shapeless.labelled.FieldType
import shapeless.ops.record.Updater

详细介绍

假设我们有一个数据类和它的一个有效实例(我们只需要一个,因此可以对其进行硬编码)

case class User(id: String, name: String, about: String, age: Int) {
  def isValid = id.length == 3 && name.nonEmpty && age >= 0
}
val someValidUser = User("oo7", "Frank", "A good guy", 42)
assert(someValidUser.isValid)

然后我们可以定义一个用于无效值的类:

case class BogusUserFields(name: String, id: String, age: Int)
val bogusData = BogusUserFields("", "1234", -5)

可以使用以下方式提供此类的实例Scala检查。编写一个所有字段都会导致失败的生成器要容易得多。字段的顺序并不重要,重要的是它们的名称和类型。这里我们排除了about from User一组字段,以便我们可以执行您所要求的操作(仅提供您想要测试的字段的子集)

然后我们使用LabelledGeneric[T]转换User and BogusUserFields到它们对应的记录值(稍后我们将转换User back)

val userLG = LabelledGeneric[User]
val bogusLG = LabelledGeneric[BogusUserFields]

val validUserRecord = userLG.to(someValidUser)
val bogusRecord = bogusLG.to(bogusData)

记录是键值对的列表,因此我们可以使用head获得单个映射,并且+运算符支持向另一条记录添加/替换字段。让我们一次将每个无效字段挑选到我们的用户中。另外,这里是转换回来的动作:

val invalidUser1 = userLG.from(validUserRecord + bogusRecord.head)// invalid name
val invalidUser2 = userLG.from(validUserRecord + bogusRecord.tail.head)// invalid ID
val invalidUser3 = userLG.from(validUserRecord + bogusRecord.tail.tail.head) // invalid age

assert(List(invalidUser1, invalidUser2, invalidUser3).forall(!_.isValid))

因为我们基本上应用相同的函数(validUserRecord + _)到我们的每个键值对bogusRecord,我们还可以使用map运算符,除非我们将它与不寻常的多态函数一起使用。我们也可以很容易地将其转换为List,因为现在每个元素都将具有相同的类型。

object polymerge extends Poly1 {
  implicit def caseField[K, V](implicit upd: Updater[userLG.Repr, FieldType[K, V]]) =
    at[FieldType[K, V]](upd(validUserRecord, _))
}

val allInvalidUsers = bogusRecord.map(polymerge).toList.map(userLG.from)
assert(allInvalidUsers == List(invalidUser1, invalidUser2, invalidUser3))

概括并删除所有样板

现在的重点是我们可以将其推广到适用于任意两个任意类。所有关系和操作的编码有点麻烦,我花了一段时间才正确处理所有关系和操作implicit not found错误,所以我将跳过细节。

class Picks[A, AR <: HList](defaults: A)(implicit lgA: LabelledGeneric.Aux[A, AR]) {
  private val defaultsRec = lgA.to(defaults)

  object mergeIntoTemplate extends Poly1 {
    implicit def caseField[K, V](implicit upd: Updater[AR, FieldType[K, V]]) =
      at[FieldType[K, V]](upd(defaultsRec, _))
  }

  def from[B, BR <: HList, MR <: HList, F <: Poly](options: B)
    (implicit
      optionsLG: LabelledGeneric.Aux[B, BR],
      mapper: ops.hlist.Mapper.Aux[mergeIntoTemplate.type, BR, MR],
      toList: ops.hlist.ToTraversable.Aux[MR, List, AR]
    ) = {
    optionsLG.to(options).map(mergeIntoTemplate).toList.map(lgA.from)
  }
}

所以,它正在发挥作用:

val cp = new Picks(someValidUser)
assert(cp.from(bogusData) == allInvalidUsers)

不幸的是,你不能写new Picks(someValidUser).from(bogusData)因为隐式的mapper需要一个稳定的标识符。另一方面,cp实例可以与其他类型重用:

case class BogusName(name: String)
assert(cp.from(BogusName("")).head == someValidUser.copy(name = ""))

现在它适用于所有类型!并且虚假数据必须是类字段的任何子集,因此它甚至适用于类本身

case class Address(country: String, city: String, line_1: String, line_2: String) {
  def isValid = Seq(country, city, line_1, line_2).forall(_.nonEmpty)
}

val acp = new Picks(Address("Test country", "Test city", "Test line 1", "Test line 2"))
val invalidAddresses = acp.from(Address("", "", "", ""))
assert(invalidAddresses.forall(!_.isValid))

你可以看到代码运行在斯卡拉小提琴 https://scalafiddle.io/sf/rB64yOO/0

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

生成负面 Scalacheck 场景的模式:使用基于属性的测试来测试 Scala 中的验证逻辑 的相关文章

随机推荐