← back to all talks and articles

Testing Elm updates

If your Elm program compiles, it probably works. When writing Elm programs, I tend to write a lot fewer unit tests than I usually would. But you can (and should) still write tests. Here’s how I test my update functions.

Unit testing Elm code

The Elm community has built the elm-test package for writing unit tests with Elm. Along with node-test-runner you’ve got a pretty neat TDD setup going. To run your tests and watch for file changes, run:

% elm test --watch

After you’ve set up elm-test, this is what an example test file would look like:

module MyTest exposing (all)

import Expect
import Test exposing (..)

all : Test
all =
describe "My Elm app"
    [ test "test the truth" <|
        \_ ->
            Expect.equal True True
    ]

Admittedly, elm-test’s syntax is not the prettiest I’ve ever seen, but that’s nothing some editor snippets and elm-format won’t solve for you.

About pure functions and side effects

Elm functions are pure. Their outputs are determined by their inputs and they have no side effects. One of the most important parts of any Elm program is the update function. This function takes a message and the current state of the application, and returns the new state and, optionally, a description of any side effects you want to trigger.

Note that your update function does not have side effects in itself — you can only let it return a description of the desired effects you want the Elm runtime to implement for you. These side effects are called commands in Elm, and despite their name, they’re still just data.

Example update function

Here’s a simple update function for a To Do list application:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        NoOp ->
            (model, Cmd.none)
        CompleteTask id ->
            ({ model
                | tasks = List.map (checkById id) model.tasks
            }, Cmd.none)

Assume, for now, that a function exists with the signature checkById : Int -> Task -> Task. It will take a Task and it will compare its ID to the given ID and decide to mark that task as completed or not.

How can we test this update function?

Setting up a test skeleton

The easiest way to get started is by writing the simplest test possible. In this case, that’s the scenario where we trigger a NoOp message in our application. It should do nothing.

Testing the model

Let’s set up a simple test file that tests the right model value is returned:

module UpdateTest exposing (all)

import Expect
import MyApp exposing(..)
import Test exposing (..)

model : Model
model =
    { tasks = [] }

all : Test
all =
    describe "update"
        [ describe "NoOp"
            [ test "does not alter the state" <|
                \_ ->
                    model
                        |> update NoOp
                        |> Tuple.first
                        |> Expect.equal model
            ]
        ]

In this test I have set up a nested test group for the NoOp message, and it has a single test: it should not change the state. I’ve used the pipeline-style of composing the test. In case you’re not familiar, this is equivalent to writing:

Expect.equal model (Tuple.first (update NoOp model))

This test should pass, so we can specify the second part of this function: the non-existent side-effects.

Testing the commands

The fact that commands are merely descriptions of desired side effects makes it real easy to test for them:

test "it has no side effects" <|
    \_ ->
        model
            |> update NoOp
            |> Tuple.second
            |> Expect.equal Cmd.none

That was easy: the second element in the two-tuple return value should be a null command. Now we have set up this pattern, we can write a test that is actually interesting.

Testing completing a task

To test our CompleteTask message, we need to test for four things:

  1. it marks a message as completed when a existing ID is used;
  2. it does nothing when a non-existent ID is used;
  3. it does nothing when a task with the given ID does exist, but is already completed;
  4. it has no side effects.

The last test is essentially the same as in the first example, so I will not repeat it here.

Testing the happy path

Let’s write a test for the first scenario. I’ll first make sure there is a task in our example model, and then verify that using its ID in the CompleteTask message will change its completion flag. Note that the “task” will not actually change, as Elm has immutable data types; by change I mean the output model will contain a Task record that is equal to the original record except for the completion flag.

First, update our example model to include a Task to update. Luckily, this won’t affect our NoOp test:

model : Model
model =
    { tasks = [{ id = 1, title = "Buy milk", completed = False }]
    }

This is enough to test marking a Task as completed:

test "marks found task as completed" <|
    \_ ->
        model
            |> update CompleteTask 1
            |> Tuple.first
            |> Expect.equal
                [ { id = 1
                  , title = "Buy milk"
                  , completed = True
                  }
                ]

At this point, you could choose to refactor your example data a little by extracting the Task value into a separate function, but I’ll leave that as an exercise to the reader.

Testing the alternative paths

Let’s test the path where the ID does not exist:

test "ignores IDs of tasks that do not exist" <|
    \_ ->
        model
            |> update CompleteTask 99
            |> Tuple.first
            |> Expect.equal model

This test was actually a lot simpler: the output model is simply the same as the input model.

Finally, we can test that tasks that are already completed will not be changed (i.e. this is not a “toggle” function):

test "does not alter already completed tasks" <|
    \_ ->
        let
            completedOnce =
                model
                    |> update (CompleteTask 1)
                    |> Tuple.first
        in
           completedOnce
               |> update (CompleteTask 1)
               |> Tuple.first
               |> Expect.equal completedOnce

I could have added another Task to the example model, or updated the model in the test itself; but instead I chose to apply the CompleteTask message twice and verify it results in the same output. To me, this makes sense: marking a single task as completed twice gives you the same completed task as completing it only once would. Decide for yourself what style you prefer!

The value of testing commands

Testing side effects is a little more involved: while simple side effects such as generating a Random number or determining the current date/time are simple enough, testing if your update function triggers the correct HTTP request, for example, means you have to duplicate such a request in your test — along with its payload, custom headers, and so forth. This is not impossible, but hardly as valuable as an integration test that has your Elm front-end code talk to an actual back-end server. When writing tests for your update function, keep asking yourself if you are testing at the right level of abstraction.

The value tests add to the compiler

A non-trivial Elm application will deal with a lot of different messages. Writing tests for all of them can be quite a bit of work. But let us remember what Kent Beck said about testing software:

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence (…). If I don’t typically make a kind of mistake (like setting the wrong variables in a constructor), I don’t test for it.

It is all about reaching some level of confidence about your code. With Elm, you should write tests that help you trust your own code. In practice that means that I would probably not actually write a test for passing the NoOp message to my update function. Also, the compiler saves me from having to write tests for nonexistent messages, not covering some of different types of Msg, or using strange input types. That saves us a lot of work. Regardless, the update function is the core of our application, so it deserves at least some tests. I hope to have demonstrated that adding those tests is actually quite easy to do!

  • programming
  • elm
  • testing
Arjan van der Gaag

Arjan van der Gaag

A thirtysomething software developer, historian and all-round geek. This is his blog about Ruby, Rails, Javascript, Git, CSS, software and the web. Back to all talks and articles?

Discuss

You cannot leave comments on my site, but you can always tweet questions or comments at me: @avdgaag.