How do you do state-based testing when state is immutable? You use the State monad.

This article is an instalment in an article series about how to move from interaction-based testing to state-based testing. In the previous article, you saw an example of an interaction-based unit test written in C#. The problem that this article series attempts to address is that interaction-based testing can lead to what xUnit Test Patterns calls Fragile Tests, because the tests get coupled to implementation details, instead of overall behaviour.

My experience is that functional programming is better aligned with unit testing because functional design is intrinsically testable. While I believe that functional programming is no panacea, it still seems to me that we can learn many valuable lessons about programming from it.

People often ask me about F# programming: How do I know that my F# code is functional?

I sometimes wonder that myself, about my own F# code. One can certainly choose to ignore such a question as irrelevant, and I sometimes do, as well. Still, in my experience, asking such questions can create learning opportunities.

The best answer that I've found is: Port the F# code to Haskell.

Haskell enforces referential transparency via its compiler. If Haskell code compiles, it's functional. In this article, then, I take the problem from the previous article and port it to Haskell.

The code shown in this article is available on GitHub.

A function to connect two users #

In the previous article, you saw implementation and test coverage of a piece of software functionality to connect two users with each other. This was a simplification of the example running through my two Clean Coders videos, Church Visitor and Preserved in translation.

In contrast to the previous article, we'll start with the implementation of the System Under Test (SUT).

post :: Monad m =>
        (a -> m (Either UserLookupError User)) ->
        (User -> m ()) ->
        a ->
        a ->
        m (HttpResponse User)
post lookupUser updateUser userId otherUserId = do
  userRes <- first (\case
      InvalidId -> "Invalid user ID."
      NotFound  -> "User not found.")
    <$> lookupUser userId
  otherUserRes <- first (\case
      InvalidId -> "Invalid ID for other user."
      NotFound  -> "Other user not found.")
    <$> lookupUser otherUserId

  connect <- runExceptT $ do
      user <- ExceptT $ return userRes
      otherUser <- ExceptT $ return otherUserRes
      lift $ updateUser $ addConnection user otherUser
      return otherUser

  return $ either BadRequest OK connect

This is as direct a translation of the C# code as makes sense. If I'd only been implementing the desired functionality in Haskell, without having to port existing code, I'd designed the code differently.

This post function uses partial application as an analogy to dependency injection, but in order to enable potentially impure operations to take place, everything must happen inside of some monad. While the production code must ultimately run in the IO monad in order to interact with a database, tests can choose to run in another monad.

In the C# example, two dependencies are injected into the class that defines the Post method. In the above Haskell function, these two dependencies are instead passed as function arguments. Notice that both functions return values in the monad m.

The intent of the lookupUser argument is that it'll query a database with a user ID. It'll return the user if present, but it could also return a UserLookupError, which is a simple sum type:

data UserLookupError = InvalidId | NotFound deriving (ShowEq)

If both users are found, the function connects the users and calls the updateUser function argument. The intent of this 'dependency' is that it updates the database. This is recognisably a Command, since its return type is m () - unit (()) is equivalent to void.

State-based testing #

How do you unit test such a function? How do you use Mocks and Stubs in Haskell? You don't; you don't have to. While the post method can be impure (when m is IO), it doesn't have to be. Functional design is intrinsically testable, but that proposition depends on purity. Thus, it's worth figuring out how to keep the post function pure in the context of unit testing.

While IO implies impurity, most common monads are pure. Which one should you choose? You could attempt to entirely 'erase' the monadic quality of the post function with the Identity monad, but if you do that, you can't verify whether or not updateUser was invoked.

While you could write an ad-hoc Mock using, for example, the Writer monad, it might be a better choice to investigate if something closer to state-based testing would be possible.

In an object-oriented context, state-based testing implies that you exercise the SUT, which mutates some state, and then you verify that the (mutated) state matches your expectations. You can't do that when you test a pure function, but you can examine the state of the function's return value. The State monad is an obvious choice, then.

A Fake database #

Haskell's State monad is parametrised on the state type as well as the normal 'value type', so in order to be able to test the post function, you'll have to figure out what type of state to use. The interactions implied by the post function's lookupUser and updateUser arguments are those of database interactions. A Fake database seems an obvious choice.

