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 (ShowEq)

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:


pattern ArbReservations :: [Reservation] -> [ArbReservation]
pattern ArbReservations rs <- (coerce -> rs) where ArbReservations rs = coerce rs

foo :: [ArbReservation] -> IO ()
foo (ArbReservations rs) = traverse print rs
			

(coerce is usually more efficient than fmap.)

OTOH I don't think orphan instances of Arbitrary are very harmful. It's unlikely that they'll get accidentally imported or overlap, because Arbitrary 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.

2018-05-26 10:55 UTC

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?

2018-05-27 7:54 UTC

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.

2018-05-27 14:08 UTC

Benjamin, thank you for elaborating. That all makes sense to me.

2018-05-27 16:06 UTC


Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.

Published

Monday, 14 May 2018 08:07:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 14 May 2018 08:07:00 UTC