Testable Back-end Programming in F#

kspeakman

Kasey Speakman

Posted on April 8, 2020

Testable Back-end Programming in F#

A way to write extremely testable code with side effects.

It has always been a challenge to write testable back-end code. Deterministic code is stupidly testable and is the ultimate goal. But it seems most business workflows that execute on the back-end are dependent on the result of side effects. There has been many a framework and practice created to try to accomplish testability: dependency injection containers, mocking libraries, turning code inside-out with interfaces, etc. None of these have ever felt right to me because they require so much unrelated overhead or framework-dependency. And they are not idiomatic to functional programming.

A person with a shovel standing over an unearthed treasure chest

A few years ago, I stumbled upon the Model-View-Update pattern. It is a UI-specific pattern, but it quite powerfully separates decision from side effect. It made a drastic difference in our UIs. They became low overhead/risk to refactor and test -- something I had never experienced with any UI framework. Eventually I realized that its power is because it is a functional version of the Interpreter pattern. While my decision code has all the power and expressiveness of a full programming language, it can also run deterministically, and return decisions and side effects as simple data values. Those values then get interpreted and the results fed back into the decision code again.

On the back-end

So I began to think about how to apply this pattern to the server-side to reap the same benefits. Of course MVU is tailored for UI, so some modifications were required.

Leaving out View

UI programs display a visual to the user. For an API request there is no visual, so the View part of MVU can be dropped.

Changing runtime behavior

UI programs are also designed to run indefinitely to accept spontaneous user input. On the back-end, there is no spontaneous user input while processing a request... it was all given up front in the request. Nor is it desirable to run the API request indefinitely. Instead, the back-end workflow should probably get a response back to the caller as soon as it can.

Front-end MVU typically runs as 3 independent agents for processing messages, effects, and view changes. But the run-time behavior on the back-end should be more like a single task that runs to completion.

Adding Effects

One part of the MVU pattern that seems to be conspicuously absent is side effects, which is a large part of the point to the back end. My original exposure to MVU was in Elm, a language that never allows you to implement your own side effects. If the provided effects were not enough, you had to write your own in Javascript and make a cumbersome interop call. That was not particularly great on the front-end, but it really is not going to cut it for the back-end.

F# permits side effects in normal code, so I wanted to take advantage of that, but also designate a place where these should be performed. So I added a new type called Effect for declaring side effects, then a corresponding function called perform which executes the Effect. Side effects are isolated into the perform function. The update function now deterministically returns Model and Effect list, making it testable. Put another way, you can test not only that the correct state change takes place but also that the correct side effects are invoked! No frameworks, no containers. Just provide values in and assert that output values equals expected values.

I also use the perform / Effect pattern in my F#-based MVU front-ends. It only requires a couple of tiny adapter functions to integrate into the Elmish library.

UMP

With these changes it might be more appropriate to call this pattern UMP: Update Model Perform.


A person observing 3 components communicating

The Pattern

The pattern looks and feels a lot like MVU. So if you use MVU on the front-end, it will feel natural to use on the back-end as well. Here is a basic example of decrementing a counter. First let's define the module and the types.

Types

I usually name the module for the workflow it implements. Defining the types is where I spend some time thinking through the workflow. And looking at the types will often give you an idea of exactly what happens.

module DecrementCounter

open System

type Effect =
    | LoadState of counterId: Guid
    | SaveState of counterId: Guid * count: int

type Msg =
    | StateLoaded of Result<int option, string>
    | StateSaved of Result<unit, string>

type Model =
    {
        // request type defined elsewhere as
        // { CounterId: Guid; Amount: int }
        Request: DecrementCounterRequest
    }
Enter fullscreen mode Exit fullscreen mode

Nothing earth shattering here. The effects are stating that we are going to load and save state. The messages are stating that we will get the result of loading and saving, that those operations can fail (hence Result), and that load may not find the counter (hence int option). The failure cases will simply contain a string error.

Deterministic functions

Next we define the two deterministic functions, init and update.

init

Init really just gets things prepped and started for update. It converts an initial argument into the model that will be used by update. Usually I will do basic request validation in init if it is not done by some other part of the infrastructure.

let init request =
    Ok { Request = request }
    , [LoadState request.CounterId]