For the purposes of testing the post function, an in-memory database implemented using a Map is appropriate:

type DB = Map Integer User

This is simply a dictionary keyed by Integer values and containing User values. You can implement compatible lookupUser and updateUser functions with State DB as the Monad. The updateUser function is the easiest one to implement:

updateUser :: User -> State DB ()
updateUser user = modify $ Map.insert (userId user) user

This simply inserts the user into the database, using the userId as the key. The type of the function is compatible with the general requirement of User -> m (), since here, m is State DB.

The lookupUser Fake implementation is a bit more involved:

lookupUser :: String -> State DB (Either UserLookupError User)
lookupUser s = do
  let maybeInt = readMaybe s :: Maybe Integer
  let eitherInt = maybe (Left InvalidId) Right maybeInt
  db <- get
  return $ eitherInt >>= maybe (Left NotFound) Right . flip Map.lookup db

First, consider the type. The function takes a String value as an argument and returns a State DB (Either UserLookupError User). The requirement is a function compatible with the type a -> m (Either UserLookupError User). This works when a is String and m is, again, State DB.

The entire function is written in do notation, where the inferred Monad is, indeed, State DB. The first line attempts to parse the String into an Integer. Since the built-in readMaybe function returns a Maybe Integer, the next line uses the maybe function to handle the two possible cases, converting the Nothing case into the Left InvalidId value, and the Just case into a Right value.

It then uses the State module's get function to access the database db, and finally attempt a lookup against that Map. Again, maybe is used to convert the Maybe value returned by Map.lookup into an Either value.

Happy path test case #

This is all you need in terms of Test Doubles. You now have test-specific lookupUser and updateUser functions that you can pass to the post function.

Like in the previous article, you can start by exercising the happy path where a user successfully connects with another user:

testProperty "Users successfully connect" $ \
  user otherUser -> runStateTest $ do
 
  put $ Map.fromList [toDBEntry user, toDBEntry otherUser]

  actual <- post lookupUser updateUser (show $ userId user) (show $ userId otherUser)
 
  db <- get
  return $
    isOK actual &&
    any (elem otherUser . connectedUsers) (Map.lookup (userId user) db)

Here I'm inlining test cases as anonymous functions - this time expressing the tests as QuickCheck properties. I'll later return to the runStateTest helper function, but first I want to focus on the test body itself. It's written in do notation, and specifically, it runs in the State DB monad.

user and otherUser are input arguments to the property. These are both User values, since the test also defines Arbitrary instances for that type (not shown in this article; see the source code repository for details).

The first step in the test is to 'save' both users in the Fake database. This is easily done by converting each User value to a database entry:

toDBEntry :: User -> (IntegerUser)
toDBEntry = userId &&& id

Recall that the Fake database is nothing but an alias over Map Integer User, so the only operation required to turn a User into a database entry is to extract the key.

The next step in the test is to exercise the SUT, passing the test-specific lookupUser and updateUser Test Doubles to the post function, together with the user IDs converted to String values.

In the assert phase of the test, it first extracts the current state of the database, using the State library's built-in get function. It then verifies that actual represents a 200 OK value, and that the user entry in the database now contains otherUser as a connected user.

Missing user test case #

While there's one happy-path test case, there's four other test cases left. One of these is when the first user doesn't exist:

testProperty "Users don't connect when user doesn't exist" $ \
  (Positive i) otherUser -> runStateTest $ do
 
  let db = Map.fromList [toDBEntry otherUser]
  put db
  let uniqueUserId = show $ userId otherUser + i
 
  actual <- post lookupUser updateUser uniqueUserId (show $ userId otherUser)
 
  assertPostFailure db actual

What ought to trigger this test case is that the 'first' user doesn't exist, even if the otherUser does exist. For this reason, the test inserts the otherUser into the Fake database.

