Testing

The Model-View-Update architecture used by Fabulous makes it simple to unit test every part of your application. Apps are composed of 3 key pure F# functions: init, update and view They take some parameters and return a value. Ideal for unit testing.

Testing init

init is the easiest one to test. It usually takes nothing and returns a value.

Let’s take this code for example:

type Model =
    { Count: int
      Step: int }

let init () =
    { Count = 0; Step = 1 }

Here we can make sure that the default state stays exact throughout the life of the project. So using our favorite unit test framework (here we use FsUnit for this example), we can write a test that will check if the value returned by init is the one we expect.

[<Test>]
let ``Init should return a valid initial state``() =
    App.init () |> should equal { Count = 0; Step = 1 }

Testing update

update can be more complex but it remains a pure F# function. Testing it is equivalent to what we just did with init.

Let’s take this code for example:

type Model =
    { Count: int
      Step: int }

type Msg =
    | Increment
    | Decrement
    | Reset 

let update msg model =
    match msg with
    | Increment -> { model with Count = model.Count + model.Step }
    | Decrement -> { model with Count = model.Count - model.Step }
    | Reset -> { model with Count = 0; Step = 1 }

We can write the following tests:

[<Test>]
let ``Given the message Increment, Update should increment Count by Step``() =
    let initialModel = { Count = 5; Step = 4 }
    let expectedModel = { Count = 9; Step = 4 }
    App.update Increment initialModel |> should equal expectedModel

[<Test>]
let ``Given the message Decrement, Update should decrement Count by Step``() =
    let initialModel = { Count = 5; Step = 4 }
    let expectedModel = { Count = 1; Step = 4 }
    App.update Decrement initialModel |> should equal expectedModel

[<Test>]
let ``Given the message Reset, Update should reset the state``() =
    let initialModel = { Count = 5; Step = 4 }
    let expectedModel = { Count = 0; Step = 1 }
    App.update Reset initialModel |> should equal expectedModel

Testing init and update when using commands

Commands are a great way for executing a set of tasks (asynchronous or not) after receiving a message.

But behind the scenes, Cmd<'msg> is really only an array of functions. This makes testing Cmd<'msg> really difficult (no way to know what the functions are) and the functions init and update as well.

In the case you want to unit test your code, even if you’re using Cmd<'msg> inside initand update, the best way is to use of the CmdMsg pattern.

This is a general pattern, applicable when using an Elm-like programming model. It is not linked to Fabulous specifically.

Fabulous only provides some helpers to help you achieve this with less code.

The principle is to replace any direct usage of Cmd<'msg> from init and update, and instead use a discriminated union called CmdMsg.

type Model = 
    { TimerOn: bool
      Count: int
      Step: int }
        
type Msg = 
    | TimedTick
    | TimerToggled of bool

type CmdMsg =
    | TimerTick

let init () =
    { TimerOn = false; Count = 0; Step = 1 }, [] // An empty list means no action
    
let update msg model =
    match msg with
    | TimerToggled on ->
       { model with TimerOn = on }, [ if on then TimerTick ]
    | TimedTick ->
       if model.TimerOn then
          { model with Count = model.Count + model.Step }, [ TimerTick ]
       else
          model, []

Doing this transforms the output of both init and update to pure data output, which can then be easily unit tested

[<Test>]
let togglingOnShouldTriggerTimerTick () =
    let initialModel = { TimerOn = false; Count = 0; Step = 1 }
    let expectedReturn = { TimerOn = true; Count = 0; Step = 1 }, [ TimerTick ]
    App.update (TimerToggled true) initialModel |> should equal expectedReturn

The actual commands are still executed as Cmd<'msg> though. So in order to make this work with Fabulous, you need a function that will convert a CmdMsg to a Cmd<'msg>

Fabulous then helps you boot your application using Program.statefulWithCmdMsg

let mapCommands cmdMsg =
    match cmdMsg with
    | TimerTick -> timerCmd()

let program =
    Program.statefulWithCmdMsg init update view mapCommands

Note that Program.statefulWithCmdMsg doesn’t do anything magic. It only applies mapCommands to any CmdMsg returned by init and update. You could achieve the exact same behavior by converting them yourself and using Program.stateful.

Last updated