Enter fullscreen mode Exit fullscreen mode

update

Update typically makes the big decisions.

let update msg modelResult =
    match modelResult with
    | Error _ ->
        modelResult, [] // do nothing
    | Ok model ->
        match msg with
        | StateLoaded (Error s) ->
            Error ("Load failed: " + s), []
        | StateLoaded (Ok None) ->
            Error "Counter not found", []
        | StateLoaded (Ok (Some oldCount)) ->
            let count = oldCount - model.Request.Amount
            if count < 0 then
                Error "Counter would go negative", []
            else
                Ok { model with Count = count }
                , [SaveState (model.Request.CounterId, count)]
        | StateSaved (Error s) ->
            Error ("Save failed: " + s), []
        | StateSaved (Ok ()) ->
            Ok model, []
Enter fullscreen mode Exit fullscreen mode

The first 4 lines of update are so common in workflow scenarios that I create a helper function and factor them out. So update would actually look like this.

let update msg model =
    match msg with
    | StateLoaded (Error s) ->
        Error ("Load failed: " + s), []
    | StateLoaded (Ok None) ->
        Error "Counter not found", []
    | StateLoaded (Ok (Some oldCount)) ->
        let count = oldCount - model.Request.Amount
        if count < 0 then
            Error "Counter would go negative", []
        else
            Ok model
            , [SaveState (model.Request.CounterId, count)]
    | StateSaved (Error s) ->
        Error ("Save failed: " + s), []
    | StateSaved (Ok ()) ->
        Ok model, []
Enter fullscreen mode Exit fullscreen mode

In this basic example, the model just keeps the original request. But in other scenarios, steps may return a changed model. Those changes are then used by subsequent steps.

perform

Here begins the side-effect area of the code. I usually put opens here which are needed for side effects instead of placing them at the top. This makes it less likely to "accidentally" include side effects in init/update. I commonly create a Config type here that has configuration or resources which are needed by side effects. perform is where I do logging as well.

// example open used only by side effects
open Microsoft.Extensions.Logging

type EffectConfig =
    {
        ExampleConfig: string
        // other items such as:
        // connection strings
        // endpoint URLs
        // loggers
    }


let perform config effect =
    match effect with
    | LoadState counterId ->
        // simulate db call
        async {
            let rand = new Random()
            do! Async.Sleep 30
            let count = rand.Next(0, 100)
            return StateLoaded (Ok (Some count))
        }
    | SaveState (counterId, count) ->
        async {
            do! Async.Sleep 30
            return StateSaved (Ok ())
        }
Enter fullscreen mode Exit fullscreen mode

A couple of notes. This simple implementation will not error. But actual code might have try/catch, log exceptions, etc. Anything you might normally need to do when you call a side effect.

I usually find that my API side effects are pretty common between all my workflows. So I will define a common Effects module and call its effect implementation, instead of implementing the effect inside the perform function. I also try to keep effects very focused to doing one single thing, with all the config and data needed as parameters, so that they can have the possibility of being reused. In the end, perform ends up looking more like this.

let perform fxConfig effect =
    match effect with
    | LoadState counterId ->
        let query = Query.counterState counterId
        Fx.Sql.readFirst<int> fxConfig query StateLoaded
    | SaveState (counterId, counter) ->
        let stmt = Stmt.saveCounter counterId counter
        Fx.Sql.write fxConfig stmt StateSaved
Enter fullscreen mode Exit fullscreen mode

Please forgive the cutesy abbreviation of Effects to Fx. The pragmatism of shortening the name won out.

In the case where I centralize the effect implementations under an Fx namespace, I also use a common config. So there is no need to define a workflow-specific config. The Fx modules also know that the output will need to be tagged with a Msg case. So it accepts that as a parameter.

Final pieces

Sometimes you will want the final return value of the workflow to be different from the Model that you used during the workflow steps. So there is an output function that will convert the model into the desired output value. The last steps are to create an output function. And then wrap everything up into a runnable UMP program.

// Return Ok () or Error string
let output result =
    match result with
    | Ok model -> Ok ()
    | Error s -> Error s

// more concisely:
// let output = Result.map ignore

