ashawley
ashawley

Reputation: 4283

How do I shrink a list but guarantee it isn't empty?

In ScalaCheck, I have written a generator of non-empty lists of strings,

val nonEmptyListsOfString: Gen[List[String]] =
  Gen.nonEmptyListOf(Arbitrary.arbitrary[String])

And then, assume I wrote a property using Prop.forAll,

Prop.forAll(nonEmptyListsOfString) { strs: List[String] =>
  strs == Nil
}

This is just a simple example that is meant to fail, so that it can show how the shrinking is done by Scalacheck to find the smallest example.

However, the default shrinker in Scalacheck doesn't respect the generator, and will still shrink to an empty string, ignoring the generator properties.

sbt> test
[info] ! Prop.isEmpty: Falsified after 1 passed tests.
[info] > ARG_0: List()
[info] > ARG_0_ORIGINAL: List("")
[info] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error]     example.Prop

Upvotes: 3

Views: 1012

Answers (2)

ashawley
ashawley

Reputation: 4283

There is a way to define your own Shrink class in ScalaCheck. However, it is not common nor very easy to do.

Overview

A Shrink requires defining an implicit definition in scope of your property test. Then Prop.forAll will find your Shrink class if it is in scope and has the appropriate type signature for the value that failed a test.

Fundamentally, a Shrink instance is a function that converts the failing value, x, to a stream of "shrunken" values. It's type signature is roughly:

trait Shrink[T] {
  def shrink(x: T): Stream[T]
}

You can define a Shrink with the companion object's apply method, which is roughly this:

object Shrink {
  def apply[T](s: T => Stream[T]): Shrink[T] = {
    new Shrink[T] {
      def shrink(x: T): Stream[T] = s(x)
    }
  }
}

Example: Shrinking integers

If you know how to work with a Stream collection in Scala, then it's easy to define a shrinker for Int that shrinks by halving the value:

implicit val intShrinker: Shrink[Int] = Shrink {
  case 0 => Stream.empty
  case x => Stream.iterate(x / 2)(_ / 2).takeWhile(_ != 0) :+ 0
}

We want to avoid returning the original value to ScalaCheck, so that's why zero is a special case.

Answer: Non-empty lists

In the case of a non-empty list of strings, you want to re-use the container shrinking of ScalaCheck, but avoid empty containers. Unfortunately, that's not easy to do, but it is possible:

implicit def shrinkListString(implicit s: Shrink[String]): Shrink[List[String]] = Shrink {
  case Nil  => Stream.empty[List[String]]
  case strs => Shrink.shrink(strs)(Shrink.shrinkContainer).filter(!_.isEmpty)
}

Rather than writing a generic container shrinker that avoids empty containers, the one above is specific to List[String]. It could probably be rewritten to List[T].

The first pattern match against Nil is probably unnecessary.

Upvotes: 1

mdm
mdm

Reputation: 3988

As mentioned in the comment, and re-using the example from the github issue you posted:

import cats.data.NonEmptyList
import org.scalacheck.{Arbitrary, Gen}
import org.scalatest.{FreeSpec, Matchers}
import org.scalatest.prop.PropertyChecks
class ScalaCheckTest extends FreeSpec with PropertyChecks with Matchers{
  "Test scalacheck (failing)" in {
    val gen: Gen[List[Int]] = for {
      n <- Gen.choose(1, 3)
      list <- Gen.listOfN(n, Gen.choose(0, 9))
    } yield list

    forAll(gen) { list =>
      list.nonEmpty shouldBe true
      if (list.sum < 18) throw new IllegalArgumentException("ups")
    }
  }

  "Test scalacheck" in {
    val gen1 = for{
      first <- Arbitrary.arbInt.arbitrary
      rest <- Gen.nonEmptyListOf(Arbitrary.arbInt.arbitrary)
    } yield {
      NonEmptyList(first, rest)
    }

    forAll(gen1) { list =>
      val normalList = list.toList
      normalList.nonEmpty shouldBe true
      if (normalList.sum < 18) throw new IllegalArgumentException("ups")
    }
  }
}

The first test does fail showing an empty list being used, but the second one does indeed throw the exception.

UPDATE: Cats is obviously not really needed, here I use a simple (and local) version of a non-empty list for the sake of this test.

"Test scalacheck 2" in {
    case class FakeNonEmptyList[A](first : A, tail : List[A]){
      def toList : List[A] = first :: tail
    }

    val gen1 = for{
      first <- Arbitrary.arbInt.arbitrary
      rest <- Gen.nonEmptyListOf(Arbitrary.arbInt.arbitrary)
    } yield {
      FakeNonEmptyList(first, rest)
    }

    forAll(gen1) { list =>
      val normalList = list.toList
      normalList.nonEmpty shouldBe true
      if (normalList.sum < 18) throw new IllegalArgumentException("ups")
    }
  }

Upvotes: 1

Related Questions