DEV Community

Cover image for Testing a derived state
Lars Furu Kjelsaas
Lars Furu Kjelsaas

Posted on

Testing a derived state

Hello world, and welcome to my first post! I am writing this as part of F# Advent 2023, many thanks to the welcoming F# community and Sergey Tihon especially for having me.

A derived state

What is a derived state? In this post, we are looking at systems where the processing of events from an initial state leave you with a model of the state of your application. This could be an Event Sourcing aggregate, where you read historical events from store instead of storing your state explicitly. It could also be your model in Redux or Elmish that updates whenever a button is clicked or the backend returns data, and is used to back your UI.

My observations will be mostly based on the Event Sourcing case, but this kind of pattern can show up many places when doing functional programming. For a nice introduction on Event Sourcing from a previous year of F# Advent, check out Event Sourcing - Step by step in F# by Roman Provazník from 2018.

The domain I work with daily is Norwegian property taxes, check out my talk Handling a Complex Domain with Readable Code from this year's fsharpConf about that. For this post, let's do an example that might be a bit easier to jump straight into. For something close to my heart, the example for today will be a board game app.

Game app domain

//My new game app
type GameCreated = { GameId: Guid; GameType: string }
type PlayerJoined = { PlayerId: Guid }
type PlayerScored = { PlayerId: Guid }

type GameEvent = 
    | GameCreated of GameCreated
    | PlayerJoined of PlayerJoined
    | PlayerScored of PlayerScored

type GameState = 
    { GameId: Guid
      PlayerScores: Map<Guid, int> }

module GameState = 
    let initial = 
        { GameId = Guid.Empty
          PlayerScores = Map.empty }

    let applyEvent state ev = 
        match ev with
        | GameCreated gameCreated ->
            { GameId = gameCreated.GameId 
              PlayerScores = Map.empty }
        | PlayerJoined playerJoined ->
            { state with
                PlayerScores = state.PlayerScores.Add (playerJoined.PlayerId, 0) }
        | PlayerScored playerScored ->
            let previousScore = state.PlayerScores |> Map.find playerScored.PlayerId
            { state with 
                PlayerScores = state.PlayerScores.Add (playerScored.PlayerId, previousScore + 1) }

    let fromEvents events = events |> List.fold applyEvent initial

Enter fullscreen mode Exit fullscreen mode

This is the small beginnings of a Board Game app, based on Event Sourcing and because of that, derived state. Many things could be modelled and coded differently, but it's enough for our purposes here. We can create a game, register players and we can register that a player has scored a point.

Let's add a function that can find out which player is the winner:

let findWinner state = 
    state.PlayerScores
    |> Map.toList
    |> List.maxBy (fun (_, score) -> score)
Enter fullscreen mode Exit fullscreen mode

We got ourselves a domain, let's test it! To any TDD people, just pretend that the test was there all along, failing in the background.

The straight-forward unit test

open Expecto

let test1 = 
    test "The player with the most points win, take 1" {
        //Arrange
        let player1 = Guid.NewGuid()
        let player2 = Guid.NewGuid()

        let state = 
            { GameId = Guid.NewGuid() 
              PlayerScores = [player1, 1; player2, 0] |> Map.ofList }

        //Act
        let (winnerName, _) = findWinner state

        //Assert
        Expect.equal winnerName player1 "Player 1 wins when they have more points" 
    }
Enter fullscreen mode Exit fullscreen mode

The advantage of writing your tests like this, is that it is very focused on this specific case. Given this input and that processing, our output should look something like this.

Copy and update

When our state is small, creating our state like this is fine, there's not too much noise. However, we are setting a Game Id that in the end doesn't matter all that much for our findWinner-function, and if there are many values like this the overall focus of the test will become less clear. Which assigned values matter, and what could be whatever you like? Since any flow in your program goes from events to state, and then out again, it becomes quite easy to create functions that just take in state. We will often solve this by changing the input of findWinner to only take in the player scores it needs, but depending on the case it is often most practical to take in a larger record that contains what we need. To help clarity in our test, we can create our state with a copy and update record expression, based on the empty or initial state:

//...
let state = 
    { GameState.initial with 
          PlayerScores = [player1, 1; player2, 0] |> Map.ofList }
//...
Enter fullscreen mode Exit fullscreen mode

