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
Testing Cmd<'msg>
can be hard, because there’s no way of knowing what the functions inside Cmd
really are before executing them.
The recommended way is to apply the CmdMsg
pattern.
See Replacing commands with command messages for better testability
Testing view
Views in Fabulous are testable as well, which makes it a clear advantage over more classic OOP frameworks (like C#/MVVM).
The view
function returns a ViewElement
value (which is a dictionary of attribute-value pairs). So we can check against that dictionary if we find the property we want, with the value we want.
Unfortunately when creating a control through View.XXX
, we lose the control’s type and access to its properties. Fabulous creates a ViewElement
which encapsulates all those data.
In order to test in a safe way, Fabulous provides type-safe helpers for every controls from Xamarin.Forms.Core
.
You can find them in the Fabulous.XamarinForms
namespace. They are each named after the control they represent.
Example: StackLayoutViewer
will let you access the properties of a StackLayout
.
The Viewer only takes a ViewElement
as a parameter.
(If you pass a ViewElement
that represents a different control than the Viewer expects, the Viewer will throw an exception)
Let’s take this code for example:
let view (model: Model) dispatch =
View.ContentPage(
content=View.StackLayout(
automationId="stackLayoutId"
children=[
View.Label(automationId="CountLabel", text=sprintf "%d" model.Count)
View.Button(text="Increment", command=(fun () -> dispatch Increment))
View.Button(text="Decrement", command=(fun () -> dispatch Decrement))
View.StackLayout(
orientation=StackOrientation.Horizontal,
children=[
View.Label(text="Timer")
View.Switch(isToggled=model.TimerOn, toggled=(fun on -> dispatch (TimerToggled on.Value)))
])
View.Slider(minimumMaximum=(0.0, 10.0), value=double model.Step, valueChanged=(fun args -> dispatch (SetStep (int args.NewValue))))
View.Label(text=sprintf "Step size: %d" model.Step)
]))
We want to make sure that if the state changes, the view will update accordingly.
The first step is to call view
with a given state and retrieve the generated ViewElement
.view
is expecting a dispatch
function as well. In our case, we don’t need to test the dispatching of messages, so we pass the function ignore
instead.
From there, we create the Viewers to help us read the properties of the controls we want to check.
And finally, we assert that the properties have the expected values.
Viewer API
The following approach uses the Viewer API. This is a way but with this you have to know exactly at which position the child you need is.
[<Test>]
let ``View should generate a label showing the count number of the model``() =
let model = { Count = 5; Step = 4; TimerOn = true }
let actualView = App.view model ignore
let contentPage = ContentPageViewer(actualView)
let stackLayout = StackLayoutViewer(contentPage.Content)
let countLabel = LabelViewer(stackLayout.Children.[0])
countLabel.Text |> should equal "5"
FindViewElement / TryFindViewElement
With findViewElement
and tryFindViewElement
you don’t need to know where exactly the child is positioned. You have to set automationId
on the ViewElements which will be used by those functions to find the element in the tree.
This approach is the recommended way for testing and to get the ViewElements in a View.
findViewElement
[<Test>]
let ``View should generate a label showing the count number of the model``() =
let model = { Count = 5; Step = 4; TimerOn = true }
let actualView = App.view model ignore
let countLabel = findViewElement "CountLabel" actualView |> LabelViewer
countLabel.Text |> should equal "5"
tryFindViewElement
tryFindViewElement
delivers a quickaccess to a ViewElement as findViewElement but here you get an Option Type. With this you can also check for the existence of a ViewElement.
[<Test>]
let ``When user is authenticated, View should not include a connection button``() =
let model = { Count = 5; Step = 4; TimerOn = true }
let actualView = App.view model ignore
tryFindViewElement "ConnectionButton" actualView |> should equal None
Testing if a control dispatches the correct message
If you want to test your event handlers, you can retrieve them in the same way than a regular property.
Then, you can execute the event handler like a normal function and check its result through a mocked dispatch.
[<Test>]
let ``Clicking the button Increment should send the message Increment``() =
let mockedDispatch msg =
msg |> should equal Increment
let model = { Count = 5; Step = 4; TimerOn = true }
let actualView = App.view model mockedDispatch
let contentPage = ContentPageViewer(actualView)
let stackLayout = StackLayoutViewer(contentPage.Content)
let incrementButton = ButtonViewer(stackLayout.Children.[1])
incrementButton.Command ()