在Scala社区,Scala是基于性质测试库的实现之一。在本章我们将实现一个自己的测试库,我们需要学习设计一个库应该做出哪些取舍,又应该总结其中的一些设计思路。
首先在设计API的时候我们应该选择正确的数据类型和函数。那么数据类型和函数应该长什么样子呢?如果没有思路的话,我们可以从ScalaCheck的示例中去发掘,也许会有不错的启示。来看下代码:
val intList = Gen.listOf(Gen.choose(0, 100))
val prop =
forAll(intList)(ns => ns.reverse.reverse == ns) &&
forAll(intList)(ns => ns.headOption == nc.reverse.lastOption)
尽管我们还不能知道listOf和choose的实现,但是我们还是可以通过上面的代码推导出这两个函数的签名,choose应该是(Int, Int) => Gen[Int], listOf应该是Gen[Int] => Gen[List[Int]]。这里对Int应该需要泛化一些,来看下代码:
def listOf[A](ga: Gen[A]): Gen[List[A]] = ???
上面的listOf并没有指定生产List[A]的长度,显然我们应该对listOf进行下重构,代码如下:
def listOfN[A](n: Int, ga: Gen[A]): Gen[List[A]] = ???
现在我们需要思考该怎么实现上面的API函数了,不过先来看下forAll的函数签名,因为他也是一个非常重要的函数。从上面的例子可以看出forAll接受一个Gen[List[Int]]和一个predicate:List[Int] => Boolean。当然想要得到forAll的函数签名还是需要泛化一下的,来看下代码:
def forAll[A](ga: Gen[A])(f: A => Boolean): Prop = ???
forAll的返回值是Prop,在目前为止我们还不太清楚Prop的详情,但是至少我们可以推断出,他应该是下面这样的结构:
trait Prop {
def &&(p: Prop): Prop
}
API的函数前面我们已经列出来了, 现在需要归纳下他们的含义:
- forAll 用来创建性质
- && 用来组合性质
- check 用来组合性质
那么check方法该如何实现呢?我们来做一个练习:
trait Prop {
def check: Boolean
//假如check的定义如上,该如何实现&&
def &&(p: Prop): Prop = {
val c = check
new Prop {
override def check: Boolean = c && p.check
}
}
}
在上面的实现中Prop和Booean是很想的,但是Boolean并不能表示Prop的全部。一旦性质失败我们需要知道导致失败的性质是哪些,失败前有哪些性质是测试成功的,如果成功我们也需要知道运行了多少个性质。所以我们得重新定义check函数的返回类型。我们既需要知道性质失败的原因,也需要知道成功性质的个数,来看下代码:
package org.fp.scala.check
import org.fp.scala.check.Prop._
trait Prop {
def check: Either[(FailedCase, SuccessCount), SuccessCount]
def &&(p: Prop): Prop = ???
}
object Prop {
type FailedCase = String
type SuccessCount = Int
}
现在回头来看下生成器Gen的API和意义,Gen[A]负责生成A类型的值。在这里可以是随机生成,想一想之前的章节我们实现了一个纯函数的数字生成器,利用这个生成器我们可以将Gen设计成一个包装类型,其中封装了随机数生成器的状态机。
case class Gen[A](simple: State[RNG, A])
知道了Gen的定义我们来看一些练习:
//利用Gen的定义实现choose函数,生成指定范围内的整数(不含有有边界)
def choose(start: Int, stopExclusive: Int): Gen[Int] = {
import RNG._
Gen(State(map(int)(i => i % stopExclusive + start)))
}
//基于这样的定义,请尝试实现unit、boolean和listOfN
def unit[A](a: => A): Gen[A] =
Gen(State.unit(a))
def boolean: Gen[Boolean] =
unit(true)
def listOfN[A](n: Int, ga: Gen[A]): Gen[List[A]] = {
val list = List.fill(n)(ga.simple)
Gen(State.sequence(list))
}
def option[A](ga: Gen[A]): Gen[Option[A]] = {
Gen(ga.simple.map(Option.apply))
}
假如我们有一个Gen[Int]可以生成一个0-11之间的整数,利用这个整数我们再来生成一个长度为N的Gen[List[Int]]。这其中有一个规律就是我们得先生成一个值,然后在利用这个值再生成下一个值。这个时候我们就需要flatMap了,来看一组练习:
case class Gen[A](simple: State[RNG, A]) {
def flatMap[B](f: A => Gen[B]): Gen[B] =
Gen(simple.flatMap(a => f(a).simple))
def listOfN(size: Gen[Int]): Gen[List[Int]] =
size.flatMap(i => Gen.listOfN(i, size))
}
//实现union将两个同类型的生成器合成一个
def union[A](g1: Gen[A], g2: Gen[A]): Gen[A] =
g1.flatMap(_ => g2)
//实现weighted,类似union根据权重来取值
def weighted[A](g1: (Gen[A], Double), g2: (Gen[A], Double)): Gen[A] = {
val (ga1, d1) = g1
val (ga2, d2) = g2
if (d1 > d2) ga1 else ga2
}
现在需要回过头来再看下Prop的定义,Prop算是一个不太严谨的Either,虽然有SuccessCount来表示成功测试用例的个数,但是假如想表示确定多少个测试用例通过才能算测试通过,就只能硬编码了。这里我们不能硬编码,需要把他变成参数。代码如下:
type FailedCase = String
type SuccessCount = Int
type TestCase = Int
sealed trait Result {
def isFailed: Boolean
}
case object Passed extends Result {
def isFailed: Boolean = false
}
case class Failed(failure: FailedCase, successes: SuccessCount) extends Result {
def isFailed: Boolean = true
}
想一想上面的信息足够表示Prop吗?我们过头来看下forAll的实现,依靠上面的API能够实现forALL吗?可以看出forAll没有足够的信息返回一个Prop,基于Gen的随机生成器就需要一个RNG,我们可以将这种依赖放在Prop上。
case class Prop(run: (TestCase, RNG) => Result)
现在我们有足够的信息来实现forAll了,下面是代码实现:
def forAll[A](ga: Gen[A])(f: A => Boolean): Prop =
Prop { (n, rng) =>
randStream(ga)(rng).zip(Stream.from(0)).take(n).map {
case (a, i) => try {
if (f(a)) Passed else Failed(a.toString, i)
} catch {
case e: Exception => Failed(buildMsg(a, e), i)
}
}
.find(_.isFailed)
.getOrElse(Passed)
}
def randStream[A](ga: Gen[A])(rng: RNG): Stream[A] =
Stream.unfold(rng)(rng => Some(ga.simple.run(rng)))
def buildMsg[A](a: A, e: Exception): String =
s"test case: $a\n" +
s"generated an exception: ${e.getMessage}\n" +
s"stack trace: \n ${e.getStackTrace.mkString("\n")}"
上面就是forAll的全部实现了,注意我们捕获了异常并标识为测试失败,而不是让异常乱飞。