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.



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, 09 February 2026 13:28:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 09 February 2026 13:28:00 UTC