/elm-shrink

A library for authoring shrinking strategies

Primary LanguageElm

Shrinking strategies with elm-shrink

elm-shrink is a library for using and creating shrinking strategies. A shrinking strategy, or shrinker is a way of taking a value and producing a list of values which are, some sense, more minimal than the original value.

In more practical terms, a shrinker is simply a function that takes a value and produces a lazy list of those values.

Note: As of version 3.0.0, elm-shrink uses lazy lists instead of lists. This means that elm-shrink has a direct dependency on TheSeamau5/elm-lazy-list

type alias Shrinker a = a -> LazyList a

Shrinking is heavily used in property-based testing as a way to shrink failing test cases into a more minimal test case. This is key for being able to debug code swiftly and easily.

Basic Examples

The following examples show how to use the basic shrinkers and the kinds of results they produce.

Shrink an Int

int 10 == [0,5,7,8,9]

Shrink a String

string "Hello World" == 
  [""," World","Hellod","llo World","Heo World","HellWorld","Hello rld","Hello Wod","ello World","Hllo World","Helo World","Helo World","Hell World","HelloWorld","Hello orld","Hello Wrld","Hello Wold","Hello Word","Hello Worl","\0ello World","$ello World","6ello World","?ello World","Cello World","Eello World","Fello World","Gello World","H\0llo World","H2llo World","HKllo World","HXllo World","H^llo World","Hallo World","Hcllo World","Hdllo World","He\0lo World","He6lo World","HeQlo World","He^lo World","Heelo World","Hehlo World","Hejlo World","Heklo World","Hel\0o World","Hel6o World","HelQo World","Hel^o World","Heleo World","Helho World","Heljo World","Helko World","Hell\0 World","Hell7 World","HellS World","Hella World","Hellh World","Hellk World","Hellm World","Helln World","Hello\0World","Hello�World","Hello�World","Hello�World","Hello�World","Hello�World","Hello \0orld","Hello +orld","Hello Aorld","Hello Lorld","Hello Qorld","Hello Torld","Hello Uorld","Hello Vorld","Hello W\0rld","Hello W7rld","Hello WSrld","Hello Warld","Hello Whrld","Hello Wkrld","Hello Wmrld","Hello Wnrld","Hello Wo\0ld","Hello Wo9ld","Hello WoUld","Hello Wocld","Hello Wojld","Hello Wonld","Hello Wopld","Hello Woqld","Hello Wor\0d","Hello Wor6d","Hello WorQd","Hello Wor^d","Hello Wored","Hello Worhd","Hello Worjd","Hello Workd","Hello Worl\0","Hello Worl2","Hello WorlK","Hello WorlW","Hello Worl]","Hello Worl`","Hello Worlb","Hello Worlc"]

Shrink a Maybe Float

maybe float (Just 3.14) == 
  [Nothing,Just 0,Just 1.57,Just 2.355,Just 2.7475,Just 2.94375,Just 3.041875,Just 3.0909375,Just 3.11546875,Just 3.127734375,Just 3.1338671875,Just 3.1369335937500002,Just 3.138466796875,Just 3.1392333984375,Just 3.1396166992187498,Just 3.1398083496093747]

Shrink a List of Bools

list bool [True, False, False, True, False] ==
  [[],[False,True,False],[True,False,False],[False,False,True,False],[True,False,True,False],[True,False,True,False],[True,False,False,False],[True,False,False,True],[False,False,False,True,False],[True,False,False,False,False]]

Make your own shrinkers

With elm-shrink, it is very easy to make your own shrinkers for your own data types.

First of all, let's look at one of the basic shrinkers available in elm-shrink and how it is implemented.

Shrinker Bool

To shrink a Bool, you have to consider the possible values of Bool: True and False. Intuitively, we understand that False is more "minimal" than True. As, such, we would shrink True to False. As for False, there is no value that is more "minimal" than False. As such, we simply shrink it to the empty list.

bool : Shrinker Bool
bool b = case b of
  True  -> False ::: empty
  False -> empty

Note that there is no "exact" rule to deciding on whether something is more "minimal" than another. The idea is that you want to have one case return the empty list if possible while other cases move towards the more "minimal" cases. In this example, we decided that False was the more "minimal" case and, in a sense, moved True towards False since False then returns the empty list. Obviously, this choice could have been reversed and you would be justified in doing so.

Now that we understand how to make a simple shrinker, let's see how we can use these simple shrinkers together to make something that can shrink a more complex data structure.

Shrinker Vector

Consider the following Vector type:

type alias Vector =
  { x : Float
  , y : Float
  , z : Float
  }

Our goal is to produce a vector shrinker:

vector : Shrinker Vector

elm-shrink provides a basic Float shrinker and we can use it in combination with map and andMap to make the Vector shrinker.

vector : Shrinker Vector
vector {x, y, z} =
  Vector
    `map`    float x
    `andMap` float y
    `andMap` float z

And voila! Super simple. Let's try this on an even larger structure.

Shrinker Mario

Consider the following types:

type alias Mario =
  { position  : Vector
  , velocity  : Vector
  , direction : Direction
  }

type alias Vector =
  { x : Float
  , y : Float
  }

type Direction
  = Left
  | Right

And our goal is to produce a shrinker of Marios.

mario : Shrinker Mario

To do this, we will split the steps. We can notice that we have two distinct data types we need to shrink: Vector and Direction.

For Vector, we can use the approach from the previous example:

vector : Shrinker Vector
vector {x, y} =
  Vector
    `map`    float x
    `andMap` float y

And for Direction, we can apply a similar approach to our Bool example:

direction : Shrinker Direction
direction dir = case dir of
  Left  -> empty
  Right -> Left ::: empty

Where Left here is considered the "minimal" case.

Now, let's put these together:

mario : Shrinker Mario
mario m =
  Mario
    `map`    vector m.position
    `andMap` vector m.velocity
    `andMap` direction m.direction

And, yay! We now can shrink Mario! No mushrooms needed!

One more technique

Sometimes, you want to shrink a data structure but you know intuitively that it should shrink in a similar fashion to some other data structure. It would be nice if you could just convert back and from that other data structure and use its already existing shrinker.

For example List and Array.

In elm-shrink, there exists a List shrinker:

list : Shrinker a -> Shrinker (List a)

This shrinker is quite involved and does a number of things to shuffle elements, shrink some elements, preserve others, etc...

It would be nice if that can be re-used for arrays, because in a high-level sense, arrays and lists are equivalent.

This is exactly what elm-shrink does and it uses a function called convert.

convert : (a -> b) -> (b -> a) -> Shrinker a -> Shrinker b

convert converts a shrinker of a's into a shrinker of b's by taking a two functions to convert to and from b's.

IMPORTANT NOTE: Both functions must be perfectly invertible or else this process may create garbage

By invertible, I mean that f and g are invertible if and only if

f (g x) == g (f x) == x

for all x.

Now we can very simply implement a shrinker of arrays as follows:

array : Shrinker a -> Shrinker (Array a)
array shrinker =
  convert (Array.fromList) (Array.toList) (list shrinker)

And, ta-da... 0 brain cells were used to get a shrinker or arrays.