Simplifying assertions with lenses by Mark Seemann
Get ready for some cryptic infix operators.
In a previous article I left you with a remaining problem: A test with an assertion weaker than warranted. In this article, you'll see a few tests like that, and how using lenses may improve the situation.
Weak tests #
The previous article already showed an example of a test I wasn't fully happy with. For convenience, I'll repeat it here.
testCase "Groom two finches" $ let cell1 = Galapagos.CellState (Just samaritan) (mkStdGen 0) cell2 = Galapagos.CellState (Just cheater) (mkStdGen 1) actual = Galapagos.groom Galapagos.defaultParams (cell1, cell2) expected = Just <$> Pair ( finchEq $ samaritan { Galapagos.finchHP = 16 } , finchEq $ cheater { Galapagos.finchHP = 13 } ) in (cellFinchEq <$> Pair actual) @?= expected
Another test exhibits the same problem, but since it's simpler, we'll start with that.
testCase "Age finch" $ let cell = Galapagos.CellState (Just samaritan) (mkStdGen 0) actual = Galapagos.age cell expected = finchEq $ samaritan { Galapagos.finchRoundsLeft = 3 } in cellFinchEq actual @?= Just expected
As you read on, you'll see what makes those tests awkward, but in short, they only compare the Finch part of a cell, rather than comparing entire cells. The reason is that full comparisons make the tests more complicated, and less readable.
Replacing Pair with both #
The problem is one that I rarely run into, because, as I outlined in the previous article (and many times before), if a test is difficult to write, I usually consider a simpler design. Because of Haskell's awkward copy-and-update syntax, I tend to avoid nested record types. (This also applies to F#.) Even so, it helps to know that when you run into nested records, lenses may be a proper response.
Since I prefer to avoid nested data types, I don't use lenses much, but when I have to, I tend to use the lens package, only because I'm of the impression that it's comprehensive and current.
Even so, I only rarely use it, so whenever I decide to pull it in, I need to get reacquainted with it. While I was spelunking the documentation, I came across the both function, and realized that it solves essentially the same problem as Pair from the previous article. So, to get an easy start, I decided to replace Pair with both, before proceeding with my actual pursuit.
The "Groom two finches" test then looked like this:
testCase "Groom two finches" $ let cell1 = Galapagos.CellState (Just samaritan) (mkStdGen 0) cell2 = Galapagos.CellState (Just cheater) (mkStdGen 1) actual = Galapagos.groom Galapagos.defaultParams (cell1, cell2) expected = ( Just $ finchEq $ samaritan { Galapagos.finchHP = 16 } , Just $ finchEq $ cheater { Galapagos.finchHP = 13 } ) in (actual & both %~ cellFinchEq) @?= expected
Notice that actual & both %~ cellFinchEq replaces cellFinchEq <$> Pair actual. In isolation, this is hardly more readable, but on the other hand, I believe that people often mistake unfamiliarity with things being hard to understand. If I imagine that all developers working with this code base are familiar with the lens library, actual & both %~ cellFinchEq may be perfectly legible.
Strengthening assertions the hard way #
Consider the "Age finch" test. The samaritan Finch value has finchRoundsLeft = 4. After each round of the cellular automaton, the age function decreases the value by one.
If I wanted to make that explicit, and also compare the actual CellState to the expected CellState, I could do it with standard Haskell language features, but the test starts to become awkward.
testCase "Age finch" $ let cell = Galapagos.CellState (Just samaritan) (mkStdGen 0) actual = Galapagos.age cell expected = cellStateEq $ cell { Galapagos.cellFinch = (\f -> f { Galapagos.finchRoundsLeft = Galapagos.finchRoundsLeft f - 1 }) <$> Galapagos.cellFinch cell } in cellStateEq actual @?= expected
This is clunky for a number of reasons: The Galapagos.cellFinch field returns the finch found in that cell, but since the cell may also be empty, the return value is a Maybe Finch. This means that any modification must be done with a projection; either fmap or, as shown here, <$>. Inside the lambda expression, I need to query Galapagos.finchRoundsLeft to get the current value, and then use copy-and-update syntax to bind the new value to Galapagos.finchRoundsLeft. And then this entire expression must be bound to Galapagos.cellFinch in order to update cell.
To summarize, both Galapagos.finchRoundsLeft and Galapagos.cellFinch has to appear twice.
The other test, "Groom two finches", involves two cells, so that's just double the cumber.
testCase "Groom two finches" $ let cell1 = Galapagos.CellState (Just samaritan) (mkStdGen 0) cell2 = Galapagos.CellState (Just cheater) (mkStdGen 1) actual = Galapagos.groom Galapagos.defaultParams (cell1, cell2) expected = ( cell1 { Galapagos.cellFinch = (\f -> f { Galapagos.finchHP = 16 }) <$> Galapagos.cellFinch cell1 } , cell2 { Galapagos.cellFinch = (\f -> f { Galapagos.finchHP = 13 }) <$> Galapagos.cellFinch cell2 } ) in (actual & both %~ cellStateEq) @?= (expected & both %~ cellStateEq)
This demonstrates why I originally took a shortcut. Even without trying it out in practice, I have enough experience with Haskell (and F#) to predict exactly this situation. Fortunately, there's a way out.
Setting an inner value #
Not being well-versed in the lens library, I found it prudent to proceed in small steps. My next move was to update finchRoundsLeft in the above "Age finch" test. While I quickly found the -~ operator, I then had to figure out how to define an ASetter for finchRoundsLeft.
All documentation points to making use of makeLenses, but that comes with requirements that I couldn't fulfil. I couldn't change the existing definition of Finch, so I couldn't name the fields according to the required naming convention. I tried to use makeLensesWith from another module, but I couldn't make it work. It's possible that you can make it work if you know what you are doing, but I didn't.
In the end, I just wrote an explicit setter function for finchRoundsLeft:
setRoundsLeft :: Functor f => (Galapagos.Rounds -> f Galapagos.Rounds) -> Galapagos.Finch -> f Galapagos.Finch setRoundsLeft f x = (\r -> x { Galapagos.finchRoundsLeft = r }) <$> f (Galapagos.finchRoundsLeft x)
This enabled me to rewrite the "Age finch" test to this:
testCase "Age finch" $ let cell = Galapagos.CellState (Just samaritan) (mkStdGen 0) actual = Galapagos.age cell expected = cellStateEq $ cell { Galapagos.cellFinch = (setRoundsLeft -~ 1) <$> Galapagos.cellFinch cell } in cellStateEq actual @?= expected
Granted, it's not much of an improvement, but it gave me an idea of how to proceed.
Composing setters #
Not only did I need a setter for finchRoundsLeft, I also needed one for cellFinch. Again, not being able to identify a way to do this in an easier way, I wrote another explicit setter for that purpose:
setFinch :: Functor f => (Maybe Galapagos.Finch -> f (Maybe Galapagos.Finch)) -> Galapagos.CellState -> f Galapagos.CellState setFinch f x = (\finch -> x { Galapagos.cellFinch = finch }) <$> f (Galapagos.cellFinch x)
Armed with that I could finally rewrite "Age finch" to something nice.
testCase "Age finch" $ let cell = Galapagos.CellState (Just samaritan) (mkStdGen 0) actual = Galapagos.age cell expected = cellStateEq $ cell & setFinch . _Just . setRoundsLeft -~ 1 in cellStateEq actual @?= expected
Likewise, with the addition of setHP, I could also rewrite "Groom two finches":
testCase "Groom two finches" $ let cell1 = Galapagos.CellState (Just samaritan) (mkStdGen 0) cell2 = Galapagos.CellState (Just cheater) (mkStdGen 1) actual = Galapagos.groom Galapagos.defaultParams (cell1, cell2) expected = ( cell1 & setFinch . _Just . setHP .~ 16 , cell2 & setFinch . _Just . setHP .~ 13 ) in (actual & both %~ cellStateEq) @?= (expected & both %~ cellStateEq)
That's not too bad, if I may say so.
Combinator golf #
Sometimes I get carried away. It's really nothing to worry about, but only to play with options in order to learn, I decided to address the duplication in the above assertion. Notice that is goes & both %~ cellStateEq twice. That's not something that should bother me, and in any case, if you apply the rule of three, it's too early to refactor.
Even so, I wanted that little bit of extra exercise, so I pulled in on and rewrote the assertion. All the other code is identical to the previous listing.
testCase "Groom two finches" $ let cell1 = Galapagos.CellState (Just samaritan) (mkStdGen 0) cell2 = Galapagos.CellState (Just cheater) (mkStdGen 1) actual = Galapagos.groom Galapagos.defaultParams (cell1, cell2) expected = ( cell1 & setFinch . _Just . setHP .~ 16 , cell2 & setFinch . _Just . setHP .~ 13 ) in ((@?=) `on` (both %~ cellStateEq)) actual expected
To be clear, I do, myself, consider this last edit frivolous. I wouldn't recommend it, and wouldn't use it in a code base shared with other people, but I still find it enjoyable.
Conclusion #
Nested data structures present problems in functional programming, particularly in Haskell, where the record syntax leaves something to be desired. Updating a value nested inside another value is, with plain vanilla code, awkward.
This kind of situation is the main use case for lenses. In this article, you saw how I refactored awkward tests with the lens package.