Pattern for generating negative Scalacheck scenarios: Using property based testing to test validation logic in Scala

*爱你&永不变心* 提交于 2019-12-05 14:09:07

We can combine a valid instance and an set of invalid fields (so that every field, if copied, would cause validation failure) to get an invalid object using shapeless library.

Shapeless allows you to represent your class as a list of key-value pairs that are still strongly typed and support some high-level operations, and converting back from this representation to your original class.

In example below I'll be providing an invalid instance for each single field provided

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

A detailed intro

Let's pretend we have a data class, and a valid instance of it (we only need one, so it can be hardcoded)

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)

We can then define a class to be used for invalid values:

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

Instances of such classes can be provided using ScalaCheck. It's much easier to write a generator where all fields would cause failure. Order of fields doesn't matter, but their names and types do. Here we excluded about from User set of fields so we can do what you asked for (feeding only a subset of fields you want to test)

We then use LabelledGeneric[T] to convert User and BogusUserFields to their corresponding record value (and later we will convert User back)

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

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

Records are lists of key-value pairs, so we can use head to get a single mapping, and the + operator supports adding / replacing field to another record. Let's pick every invalid field into our user one at a time. Also, here's the conversion back in action:

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))

Since we basically are applying the same function (validUserRecord + _) to every key-value pair in our bogusRecord, we can also use map operator, except we use it with an unusual - polymorphic - function. We can also easily convert it to List, because every element will be of a same type now.

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))

Generalizing and removing all the boilerplate

Now the whole point of this was that we can generalize it to work for any two arbitrary classes. The encoding of all relationships and operations is a bit cumbersome and it took me a while to get it right with all the implicit not found errors, so I'll skip the details.

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)
  }
}

So, here it is in action:

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

Unfortunately, you cannot write new Picks(someValidUser).from(bogusData) because implicit for mapper requires a stable identifier. On the other hand, cp instance can be reused with other types:

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

And now it works for all types! And bogus data is required to be any subset of class fields, so it will work even for class itself

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))

You can see the code running at ScalaFiddle

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!