Interpreting history

Right now, our PlayerScores is set up as a map from the playerId to a score. In the future, we might change this to become a Player record with more data, or keeping track of this in another way. When working with derived state, our state isn't stored away, never to be touched. This means that we are quite free to refactor it any way we see fit, while our events more often stay the same, especially when they are stored. It is preferable if we don't have to change the Arrange-part of all of our tests each time we do this, as that can become quite the pain as you write more tests. What if we rewrote these tests with arrangement based on our more stable events instead, rather than depending on the derived state?

module GameState = 
    //...
    let fromEvents events = events |> List.fold applyEvent initial

//...
let state = 
    GameState.fromEvents [
        PlayerJoined { PlayerId = player1 }
        PlayerJoined { PlayerId = player2 }
        PlayerScored { PlayerId = player1 } ]
//...
Enter fullscreen mode Exit fullscreen mode

Here, we don't concern ourselves with the structure of our state, it is an almost an afterthough. The tests can stay untouched while we refactor our state, ensuring that we don't break anything along the way.

Sidenote: One of the advantages of the derived state, is that we are not locked into the structure of it. Since we are changing our interpretation of history, we open ourselves to retroactively fixing bugs without touching our database, and for a new business requirement we might already have data ready to be read in a different way. On the flip-side, we open ourselves to adding new bugs to how we interpret facts of the past. It is important to have this in mind when testing your system.

Simplifying the asserts

So far, we've focused on how we Arrange our tests. At least in the example we have worked with here, the Act phase is quite simple, but Assert requires some thinking. As our application grows, so will our state structure. Some functions might return records with 10+ fields, or complex, nested structures. At that point, what should we check in our test? Often, the objective of the test you have in mind is clear and what to test is quite simple. But what about that DTO that you send out from your API? Do you make guesses?

//Act
let (winnerName, _) = findWinner state

//Assert
Expect.equal winnerName player1 "Player 1 wins when they have more points"
Enter fullscreen mode Exit fullscreen mode

Here, we are discarding a field, assuming that whatever value is there is fine. We could also do:

//Act
let (winnerName, score) = findWinner state

//Assert
Expect.equal winnerName player1 "Player 1 wins when they have more points"
Expect.equal score 1 "Winner had one point"
Enter fullscreen mode Exit fullscreen mode

Or similarily:

//Act
let winner = findWinner state

//Assert
Expect.equal winner (player1, 1) "Player 1 wins with 1 point"
Enter fullscreen mode Exit fullscreen mode

With the two values here, either approach doesn't seem daunting. But with larger data structures, keeping your tests up to speed start to slow you down.

One tool that we have found very useful the last months, and find especially fitting for working with derived state, is Verify. Very briefly, Verify is a Snapshot Testing tool, which works by serializing the output of your test to storage, rather than asserting anything about the values of it. Your test passes when your output is the same as the previous success, and when it fails you use your favourite diff-file tool to see the changes. At this point, you can accept the change or reject it. A test might look something like this:

testTask "The player with the most points win, event based" {
    let player1 = Guid.NewGuid()
    let player2 = Guid.NewGuid()

    let state = 
        GameState.fromEvents [
            PlayerJoined { PlayerId = player1 }
            PlayerJoined { PlayerId = player2 }
            PlayerScored { PlayerId = player1 } ]

    let winner = findWinner state
    do! Verifier.Verify("Player 1 wins when they have more points", winner)
}
Enter fullscreen mode Exit fullscreen mode

Further tests

At this point, we have looked at some ways to write tests that doesn't rely too much on the everchanging derived state, while also looking briefly at how we can secure that our application doesn't change behaviour in unexpected ways. Further options to explore include property-based testing, where you can generate "random" input that sniff out unexpected behaviour, including throwing different events at the system and ensuring that no path leads to undesired states. The state-through-events approach is also very well equipped to write good tests that replicate bugs and user-stories, testing that the system does or doesn't behave in a certain way on a larger scale than single units.

Finishing up

If you made it this far, great to have you along for my semi-structured ramblings through my thoughts on some testing approaches for systems with derived states. The strong type system of F#, immutable datastructures and state based on events combine into a potent combination for writing flexible, yet predictible code that is easy to test. Enjoy the rest of the calendar!

Top comments (0)