Creating a Specs2 matcher in a modular way

1.1k views Asked by At

I have functions A => Double. I want to check whether two such functions give the same results (up to a tolerance, using the existing beCloseTo matcher) for a given set of values.

I want to be able to write:

type TF = A => Double
(f: TF) must computeSameResultsAs(g: TF,tolerance: Double, tests: Set[A])

I want to build this matcher in a modular way, not simply writing a Matcher[TF] from scratch.

It might be even nicer if I could write:

(f: TF) must computeSameResultsAs(g: TF)
               .withTolerance(tolerance)
               .onValues(tests: Set[A])

Also I want to get a reasonable description when the matcher fails.

Edit

After sleeping over it I came up with the following.

def computeSameResultsAs[A](ref: A => Double, tolerance: Double, args: Set[A]): Matcher[A => Double] = 
  args.map(beCloseOnArg(ref, tolerance, _)).reduce(_ and _)

def beCloseOnArg[A](ref: A => Double, tolerance: Double, arg: A): Matcher[A => Double] = 
  closeTo(ref(arg), tolerance) ^^ ((_: A => Double).apply(arg))

This is much shorter than Eric's solution but doesn't provide a good failure message. What I'd love to be able is rename the mapped value in the second method. Something like the following (which does not compile).

def beCloseOnArg[A](ref: A => Double, tolerance: Double, arg: A): Matcher[A => Double] = 
  closeTo(ref(arg), tolerance) ^^ ((_: A => Double).apply(arg) aka "result on argument " + arg)
1

There are 1 answers

2
Eric On BEST ANSWER

If you want to write things with the second version you need to create a new Matcher class encapsulating the functionality of the beCloseTo matcher:

def computeSameResultsAs[A](g: A => Double, 
                            tolerance: Double = 0.0, 
                            values: Seq[A] = Seq()) = TFMatcher(g, tolerance, values)

case class TFMatcher[A](g: A => Double, 
                        tolerance: Double = 0.0, 
                        values: Seq[A] = Seq()) extends Matcher[A => Double] {

  def apply[S <: A => Double](f: Expectable[S]) = {
    // see definition below
  }

  def withTolerance(t: Double) = TFMatcher(g, t, values)
  def onValues(tests: A*) = TFMatcher(g, tolerance, tests)
}

This class allows to use the syntax you're after:

val f = (i: Int) => i.toDouble
val g = (i: Int) => i.toDouble + 0.1

"f must be close to another similar function with a tolerance" in {
  f must computeSameResultsAs[Int](g).withTolerance(0.5).onValues(1, 2, 3)          
}

Now, let's see how to reuse the beCloseTo matcher in the apply method:

def apply[S <: A => Double](f: Expectable[S]) = {
  val res = ((v: A) => beCloseTo(g(v) +/- tolerance).apply(theValue(f.value(v)))).forall(values)

  val message = "f is "+(if (res.isSuccess) "" else "not ")+
                "close to g with a tolerance of "+tolerance+" "+
                "on values "+values.mkString(",")+": "+res.message
   result(res.isSuccess, message, message, f)
 }

In the code above, we apply a function returning a MatcherResult to a sequence of values:

((v: A) => beCloseTo(g(v) +/- tolerance).apply(theValue(f.value(v)))).forall(values)

Note that:

  1. f is an Expectable[A => Double] so we need to take its actual value to be able to use it

  2. similarly we can only apply an Expectable[T] to a Matcher[T] so we need to use the method theValue to transform f.value(v) to an Expectable[Double] (from the MustExpectations trait)

Finally, we when have the result of the forall matching, we can customize the result messages by using:

  1. the inherited result method building a MatchResult (what the apply method of any Matcher should return

  2. passing it a boolean saying if the execution of beCloseTo was successful: .isSuccess

  3. passing it nicely formatted "ok" and "ko" messages, based on the input and on the result message of the beCloseTo matching

  4. passing it the Expectable which was used to do the matching in the first place: f, so that the final result has a type of MatchResult[A => Double]

I'm not sure how more modular we can get given your requirements. It looks to me that the best we can do here is to reuse beCloseTo with forall.

UPDATE

A shorter answer might be something like this:

val f = (i: Int) => i.toDouble
val g = (i: Int) => i.toDouble + 1.0

"f must be close to another similar function with a tolerance" in {
  f must computeSameResultsAs[Int](g, tolerance = 0.5, values = Seq(1, 2, 3))          
}

def computeSameResultsAs[A](ref: A => Double, tolerance: Double, values: Seq[A]): Matcher[A => Double] = (f: A => Double) => {
  verifyFunction((a: A) => (beCloseTo(ref(a) +/- tolerance)).apply(theValue(f(a)))).forall(values)
}

The code above creates a failure message like:

In the sequence '1, 2, 3', the 1st element is failing: 1.0 is not close to 2.0 +/- 0.5

This should almost work out-of-the-box. The missing part is an implicit conversion from A => MatchResult[_] to Matcher[A] (which I'm going to add to the next version):

implicit def functionResultToMatcher[T](f: T => MatchResult[_]): Matcher[T] = (t: T) => {
  val result = f(t)
  (result.isSuccess, result.message)
}

You can use foreach instead of forall if you want to get all the failures:

1.0 is not close to 2.0 +/- 0.5; 2.0 is not close to 3.0 +/- 0.5; 3.0 is not close to 4.0 +/- 0.5

UPDATE 2

This gets better everyday. With the latest specs2 snapshot you can write:

def computeSameResultsAs[A](ref: A => Double, tolerance: Double, values: Seq[A]): Matcher[A => Double] = (f: A => Double) => {
  ((a: A) => beCloseTo(ref(a) +/- tolerance) ^^ f).forall(values)
}   

UPDATE 3

And now with the latest specs2 snapshot you can write:

def computeSameResultsAs[A](ref: A => Double, tolerance: Double, values: Seq[A]): Matcher[A => Double] = (f: A => Double) => {
  ((a: A) => beCloseTo(ref(a) +/- tolerance) ^^ ((a1: A) => f(a) aka "the value")).forall(values)
}   

The failure message will be:

In the sequence '1, 2, 3', the 1st element is failing: the value '1.0' is not close to 2.0 +/- 0.5