Project Arbitraries with view patterns by Mark Seemann
Write expressive property-based test with QuickCheck and view patterns.
Recently, I was writing some QuickCheck-based tests of some business logic, and since the business logic in question involved a custom domain type called Reservation
, I had to write an Arbitrary
instance for it. Being a dutiful Haskell programmer, I wrapped it in a newtype
in order to prevent warnings about orphaned instances:
newtype ArbReservation = ArbReservation { getReservation :: Reservation } deriving (Show, Eq) instance Arbitrary ArbReservation where arbitrary = do (d, e, n, Positive q, b) <- arbitrary return $ ArbReservation $ Reservation d e n q b
This is all fine as long as you just need one Reservation
in a test, because in that case, you can simply pattern-match it out of ArbReservation
:
testProperty "tryAccept reservation in the past" $ \ (Positive capacity) (ArbReservation reservation) -> let stub (IsReservationInFuture _ next) = next False stub (ReadReservations _ next) = next [] stub (Create _ next) = next 0 actual = iter stub $ runMaybeT $ tryAccept capacity reservation in isNothing actual
Here, reservation
is a Reservation
value because it was pattern-matched out of ArbReservation reservation
. That's just like capacity
is an Int
, because it was pattern-matched out of Positive capacity
.
Incidentally, in the spirit of the previous article, I'm here using in-lined properties implemented as lambda expressions. The lambda expressions use non-idiomatic formatting in order to make the tests more readable (and to prevent horizontal scrolling), but the gist of the matter is that the entire expression has the type Positive Int -> ArbReservation -> Bool
. This is a Testable
property because all the input types have Arbitrary
instances.
Discommodity creeps in #
That's fine for that test case, but for the next, I needed not only a single Reservation
value, but also a list of Reservation
values. Again, with QuickCheck, you can't write a property with a type like Positive Int -> [Reservation] -> ArbReservation -> Bool
, because there's no Arbitrary
instance for [Reservation]
. Instead, you'll need a property with the type Positive Int -> [ArbReservation] -> ArbReservation -> Bool
.
One way to do that is to write the property like this:
testProperty "tryAccept reservation when capacity is insufficient" $ \ (Positive i) reservations (ArbReservation reservation) -> let stub (IsReservationInFuture _ next) = next True stub (ReadReservations _ next) = next $ getReservation <$> reservations stub (Create _ next) = next 0 reservedSeats = sum $ reservationQuantity <$> getReservation <$> reservations capacity = reservedSeats + reservationQuantity reservation - i actual = iter stub $ runMaybeT $ tryAccept capacity reservation in isNothing actual
Here, reservations
has type [ArbReservation]
, so every time the test needs to operate on the values, it first has to pull the Reservation
values out of it using getReservation <$> reservations
. That seems unnecessarily verbose and repetitive, so it'd be nice if a better option was available.
View pattern #
Had I been writing F# code, I'd immediately be reaching for an active pattern, but this is Haskell. If there's one thing, though, I've learned about Haskell so far, it's that, if F# can do something, there's a very good chance Haskell can do it too - only, it may be called something else.
With a vague sense that I'd seen something similar in some Haskell code somewhere, I went looking, and about fifteen minutes later I'd found what I was looking for: a little language extension called view patterns. Just add the language extension to the top of the file where you want to use it:
{-# LANGUAGE ViewPatterns #-}
You can now change the property to pattern-match reservations
out of a function call, so to speak:
testProperty "tryAccept reservation when capacity is insufficient" $ \ (Positive i) (fmap getReservation -> reservations) (ArbReservation reservation) -> let stub (IsReservationInFuture _ next) = next True stub (ReadReservations _ next) = next reservations stub (Create _ next) = next 0 reservedSeats = sum $ reservationQuantity <$> reservations capacity = reservedSeats + reservationQuantity reservation - i actual = iter stub $ runMaybeT $ tryAccept capacity reservation in isNothing actual
The function getReservation
has the type ArbReservation -> Reservation
, so fmap getReservation
is a partially applied function with the type [ArbReservation] -> [Reservation]
. In order to be able to call the overall lambda expression, the caller must supply an [ArbReservation]
value to the view pattern, which means that the type of that argument must be [ArbReservation]
. The view pattern then immediately unpacks the result of the function and gives you reservations
, which is the return value of calling fmap getReservation
with the input value(s). Thus, reservations
has the type [Reservation]
.
The type of the entire property is now Positive Int -> [ArbReservation] -> ArbReservation -> Bool
.
This removes some noise from the body of the property, so I find that this is a useful trick in this particular situation.
Summary #
You can use the view patterns GHC language extension when you need to write a function that takes an argument of a particular type, but you never care about the original input, but instead immediately wish to project it to a different value.
I haven't had much use for it before, but it seems to be useful in the context of QuickCheck properties.
Comments
I've seen folks wrap up the view pattern in a pattern synonym:
(
coerce
is usually more efficient thanfmap
.)OTOH I don't think orphan instances of
Arbitrary
are very harmful. It's unlikely that they'll get accidentally imported or overlap, becauseArbitrary
is purely used for testing. So in this specific case I'd probably just stick with an orphan instance and turn off the warning for that file.Benjamin, thank you for the pattern synonyms tip; I'll have to try that next time.
Regarding orphaned instances, your point is something I've been considering myself, but I'm still at the point of my Haskell journey where I'm trying to understand the subtleties of the ecosystem, so I wasn't sure whether or not it'd be a good idea to allow orphaned
Arbitrary
instances.When you suggest turning off the warning for a file, do you mean adding an
{-# OPTIONS_GHC -fno-warn-orphans #-}
pragma, or did you have some other method in mind?Yep I meant
OPTIONS_GHC
.Orphan instances are problematic because of the possibility that they'll be imported unintentionally or overlap with another orphan instance. If you import two modules which both define orphan instances for the same type then there's no way for GHC to know which one you meant when you attempt to use them. Since instances aren't named you can't even specify it manually, and the compiler can't check for this scenario ahead of time because of separate compilation. (Non-orphans are guaranteed unique by the fact that you can't import the parent type/class without importing the instance.)
In the case of
Arbitrary
these problems typically don't apply because the class is intended to be used for testing.Arbitrary
instances are usually internal to your test project and not exported, so the potential for damage is small.Benjamin, thank you for elaborating. That all makes sense to me.