Joseph Haugh
University of New Mexico
Suppose we write a sorting function and want to test it.
The unit testing approach: write specific examples.
sort [3,1,2] == [1,2,3] -- test 1
sort [] == [] -- test 2
sort [1] == [1] -- test 3
sort [5,4,3,2] == [1,2,3,4] -- wrong expected value
Each test requires us to:
The deeper issue: unit tests are examples, not specifications.
A test like sort [3,1,2] == [1,2,3] tells us the output
for one particular input.
But what we really want to say is:
For all lists, sorting produces an ordered list that contains the same elements.
That is a property, a universally quantified law.
A property is a Haskell function that returns Bool (or Property).
prop_SortOrdered :: [Int] -> Bool
prop_SortOrdered xs = ordered (sort xs)
where
ordered [] = True
ordered [_] = True
ordered (x:y:zs) = x <= y && ordered (y:zs)
This says: for every list of Ints, sort produces an ordered result.
QuickCheck will generate hundreds of random lists and check that this holds for all of them.
QuickCheck was introduced by Claessen and Hughes in 2000. The central idea: let the computer choose the test inputs.
import Test.QuickCheck
main :: IO ()
main = quickCheck prop_SortOrdered
OK, passed 100 tests.
QuickCheck generates 100 random lists of varying lengths and contents, runs our property on each one, and reports whether any failed.
Let us start with a simpler example from the original paper: reverse.
What laws must reverse satisfy? From mathematics:
reverse [x] = [x]
reverse (xs ++ ys) = reverse ys ++ reverse xs
reverse (reverse xs) = xs
The first two laws characterize reverse uniquely; the third follows from them.
prop_RevSingle :: Int -> Bool
prop_RevSingle x =
reverse [x] == [x]
prop_RevApp :: [Int] -> [Int] -> Bool
prop_RevApp xs ys =
reverse (xs ++ ys) == reverse ys ++ reverse xs
prop_RevRev :: [Int] -> Bool
prop_RevRev xs =
reverse (reverse xs) == xs
ghci> quickCheck prop_RevApp
OK, passed 100 tests.
ghci> quickCheck prop_RevRev
OK, passed 100 tests.
Suppose we accidentally write prop_RevApp backwards:
prop_RevApp_Wrong :: [Int] -> [Int] -> Bool
prop_RevApp_Wrong xs ys =
reverse (xs ++ ys) == reverse xs ++ reverse ys
ghci> quickCheck prop_RevApp_Wrong
Falsifiable, after 1 tests:
[0]
[1,-1]
QuickCheck found a counterexample immediately: with xs = [0] and
ys = [1,-1], the left side gives [1,-1,0] but the right gives [0,1,-1].
Some properties only hold under a precondition.
Consider insert, which inserts an element into an already-ordered list:
insert :: Ord a => a -> [a] -> [a]
insert x [] = [x]
insert x (y:ys)
| x <= y = x : y : ys
| otherwise = y : insert x ys
The property: inserting into an ordered list yields an ordered list.
But this only makes sense when the input list is already ordered.
QuickCheck provides (==>) to express conditional properties:
ordered :: Ord a => [a] -> Bool
ordered [] = True
ordered [_] = True
ordered (x:y:zs) = x <= y && ordered (y:zs)
prop_Insert :: Int -> [Int] -> Property
prop_Insert x xs =
ordered xs ==> ordered (insert x xs)
If the precondition ordered xs is False, QuickCheck discards that test case and tries another.
ordered :: Ord a => [a] -> Bool
ordered [] = True
ordered [_] = True
ordered (x:y:zs) = x <= y && ordered (y:zs)
prop_Insert :: Int -> [Int] -> Property
prop_Insert x xs =
ordered xs ==> ordered (insert x xs)
ghci> quickCheck prop_Insert
OK, passed 100 tests.
Looks good! But there is a hidden problem.
How many of those 100 tests actually exercised a list with more than one element?
We can ask QuickCheck to report what it is testing with collect:
prop_Insert_Report :: Int -> [Int] -> Property
prop_Insert_Report x xs =
ordered xs ==>
collect (length xs) $
ordered (insert x xs)
OK, passed 100 tests.
49% 0.
32% 1.
12% 2.
4% 3.
2% 4.
1% 5.
Only 19 out of 100 tests had a list with more than one element!
Why? The precondition ordered xs silently skews the distribution:
We are mostly testing trivial cases: inserting into empty or singleton lists.
Instead of generating random lists and discarding the unordered ones, we should generate ordered lists directly:
orderedList :: (Arbitrary a, Ord a) => Gen [a]
orderedList = fmap sort arbitrary
We use forAll to supply a custom generator:
prop_Insert_Good :: Int -> Property
prop_Insert_Good x =
forAll orderedList $ \xs ->
ordered (insert x xs)
ghci> quickCheck prop_Insert_Good
OK, passed 100 tests.
Now every test case is a genuinely interesting ordered list.
How does QuickCheck know how to generate random Int values, or random [Int] values?
class Arbitrary a where
arbitrary :: Gen a
Gen a is a generator, a computation that produces random values of type a.
QuickCheck uses type inference to pick the right instance automatically.
When it sees prop :: [Int] -> Bool, it knows it needs
arbitrary :: Gen [Int].
instance Arbitrary Bool where
arbitrary = elements [True, False]
instance Arbitrary Int where
arbitrary = sized (\n -> choose (-n, n))
elements picks uniformly from a list. sized gives access to a size parameter that QuickCheck grows across tests. choose generates a random value in a range.
instance Arbitrary Bool where
arbitrary = elements [True, False]
instance Arbitrary Int where
arbitrary = sized (\n -> choose (-n, n))
instance (Arbitrary a, Arbitrary b) => Arbitrary (a, b) where
arbitrary = do
x <- arbitrary
y <- arbitrary
return (x, y)
instance Arbitrary a => Arbitrary [a] where
arbitrary = sized $ \n -> do
k <- choose (0, n)
sequence (replicate k arbitrary)
Instances for pairs and lists are derived from instances for their element types.
data Expr
= Num Int
| Add Expr Expr
| Mul Expr Expr
The size parameter starts at 0 to force a base case, then grows with each recursive level, halved at each branch.
instance Arbitrary Expr where
arbitrary = sized arbExpr
where
arbExpr 0 =
fmap Num arbitrary
arbExpr n = oneof
[ fmap Num arbitrary
, do l <- arbExpr (n `div` 2)
r <- arbExpr (n `div` 2)
return (Add l r)
, do l <- arbExpr (n `div` 2)
r <- arbExpr (n `div` 2)
return (Mul l r)
]
Without sized, a recursive generator can fail to terminate.
Consider a naive attempt:
-- DANGER: may loop forever!
instance Arbitrary Expr where
arbitrary = oneof
[ fmap Num arbitrary
, liftA2 Add arbitrary arbitrary
, liftA2 Mul arbitrary arbitrary
]
oneof picks each branch with equal probability (1/3). But the two recursive branches each call arbitrary twice, so the expected number of nodes is infinite.
sized lets us shrink the bound at each recursive call, guaranteeing termination and controlling tree depth.
| Combinator | Meaning |
|---|---|
elements xs |
Pick uniformly from xs |
choose (lo, hi) |
Random value in range |
oneof gens |
Pick uniformly from generators |
frequency [(w,g)] |
Pick generator g with weight w |
sized f |
Pass size bound to f |
forAll gen prop |
Use custom generator gen |
frequency is useful when you want one branch to be picked more or less often than others:
arbitrary = frequency
[ (1, return []) -- 1/5 chance of []
, (4, liftA2 (:) arbitrary arbitrary) ] -- 4/5 chance of non-empty
When QuickCheck finds a failing case, the counterexample may be large and hard to understand. Shrinking automatically finds a smaller failing case.
The Arbitrary typeclass has an optional shrink method:
class Arbitrary a where
arbitrary :: Gen a
shrink :: a -> [a]
shrink _ = [] -- default: no shrinking
shrink x should return a list of values that are “smaller” than x in some meaningful sense.
For lists, shrinking means: remove elements, or shrink individual elements.
instance Arbitrary a => Arbitrary [a] where
shrink [] = []
shrink (x:xs) =
xs : map (:xs) (shrink x)
++ map (x:) (shrink xs)
For integers, shrinking means: move toward zero.
instance Arbitrary Int where
shrink n | n > 0 = [n-1, n `div` 2]
| n < 0 = [n+1, n `div` 2]
| otherwise = []
Suppose we test whether all lists are palindromes:
prop_Palindrome :: [Int] -> Bool
prop_Palindrome xs = reverse xs == xs
Without shrinking, QuickCheck might report:
Falsifiable, after 3 tests:
[-3, 0, 2, 5, 1, -2]
With shrinking, it reports:
Falsifiable, after 3 tests and 8 shrinks:
[0, 1]
The minimal counterexample: the shortest non-palindrome with the smallest possible values. Much easier to understand and reason about.
When a property fails on input x:
shrink x, the list of “smaller” candidatesThis is a greedy search: QuickCheck keeps shrinking as long as it can find a smaller failing case. The result is a locally minimal counterexample: every element of it is necessary to trigger the bug.
QuickCheck blurs the line between tests and documentation.
A property like:
prop_SortCorrect :: [Int] -> Bool
prop_SortCorrect xs =
let ys = sort xs
in ordered ys && sameElements xs ys
is simultaneously:
The QuickCheck paper reports a striking finding: when testing real programs, bugs found are divided roughly evenly into three categories.
Bugs in the generator: the test data does not cover the cases
you think it does (as we saw with ordered xs ==>)
Bugs in the specification: the property itself is wrong; you had a mistaken belief about what the function should do
Bugs in the implementation: the actual code is wrong
All three are valuable. Finding a specification bug reveals a misunderstanding, even if the code happens to be correct.
Claessen and Hughes tested two propositional theorem provers (Davis-Putnam and Stalmarck) against each other with:
prop_Agree :: Formula -> Property
prop_Agree f =
davisPutnam f == stalmarck f
QuickCheck found three bugs, all due to implicit assumptions:
Hand-written tests would likely never have generated these edge cases.
Unit Tests
sort [3,1,2] == [1,2,3]
sort [] == []
sort [5] == [5]
sort [2,2] == [2,2]
Properties
prop_Sorted xs =
ordered (sort xs)
prop_Stable xs =
sameElements xs (sort xs)
Properties and unit tests are complementary, not opposed.
import Test.QuickCheck
-- Basic property
prop_RevRev :: [Int] -> Bool
prop_RevRev xs = reverse (reverse xs) == xs
-- Conditional property
prop_Insert :: Int -> [Int] -> Property
prop_Insert x xs =
ordered xs ==> ordered (insert x xs)
-- Custom generator
prop_InsertGood :: Int -> Property
prop_InsertGood x =
forAll orderedList $ \xs ->
ordered (insert x xs)
main :: IO ()
main = do
quickCheck prop_RevRev
quickCheck prop_Insert
quickCheck prop_InsertGood