Since the test is a QuickCheck property, i could be any positive Integer value - including the userId of otherUser. In order to properly exercise the test case, however, you'll need to call the post function with a uniqueUserId - thas it: an ID which is guaranteed to not be equal to the userId of otherUser. There's several options for achieving this guarantee (including, as you'll see soon, the ==> operator), but a simple way is to add a non-zero number to the number you need to avoid.

You then exercise the post function and, as a verification, call a reusable assertPostFailure function:

assertPostFailure :: (Eq s, Monad m) => s -> HttpResponse a -> StateT s m Bool
assertPostFailure stateBefore resp = do
  stateAfter <- get
  let stateDidNotChange = stateBefore == stateAfter
  return $ stateDidNotChange && isBadRequest resp

This function verifies that the state of the database didn't change, and that the response value represents a 400 Bad Request HTTP response. This verification doesn't actually verify that the error message associated with the BadRequest case is the expected message, like in the previous article. This would, however, involve a fairly trivial change to the code.

Missing other user test case #

Similar to the above test case, users will also fail to connect if the 'other user' doesn't exist. The property is almost identical:

testProperty "Users don't connect when other user doesn't exist" $ \
  (Positive i) user -> runStateTest $ do
  
  let db = Map.fromList [toDBEntry user]
  put db
  let uniqueOtherUserId = show $ userId user + i
 
  actual <- post lookupUser updateUser (show $ userId user) uniqueOtherUserId
 
  assertPostFailure db actual

Since this test body is so similar to the previous test, I'm not going to give you a detailed walkthrough. I did, however, promise to describe the runStateTest helper function:

runStateTest :: State (Map k a) b -> b
runStateTest = flip evalState Map.empty

Since this is a one-liner, you could also write all the tests by simply in-lining that little expression, but I thought that it made the tests more readable to give this function an explicit name.

It takes any State (Map k a) b and runs it with an empty map. Thus, all State-valued functions, like the tests, must explicitly put data into the state. This is also what the tests do.

Notice that all the tests return State values. For example, the assertPostFailure function returns StateT s m Bool, of which State s Bool is an alias. This fits State (Map k a) b when s is Map k a, which again is aliased to DB. Reducing all of this, the tests are simply functions that return Bool.

Invalid user ID test cases #

Finally, you can also cover the two test cases where one of the user IDs is invalid:

testProperty "Users don't connect when user Id is invalid" $ \
  s otherUser -> isIdInvalid s ==> runStateTest $ do
 
  let db = Map.fromList [toDBEntry otherUser]
  put db
 
  actual <- post lookupUser updateUser s (show $ userId otherUser)
 
  assertPostFailure db actual
 
,
testProperty "Users don't connect when other user Id is invalid" $ \
  s user -> isIdInvalid s ==> runStateTest $ do
 
  let db = Map.fromList [toDBEntry user]
  put db
 
  actual <- post lookupUser updateUser (show $ userId user) s
 
  assertPostFailure db actual

Both of these properties take a String value s as input. When QuickCheck generates a String, that could be any String value. Both tests require that the value is an invalid user ID. Specifically, it mustn't be possible to parse the string into an Integer. If you don't constrain QuickCheck, it'll generate various strings, including e.g. "8" and other strings that can be parsed as numbers.

In the above "Users don't connect when user doesn't exist" test, you saw how one way to explicitly model constraints on data is to project a seed value in such a way that the constraint always holds. Another way is to use QuickCheck's built-in ==> operator to filter out undesired values. In this example, both tests employ the isIdInvalid function:

isIdInvalid :: String -> Bool
isIdInvalid s =
  let userInt = readMaybe s :: Maybe Integer
  in isNothing userInt

Using isIdInvalid with the ==> operator guarantees that s is an invalid ID.

Summary #

While state-based testing may, at first, sound incompatible with strictly functional programming, it's not only possible with the State monad, but even, with good language support, easily done.

The tests shown in this article aren't concerned with the interactions between the SUT and its dependencies. Instead, they compare the initial state with the state after exercising the SUT. Comparing values, even complex data structures such as maps, tends to be trivial in functional programming. Immutable values typically have built-in structural equality (in Haskell signified by the automatic Eq type class), which makes comparing them trivial.

Now that we know that state-based testing is possible even with Haskell's enforced purity, it should be clear that we can repeat the feat in F#.

Next: An example of state based-testing in F#.



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, 11 March 2019 07:55:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 11 March 2019 07:55:00 UTC