let toUmp config =
    {
        Init = init
        Update = Result.bindUpdate update
        Perform = perform config
        Output = output
    }
Enter fullscreen mode Exit fullscreen mode

If you want to return the Model as-is once the workflow completes, you can simply set Output = id. The F# built-in id function returns the same thing it is given.

Result.bindUpdate is an extension function that I define somewhere in my project. It is that helper I mentioned that simplifies the update statement to remove those first 4 lines.

module Result =
    let bindUpdate updatef msg result =
        match result with
        | Ok model -> updatef msg model
        | Error err -> Error err, []
Enter fullscreen mode Exit fullscreen mode

Running it

You will want to provide the necessary config for effects, but aside from that you just run it with the initial argument.

    let program =
        DecrementCounter.toUmp { ExampleConfig = "foo" }

    ...

    async {
        let request : DecrementCounterRequest = ...
        let! result = Ump.run program request
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Sometimes it is convenient to hide the UMP details. So I turn it into a normal async-returning function like this:

    // Request -> Async<Result<unit, string>>
    let decrementCounter request =
        let prog = DecrementCounter.toUmp { ExampleConfig = "foo" }
        Ump.run prog request

    async {
        // this code knows nothing of UMP
        // it is just executing a side effect
        let! result = decrementCounter request
    }
Enter fullscreen mode Exit fullscreen mode

Testing it

Probably the most common way you want to test is providing the initial argument and all the Msgs (the results of side effects) that have occurred. Then you would assert that the return value matches what you expected.

    // test data
    let counterId = new Guid("9E6F6552-DEA9-4D56-AEAB-08EE5EBD54D3")
    let request =
        {
            CounterId = counterId
            Amount = 12
        }
    let model = { Request = request }

    [<TestMethod>]
    member __.``DecrementCounter - loads state`` () =
        let initArg, msgs = request, []
        let expected = Ok model, [LoadState counterId]
        expected |> equals (test initArg msgs)
Enter fullscreen mode Exit fullscreen mode

It is not hard to see how you can run a lot of different tests at once by parameterizing the input and expected output.

    let tests =
        [   // name, initArg, msgs, expected model, expected effects
            ( "loads state"
            , request
            , []
            , Ok model
            , [LoadState counterId]
            )
            ( "saves state"
            , request
            , [StateLoaded (Ok (Some 13))]
            , Ok model
            , [SaveState (counterId, 1)]
            )
        ]

    [<TestMethod>]
    member __.``DecrementCounter tests`` () =
        for (name, initArg, msgs, model, effects) in tests do
            printfn "%s" name
            let expected = model, effects
            expected |> equals (test initArg msgs)
Enter fullscreen mode Exit fullscreen mode

You can also manually test one specific step by calling the update function directly.

    [<TestMethod>]
    member __.``DecrementCounter - prevent negative`` () =
        let msg = StateLoaded (Ok (Some 0))
        let expected = Error "Counter would go negative", []
        expected |> equals (DecrementCounter.update msg model)
Enter fullscreen mode Exit fullscreen mode

When (not) to use

UMP is only valuable when you need to mix decisions and side effects. Further, it should be used for important business code that needs its decisions validated for correctness. If your code is does not require this, you pay the overhead cost of creating all the pattern's types and functions for no advantage. Below I have documented some indications of inappropriate usage that I found the hard way.

Pass-through Effect

When you find yourself making an Effect that passes a value through directly to a Msg without performing any side effect, then it is likely that have tried to use an Effect to represent a logical (non-side-effect) decision step.

This pattern is not designed to divide logical decision steps. It is designed to divide into steps around side effects. You can use normal let statements or pipelining with |> to compose multiple logical steps. It is perfectly valid for one of the update cases to be more lines of code than other steps. If you do want to keep your update cases small for clarity, you can place your logic functions into a separate module and call them from init or update.

module Logic =
    let validate data =
        ...


let init request =
    Logic.validate request
    ...
Enter fullscreen mode Exit fullscreen mode

Never changing the model

When you find that your update code never updates the model, or it only updates the model to store values needed by later effects, this might indicate that the code is purely effectful. If it is not business critical, it is probably better to write a normal function that invokes side effects. Below is an example that could be written as UMP, but it is not solving a business problem only a technical one. So the overhead outweighs the benefit.

module Sql =
    ...

    let read<'T> (config: SqlConfig) (op: SqlOperation) =
        async {
            try
                let cmd = SqlOperation.toCommandDefinition config op
                use conn = new NpgsqlConnection(config.ConnectString)
                let! resultSeq = conn.QueryAsync<'T>(cmd) |> Async.AwaitTask
                return Ok (List.ofSeq resultSeq)
            with ex ->
                return Error ex
        }
Enter fullscreen mode Exit fullscreen mode

Implementation

Here is the full implementation of Ump functions with comments. It includes a function to create a test and the Result.bindUpdate helper.

namespace Ump

type Ump<'initArg, 'Model, 'Effect, 'Msg, 'Output> =
    {
        Init : 'initArg -> 'Model * 'Effect list
        Update : 'Msg -> 'Model -> 'Model * 'Effect list
        Perform : 'Effect -> Async<'Msg>
        Output : 'Model -> 'Output
    }


module Result =

    let bindUpdate updatef msg result =
        match result with
        | Ok model ->
            updatef msg model
        | Error err ->
            Error err, []


module Ump =

    [<AutoOpen>]
    module Internal =

        [<Struct>]
        // struct - per iteration: 1 stack allocation + 1 frame copy
        type ProgramState<'Model, 'Effect, 'Msg> =
            {
                Model : 'Model
                Effects : 'Effect list
                Msgs : 'Msg list
            }


        // Msgs are processed before Effects.
        // Msgs are run sequentially.
        // Effects are run in parallel.
        // In practice, program.Update will return
        //  one Effect at a time when it needs sequential effects.
        let rec runLoop program state =
            match state.Effects, state.Msgs with
            | [], [] ->
                async.Return (program.Output state.Model)
            | _, msg :: nMsgs ->
                let (nModel, effects) = program.Update msg state.Model
                let nState =
                    {
                        Model = nModel
                        Effects = List.append state.Effects effects
                        Msgs = nMsgs
                    }
                runLoop program nState
            | _, [] ->
                async {
                    let effectsAsync = List.map program.Perform state.Effects
                    let! nMsgsArr = Async.Parallel effectsAsync
                    let nState =
                        {
                            Model = state.Model
                            Effects = []
                            Msgs = List.ofArray nMsgsArr
                        }
                    return! runLoop program nState
                }


    /// Runs a program using the provided initial argument.
    /// The returned Model is the final state of the Model when the program exited.
    /// Infinite loops are possible when Update generates Effects on every iteration.
    /// This allows the program to support interactive applications, for example.
    let run (program: Ump<'initArg, 'Model, 'Effect, 'Msg, 'Output>) (initArg: 'initArg) =
        let (model, effects) = program.Init initArg
        let state =
            {
                Model = model
                Msgs = []
                Effects = effects
            }
        runLoop program state


    /// Creates a test function from the init and update functions.
    /// The returned function takes initArg and msgs and returns
    /// a model and effect list that you can test against expected values.
    let createTest init update =
        let test initArg msgs =
            let init = init initArg
            // ignore previous effects
            let update (model, _) msg =
                update msg model
            msgs
            |> List.fold update init
        test
Enter fullscreen mode Exit fullscreen mode

Here is a repo with this DecrementCounter example and tests. There is also a more advanced example of a rate-limited Emailer.

GitHub logo XeSoft / ump-example

Testable back-end programming pattern

Perspective

This pattern is not meant to be used everywhere. It shines with effectful code that you want rigorously test. In other words, side-effect-infused workflows that are important to your business.

And let's be honest, this pattern has boilerplate in how it is organized: Msg, Effect, Model, init, update, perform. It will not suit everyone's taste. But that same organization pattern also provides a testable structure. It happens to be quite nice if your team already uses MVU on the front-end -- it is much easier for the team to be cross-functional. Even if not, I think with some practice you will find that it makes just about any workflow quite testable.

/∞

Images courtesy of Undraw.

💖 💪 🙅 🚩
kspeakman
Kasey Speakman

Posted on April 8, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Testable Back-end Programming in F#
functional Testable Back-end Programming in F#

April 8, 2020