An example of state based-testing in F# by Mark Seemann
While F# is a functional-first language, it's okay to occasionally be pragmatic and use mutable state, for example to easily write some sustainable state-based tests.
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 how to write state-based tests in Haskell. In this article, you'll see how to apply what you've learned in F#.
The code shown in this article is available on GitHub.
A function to connect two users #
This article, like the others in this series, implements an operation to connect two users. I explain the example in details in my two Clean Coders videos, Church Visitor and Preserved in translation.
Like in the previous Haskell example, in this article we'll start with the implementation, and then see how to unit test it.
// ('a -> Result<User,UserLookupError>) -> (User -> unit) -> 'a -> 'a -> HttpResponse<User> let post lookupUser updateUser userId otherUserId = let userRes = lookupUser userId |> Result.mapError (function | InvalidId -> "Invalid user ID." | NotFound -> "User not found.") let otherUserRes = lookupUser otherUserId |> Result.mapError (function | InvalidId -> "Invalid ID for other user." | NotFound -> "Other user not found.") let connect = result { let! user = userRes let! otherUser = otherUserRes addConnection user otherUser |> updateUser return otherUser } match connect with Ok u -> OK u | Error msg -> BadRequest msg
While the original C# example used Constructor Injection, the above post
function uses partial application for Dependency Injection. The two function arguments lookupUser
and updateUser
represent interactions with a database. Since functions are polymorphic, however, it's possible to replace them with Test Doubles.
A Fake database #
Like in the Haskell example, you can implement a Fake database in F#. It's also possible to implement the State monad in F#, but there's less need for it. F# is a functional-first language, but you can also write mutable code if need be. You could, then, choose to be pragmatic and base your Fake database on mutable state.
type FakeDB () = let users = Dictionary<int, User> () member val IsDirty = false with get, set member this.AddUser user = this.IsDirty <- true users.Add (user.UserId, user) member this.TryFind i = match users.TryGetValue i with | false, _ -> None | true, u -> Some u member this.LookupUser s = match Int32.TryParse s with | false, _ -> Error InvalidId | true, i -> match users.TryGetValue i with | false, _ -> Error NotFound | true, u -> Ok u member this.UpdateUser u = this.IsDirty <- true users.[u.UserId] <- u
This FakeDB
type is a class that wraps a mutable dictionary. While it 'implements' LookupUser
and UpdateUser
, it also exposes what xUnit Test Patterns calls a Retrieval Interface: an API that tests can use to examine the state of the object.
Immutable values normally have structural equality. This means that two values are considered equal if they contain the same constituent values, and have the same structure. Mutable objects, on the other hand, typically have reference equality. This makes it harder to compare two objects, which is, however, what almost all unit testing is about. You compare expected state with actual state.
In the previous article, the Fake database was simply an immutable dictionary. This meant that tests could easily compare expected and actual values, since they were immutable. When you use a mutable object, like the above dictionary, this is harder. Instead, what I chose to do here was to introduce an IsDirty
flag. This enables easy verification of whether or not the database changed.
Happy path test case #
This is all you need in terms of Test Doubles. You now have test-specific LookupUser
and UpdateUser
methods 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:
[<Fact>] let ``Users successfully connect`` () = Property.check <| property { let! user = Gen.user let! otherUser = Gen.withOtherId user let db = FakeDB () db.AddUser user db.AddUser otherUser let actual = post db.LookupUser db.UpdateUser (string user.UserId) (string otherUser.UserId) test <@ db.TryFind user.UserId |> Option.exists (fun u -> u.ConnectedUsers |> List.contains otherUser) @> test <@ isOK actual @> }
All tests in this article use xUnit.net 2.3.1, Unquote 4.0.0, and Hedgehog 0.7.0.0.
This test first adds two valid users to the Fake database db
. It then calls the post
function, passing the db.LookupUser
and db.UpdateUser
methods as arguments. Finally, it verifies that the 'first' user's ConnectedUsers
now contains the otherUser
. It also verifies that actual
represents a 200 OK
HTTP response.
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:
[<Fact>] let ``Users don't connect when user doesn't exist`` () = Property.check <| property { let! i = Range.linear 1 1_000_000 |> Gen.int let! otherUser = Gen.user let db = FakeDB () db.AddUser otherUser db.IsDirty <- false let uniqueUserId = string (otherUser.UserId + i) let actual = post db.LookupUser db.UpdateUser uniqueUserId (string otherUser.UserId) test <@ not db.IsDirty @> test <@ isBadRequest actual @> }
This test adds one valid user to the Fake database. Once it's done with configuring the database, it sets IsDirty
to false
. The AddUser
method sets IsDirty
to true
, so it's important to reset the flag before the act phase of the test. You could consider this a bit of a hack, but I think it makes the intent of the test clear. This is, however, a position I'm ready to reassess should the tests evolve to make this design awkward.
As explained in the previous article, this test case requires an ID of a user that doesn't exist. Since this is a property-based test, there's a risk that Hedgehog might generate a number i
equal to otherUser.UserId
. One way to get around that problem is to add the two numbers together. Since i
is generated from the range 1 - 1,000,000, uniqueUserId
is guaranteed to be different from otherUser.UserId
.
The test verifies that the state of the database didn't change (that IsDirty
is still false
), and that actual
represents a 400 Bad Request
HTTP response.
Remaining test cases #
You can write the remaining three test cases in the same vein:
[<Fact>] let ``Users don't connect when other user doesn't exist`` () = Property.check <| property { let! i = Range.linear 1 1_000_000 |> Gen.int let! user = Gen.user let db = FakeDB () db.AddUser user db.IsDirty <- false let uniqueOtherUserId = string (user.UserId + i) let actual = post db.LookupUser db.UpdateUser (string user.UserId) uniqueOtherUserId test <@ not db.IsDirty @> test <@ isBadRequest actual @> } [<Fact>] let ``Users don't connect when user Id is invalid`` () = Property.check <| property { let! s = Gen.alphaNum |> Gen.string (Range.linear 0 100) |> Gen.filter isIdInvalid let! otherUser = Gen.user let db = FakeDB () db.AddUser otherUser db.IsDirty <- false let actual = post db.LookupUser db.UpdateUser s (string otherUser.UserId) test <@ not db.IsDirty @> test <@ isBadRequest actual @> } [<Fact>] let ``Users don't connect when other user Id is invalid`` () = Property.check <| property { let! s = Gen.alphaNum |> Gen.string (Range.linear 0 100) |> Gen.filter isIdInvalid let! user = Gen.user let db = FakeDB () db.AddUser user db.IsDirty <- false let actual = post db.LookupUser db.UpdateUser (string user.UserId) s test <@ not db.IsDirty @> test <@ isBadRequest actual @> }
All tests inspect the state of the Fake database after the calling the post
function. The exact interactions between post
and db
aren't specified. Instead, these tests rely on setting up the initial state, exercising the System Under Test, and verifying the final state. These are all state-based tests that avoid over-specifying the interactions.
Summary #
While the previous Haskell example demonstrated that it's possible to write state-based unit tests in a functional style, when using F#, it sometimes make sense to leverage the object-oriented features already available in the .NET framework, such as mutable dictionaries. It would have been possible to write purely functional state-based tests in F# as well, by porting the Haskell examples, but here, I wanted to demonstrate that this isn't required.
I tend to be of the opinion that it's only possible to be pragmatic if you know how to be dogmatic, but now that we know how to write state-based tests in a strictly functional style, I think it's fine to be pragmatic and use a bit of mutable state in F#. The benefit of this is that it now seems clear how to apply what we've learned to the original C# example.