Property testing helps developers express their intent in their tests more clearly while at the same time allowing them to surface more bugs in less time with fewer lines of code.
### Slide 2: What is a Property Test?
So what is a property test?
Ordinary example-based tests -- the kind we're all familiar with -- assert that, for a particular input, your program or function produces a particular output.
Property tests allow you to make a stronger assertion: that, for any acceptable input, your program produces an output where some property holds.
A generative property testing framework will repeatedly ensure that your output property holds for every input it generates.
Property tests share a lot in common with contract systems: they both provide runtime facilities for making assertions about the values in your program. But where contracts are only run against the values that end up reaching that part of the program, the property testing framework will run your program or function against all acceptable inputs.
Let's look at some of the assertions we can make in a property test:
* For any list, regardless of the number or kind of its elements, reversing it twice will give you the input list.
* For any list, regardless of the number or kind of its elements, sorting it twice will give you the same result as sorting it once.
### Slide 3: Prior Art
As far as I'm aware, Haskell's QuickCheck was the first widely-adopted property testing library for a general-purpose programming language.
Clojure's clojure.spec had a big hand in popularising property testing, and now property testing libraries exist for many popular programming languages.
Modern libraries have added more advanced testing features such as StrongCheck's exhaustive and statistical tests.
And the state-of-the-art libraries like Jack are even rethinking their input generation to provide the best possible counterexamples.
### Slide 4: Built-in Generators
The testing library we'll be using is called "gentest". Its built-in generators can be used for generating
* subsets of the integers
* and Booleans.
### Slide 5: Sampling a Generator
The way we get values out of a generator is by sampling it.
gentest provides a "sample" function which takes a generator and a number of values to generate. The default is 10.
For built-in generators, later values are more "complex"; integers get larger magnitudes, arrays get longer, and so on.
### Slide 6
In this very simple test, we are trying to make sure the encoder is fully deterministic in its integer serialisation.
### Slide 7
I've grayed out everything but the important bits. You can see here that we're sampling integers and making sure that calling encode on them more than once never produces different results.
Let's look at another example.
### Slide 8
A common property to test when we have both a function and its inverse is its ability to round-trip.
Here, we're sampling integers and ensuring that, after round-tripping through our serialisation library, we get the same integer back.
### Slide 9: Functions Which Produce Generators
The built-in generators aren't going to be sufficient for most use-cases.
That's why gentest provides us with some functions we can use to create our own generators.
The "elements" function turns any non-empty array into a generator. When sampled, this generator produces random elements from the given array.
"constantly" is simply a shorthand for the single element case.
### Slide 10: Generator Combinators: arrayOf
Some of the generator-producing functions that gentest gives us take other generators as input. We call these functions "combinators".
"arrayOf" takes a generator and gives us a generator of arrays. The elements of the array are sampled from the given generator.
### Slide 11: Generator Combinators: arrayOf
So if we pass "bool" instead of "int", the generated arrays are filled with Booleans instead of integers.
### Slide 12: Generator Combinators: arrayOf
And since this is just a generator like any other, we can pass it to "arrayOf" to create arrays of arrays!
### Slide 13
Back in the tests for our serialisation library, we can use arrayOf to confirm that round-tripping arrays preserves their length.
### Slide 14: Generator Combinators: oneOf/shape
Other combinators include
* "oneOf", which samples at random from the given generators
* "shape", which generates records (the value of each field is sampled from the respective generator)
### Slide 15: Generator Combinators: suchThat
* And "suchThat", which uses the given predicate to filter the values generated by the given generator
The top example ensures that each of the generated arrays is non-empty. The bottom example ensures that each of the generated integers is even.
We need to be careful when using suchThat to construct generators. A predicate that will rarely be true could cause the generator to be very expensive to sample.
It's better to generate values through construction rather than filtering. For this, we need a way to map over generators.
### Slide 16: fmap
"fmap" produces a generator that applies a function to each value generated by the given generator.
The top example shows how we can create an "even integers" generator through construction instead of filtering. "Odds" (below) isn't much different.
### Slide 17
In our test suite, we can use fmap with the absolute value function to make assertions about how our encoder treats non-negative integers.
### Slide 18: bind
But we can't create the non-empty array generator we saw earlier through construction using just fmap. For that, we'll need something more powerful.
"bind" is a function with a similar signature to fmap. Where the function given to fmap would take a generated value and produce an alternative generated value, the function given to bind takes a generated value and produces an alternative generator from which the value to be generated can be sampled. This small change makes bind so powerful, we could define every other function we've seen so far in terms of it.
### Slide 19: bind
This is what our "nonEmptyArrayOf" generator looks like, implemented in terms of bind.
In short, it puts another element on the front of any array generated by the arrayOf generator, ensuring the array will never be empty.
### Slide 20: Areas for Improvement
All of this is great, but it is far from perfect.
Shrinking is a common process for automatically minimising a counterexample once one is found. This technique is becoming very common, and some people would expect any generative property testing library to include it.
Some other property testing libraries have integration with the Flow type checker for automatically defining generators for your types.
And there's even academic projects looking into how a counterexample can produce a suggested resolution.
### Slide 21
Here's where the library we've been looking at lives on GitHub.
### Slide 22
And that's all.