Have some Hedgehog tests to write? Here’s five useful features you may not know about!
Roundtripping
If you only try one thing on the list this should be it!
When you serialize something, no matter the format, you probably want to be able to deserialize it again and get the same result.
Even if you’re lucky enough to be deriving your encode/decode from a single specification (e.g. using TemplateHaskell
or GHC.Generics
), it’s a good idea to check that this actually works. These deriving tools could well be referring to other instances which are broken, or the deriving could be broken for your particular use case. Ultimately, it’s you who is responsible for your code working in production.
While you can easily implement this check by hand, Hedgehog provides a tripping
function which gives prettier output, including the intermediate value when the roundtrip fails.
tripping
takes the value to roundtrip and the encode/decode functions and checks that decode . encode == id
.
Let’s say you want to test your Aeson ToJSON
/FromJSON
instances, the test will look something like this…
prop_roundtrip :: Property
prop_roundtrip =
property $ do
x <- forAll genUser
tripping x Aeson.encode Aeson.eitherDecode
-- genUser is a generator that you have to write
The tripping
function’s general type is a bit scary, but things become clearer when you constrain the types of your encode/decode functions.
-- general type
tripping :: (
MonadTest m
, Show b
, Show (f a)
, Eq (f a)
, Applicative f
)
=> a
-> (a -> b)
-> (b -> f a)
-> m ()
-- specialized for aeson
\x -> tripping x Aeson.encode Aeson.eitherDecode
:: (MonadTest m, Show a, Eq a, ToJSON a, FromJSON a) => a -> m ()
Failures include the intermediate value which makes it much quicker to see whether it’s the encode or the decode which has a problem.
┏━━ test/Article.hs ━━━
44 ┃ prop_roundtrip :: Property
45 ┃ prop_roundtrip =
46 ┃ property $ do
47 ┃ x <- forAll genUser
┃ │ User { userId = "00000" , userName = "stephanie" }
48 ┃ tripping x Aeson.encode Aeson.eitherDecode
┃ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
┃ │ ━━━ Intermediate ━━━
┃ │ "{\"userName\":\"stephanie\",\"userId\":\"00007\"}"
┃ │ ━━━ - Original) (+ Roundtrip ━━━
┃ │ - Right User { userId = "00000" , userName = "stephanie" }
┃ │ + Right User { userId = "00007" , userName = "stephanie" }
In the above example you can easily see that the incorrect id 00007
is in the intermediate data so it must be the encoder which is broken.
Exceptions
Love them or hate them, exceptions are a fact of life in Haskell.
Having exceptions occur during a test is great! Hopefully it means you’ve found a bug. What’s not so great is that you probably have no idea which line caused the exception. This can be really annoying as tests get larger and involve more IO.
Hedgehog has your back with evalIO
. The general rule is whenever you would use liftIO
, just use evalIO
instead.
evalIO
fails the test if an exception is thrown, but the practical difference from liftIO
is that the location of the exception will be shown in the failure output. With liftIO
the failure is instead attributed to the test as a whole, so you’ll need to do some detective work.
In this liftIO
example, the cause of the failure isn’t obvious.
┏━━ test/Article.hs ━━━
57 ┃ prop_launch :: Property
58 ┃ prop_launch =
59 ┃ withTests 1 . property $ do
┃ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
┃ │ ━━━ Exception (KeyStuckLaunchFailed) ━━━
┃ │ KeyStuckLaunchFailed
60 ┃ liftIO $ launchMissiles 3
61 ┃ liftIO $ launchMissiles 2
62 ┃ liftIO $ launchMissiles 1
With evalIO
, you can see the exact line.
┏━━ test/Article.hs ━━━
57 ┃ prop_launch :: Property
58 ┃ prop_launch =
59 ┃ withTests 1 . property $ do
60 ┃ evalIO $ launchMissiles 3
61 ┃ evalIO $ launchMissiles 2
┃ ^^^^^^^^^^^^^^^^^^^^^^^^^
┃ │ ━━━ Exception (KeyStuckLaunchFailed) ━━━
┃ │ KeyStuckLaunchFailed
62 ┃ evalIO $ launchMissiles 1
Hedgehog has a family of evalXXX functions which offer this functionality, depending on the situation. If you are using ExceptT
for errors I highly recommend trying evalExceptT
.
-- Fails the test if the value throws an exception when evaluated to weak
-- head normal form (WHNF).
eval :: (MonadTest m, HasCallStack) => a -> m a
-- Fails the test if the action throws an exception.
evalM :: (MonadTest m, MonadCatch m, HasCallStack) => m a -> m a
-- Fails the test if the IO action throws an exception.
evalIO :: (MonadTest m, MonadIO m, HasCallStack) => IO a -> m a
-- Fails the test if the Either is Left, otherwise returns the value in
-- the Right.
evalEither ::
(MonadTest m, Show x, HasCallStack) => Either x a -> m a
-- Fails the test if the ExceptT is Left, otherwise returns the value in
-- the Right.
evalExceptT ::
(MonadTest m, Show x, HasCallStack) => ExceptT x m a -> m a
Resource Cleanup
Real world tests use real world resources. Cleaning up temporary files and temporary databases is even more important when using property-based testing as your test code will be exercised many times more than with traditional testing approaches.
Brand new for 2020, Hedgehog finally has a MonadBaseControl
instance for PropertyT
, this will give you a lot more flexibility with which libraries can work right inside a property.
So, with the hedgehog-1.0.2
release, try using bracket
from lifted-base
to do resource cleanup in your tests.
The code below creates a temporary directory that will be cleaned up no matter how the test passes or fails.
prop_use_tmpdir :: Property
prop_use_tmpdir =
property $ do
x <- forAll ...
y <- forAll ...
tmpdir <- Temp.getTemporaryDirectory -- aka TMPDIR
bracket
(liftIO $ Temp.createTempDirectory tmpdir "my_prop_files")
(liftIO . Directory.removeDirectoryRecursive) $ \dir -> do
-- test assertions begin
Hedgehog traditionally used ResourceT
for this purpose, but as of resourcet-1.2
it is no longer a viable option. Hedgehog will never be able to implement MonadUnliftIO
due to its transformer stack.
ResourceT
still works but you’ll be forever limited to resourcet-1.1
.
Coverage
Ever think to yourself, “How do I know if my generators are good enough?”
Hedgehog’s coverage combinators will help you answer that question.
cover
records the number of times a predicate is satisfied and displays the result as a percentage. If the percentage doesn’t meet your threshold then the test fails.
Below is a small example of the kind of output you get when a test fails its coverage obligations.
✗ prop_cover_number failed
after 101 tests.
⚠ small number 25% ████▉··············· ✗ 50%
medium number 57% ███████████▍········ ✓ 15%
big number 10% █▉·················· ✓ 5%
>10 number 71% ██████████████▎·····
┏━━ hedgehog-example/src/Test/Example/Coverage.hs ━━━
71 ┃ prop_cover_number :: Property
72 ┃ prop_cover_number =
73 ┃ withTests 101 . property $ do
74 ┃ number <- forAll (Gen.int $ Range.linear 1 100)
75 ┃ cover 50 "small number" $ number < 10
┃ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
┃ │ Failed (25% coverage)
76 ┃ cover 15 "medium number" $ number >= 20
77 ┃ cover 5 "big number" $ number >= 70
78 ┃ when (number > 10) $ label ">10 number"
Oskar Wickström has published a wonderful tutorial on applying property-based testing (PBT) to the real world problem of form validation which I highly recommend! He makes use of cover
to test that his generators are producing inputs which adequately exercise the edge cases involved around dates and leap years.
classify works the same as cover
but is purely informational and doesn’t have a threshold below which it will fail the test.
classify "even" $ n `mod` 2 == 0
label is like classify
but doesn’t have a predicate, so it simply tracks the percentage of tests run which hit a certain line of code.
label "branch-x"
collect is like label
but uses Show
on its argument to create the label name.
collect someCounter
Threshold | Predicate | String | Show | |
cover | ✓ | ✓ | ✓ | |
classify | ✓ | ✓ | ||
label | ✓ | |||
collect | ✓ |
Printing
Hedgehog’s major point of difference with QuickCheck is how it approaches shrinking. For the most part you don’t have to worry about it, but if you want to construct high quality generators which cause easy to understand counterexamples and don’t take a million iterations to do so, then you should be looking at what happens when they shrink.
Check out Gen.print which generates a random sample from your generator and also the first level of shrinks, this is great to get an understanding of what is going on under the hood of your generator. This can produce a lot of output for big top-level data types so it’s better used for making sure your more primitive generators are shrinking well. Things like string identifiers often produce too many shrinks, testing cases that aren’t that interesting.
Here we generate the number 64
. You can see that Hedgehog would first try to shrink to the smallest possible value in the range (i.e. 1
) and would gradually cut its distance to 64
by half each time it doesn’t find a failure.
ghci> Gen.print (Gen.int (Range.constant 1 100))
=== Outcome ===
64
=== Shrinks ===
1
32
48
56
60
62
63
Here we generate the string "acc"
, this is a bit more interesting. We first try the empty string and then all combinations of the 2-letter substrings, then we try keeping the length of the string the same and shrinking the characters themselves.
ghci> Gen.print (Gen.string (Range.constant 0 3) (Gen.element ['a','b','c']))
=== Outcome ===
"acc"
=== Shrinks ===
""
"cc"
"ac"
"ac"
"aac"
"abc"
"aca"
"acb"
As with Gen.print
, Gen.printTree
generates a random sample from your generator, but this time we can see the entire shrink tree. This is rarely practical for even modest generators, but it’s super useful for debugging if you can isolate your problem shrinking problem down to a smaller generator. In theory a web interface for this could take advantage of the shrink tree’s laziness and it would be practical for displaying all generators.
ghci> Gen.printTree (Gen.string (Range.constant 0 3) (Gen.element ['a','b','c']))
"aca"
├╼""
├╼"ca"
│ ├╼""
│ ├╼"a"
│ │ └╼""
│ ├╼"c"
│ │ ├╼""
│ │ ├╼"a"
│ │ │ └╼""
│ │ └╼"b"
│ │ ├╼""
│ │ └╼"a"
│ │ └╼""
│ ├╼"aa"
│ │ ├╼""
│ │ ├╼"a"
│ │ │ └╼""
│ │ └╼"a"
│ │ └╼""
│ └╼"ba"
.. snip ..
It’s a good idea to check your lower level combinators like this especially things involving strings as they can easy cause a blow up in the space of values you’re trying to search. Remember that your shrink search space is basically the product of all the generators you use in it. So think about what you’re trying to test, there may be places which don’t benefit from a highly variable generator.
ghci> Gen.print (Gen.string (Range.constant 0 10) Gen.alphaNum)
=== Outcome ===
"Ft92KHpEw"
=== Shrinks ===
""
"KHpEw"
"Ft92w"
"Ft92KHpE"
"92KHpEw"
"FtKHpEw"
"Ft92pEw"
"Ft92KHw"
.. _many_ more lines ..
For things like user ids you can usually get away with using Gen.element on a handful of strings, as you just want a few different possibilities and not totally random data.
The hedgehog-corpus has a few fun lists to try, it’s always entertaining when your test failures are complaining about "agile alligator"
.
ghci> Gen.print $ mconcat [Gen.element Corpus.agile, pure (" " :: Text), Gen.element Corpus.animals]
=== Outcome ===
"test driven elephant"
=== Shrinks ===
"agile elephant"
"pair programming elephant"
"scrum master elephant"
"standup elephant"
"story points elephant"
"test driven alligator"
"test driven chimpanzee"
"test driven dog"
"test driven duck"
"test driven eagle"
Try it
I hope you learned something! I’m sure even Oskar Wickström didn’t know about all of these things.
Subscribers should feel free to email me directly about any of this stuff, I may not be able to respond immediately but I’m always happy to help!
Credits
Photo by Sierra Narvaeth on Unsplash