Shrinkers

We have looked at two cornerstones of PBT—properties and generators. There is still one aspect we should take a look at before considering ourselves done.

In PBT, the test data comes from generators and it is kind of random. Given this fact, we could expect that it might be hard to find out why a test is failing. Consider the following example:

scala> forAllNoShrink { num: Int =>
| num < 42
| }.check
! Falsified after 0 passed tests.
> ARG_0: 2008612603

Here, we can see that our property was falsified by the number 2008612603, which is arguably not very useful. It is more or less obvious for an Int, but consider a case with a list of many elements and a property formulated for these elements:

scala> forAllNoShrink(Gen.listOfN(1000, Arbitrary.arbString.arbitrary)) {
| _.forall(_.length < 10)
| }.check
! Falsified after 10 passed tests.
> ARG_0: List

",
... // a lot of similar lines

Obviously, it is near to impossible to find out which of 1,000 strings had a wrong length in this test.

At this moment, the new component comes into play: the Shrink. The job of the shrinker is to find a minimal test data with which the property does not hold. In two previous examples, we used a forAllNoShrink property constructor and thus had no shrinker active. This is how the result will look like if we change the definition to the normal forAll:

scala> forAll(Gen.listOfN(1000, Arbitrary.arbString.arbitrary)) {
| _.forall(_.length < 10)
| }.check
! Falsified after 10 passed tests.
> ARG_0: List("")
> ARG_0_ORIGINAL: // a long list as before

Here, we can see that the minimal list, which falsifies our property, is the list with one empty string. The original failing input is shown as ARG_0_ORIGINAL and it is of a similar length and complexity as we've seen before.

The Shrink instances are passed as implicit parameters, so we can summon one to see how they work. We'll do this with our failing value for Int property:

val intShrink: Shrink[Int] = implicitly[Shrink[Int]]
scala> intShrink.shrink(2008612603).toList
res23: List[Int] = List(1004306301, -1004306301, 502153150, -502153150, 251076575, -251076575, 125538287, -125538287, 62769143, -62769143, 31384571, -31384571, 15692285, -15692285, 7846142, -7846142, 3923071, -3923071, 1961535, -1961535, 980767, -980767, 490383, -490383, 245191, -245191, 122595, -122595, 61297, -61297, 30648, -30648, 15324, -15324, 7662, -7662, 3831, -3831, 1915, -1915, 957, -957, 478, -478, 239, -239, 119, -119, 59, -59, 29, -29, 14, -14, 7, -7, 3, -3, 1, -1, 0)

The shrink method generates a stream of values and we evaluate it by converting it to the list. It is easy to see the pattern—the values produced by the Shrink lie symmetrically to the central value of 0 (zero), starting from the initial failing value, and then are each time divided by two until they converge to the zero. This is pretty much how it is implemented for numbers, including hardcoded values of +-two, +-one, and zero.

It is easy to see that numbers produced by the Shrink will depend on the initial failing argument. This is why for the first property the returned value will differ each time:

scala> forAll { (_: Int) < 42 }.check
! Falsified after 0 passed tests.
> ARG_0: 47
> ARG_0_ORIGINAL: 800692446

scala> forAll { (_: Int) < 42 }.check
! Falsified after 0 passed tests.
> ARG_0: 54
> ARG_0_ORIGINAL: 908148321

scala> forAll { (_: Int) < 42 }.check
! Falsified after 2 passed tests.
> ARG_0: 57
> ARG_0_ORIGINAL: 969910515

scala> forAll { (_: Int) < 42 }.check
! Falsified after 6 passed tests.
> ARG_0: 44
> ARG_0_ORIGINAL: 745869268

As we can see, the resulting failing value depends on the original failing value and is never 43, but sometimes it lies quite close.

Shrinkers are essential at the time there are some properties which do not hold, especially if the input data is of significant size.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.14.132