Test-Driving Elm with Elmer

Elmer is a test framework that helps you describe the behavior of your Elm web app. Let’s use Elmer to do some test-driven development. We’ll build a simple web application that communicates over a web socket.

Testing the Dom

Let’s begin with a test that describes the initial state of our app. Here it is:

module WebSocketTests exposing (..)

import Test exposing (..)
import Expect
import Elmer exposing (hasLength)
import Elmer.Html as Markup
import Elmer.Html.Matchers exposing (elements)
import App

viewTests : Test
viewTests =
describe "initial view"
[ test "it shows no items" <|
\() ->
Elmer.given App.defaultModel App.view App.update
|> Markup.target "#items li"
|> Markup.expect (elements <| hasLength 0)
]

If you’ve used elm-test, this should look familiar; Elmer just adds functions that can be used within an elm-test test. Here, we’re asserting that, when our app is initialized, no list items are displayed. Later, we’ll write a test that describes how those list items are populated based on messages we receive over a web socket. But for now we’ll just consider the initial case.

Our test first sets up the Elmer test context with our app’s initial model and its view and update functions. Then, it targets a particular area of the dom, namely all li elements that are descendents of the element whose id is items. Finally, we expect that no elements are found.

We can make our test pass by creating an app like so:

module App exposing
( Model
, defaultModel
, view
, update
)

import Html exposing (Html)
import Html.Attributes as Attr

type Msg
= Msg

type alias Model =
{ items : List String
}

defaultModel : Model
defaultModel =
{ items = []
}

view : Model -> Html Msg
view model =
Html.div []
[ Html.ul [ Attr.id "items" ] []
]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
(model, Cmd.none)

Great — but our app doesn’t do much at all, yet. Let’s fix that.

Testing Events, Commands, and Subscriptions

We’d like our app to have a text field and a button. When text is entered in the field and the button is clicked, a message with the entered text will be sent via web socket to our server. Let’s describe that behavior with a test:

module WebSocketTests exposing (..)

import Test exposing (..)
import Expect
import Elmer
import Elmer.Html as Markup
import Elmer.Html.Event as Event
import Elmer.Spy as Spy exposing (Spy, andCallFake)
import Elmer.Spy.Matchers exposing (wasCalledWith, stringArg)
import WebSocket
import App

webSocketSendSpy : Spy
webSocketSendSpy =
Spy.create "webSocketSend" (\_ -> WebSocket.send)
|> andCallFake (\_ _ -> Cmd.none)

sendTests : Test
sendTests =
describe "send message"
[ test "it says hello to the websocket" <|
\() ->
Elmer.given App.defaultModel App.view App.update
|> Spy.use [ webSocketSendSpy ]
|> Markup.target "#message-field"
|> Event.input "Hello!"
|> Markup.target "#send-button"
|> Event.click
|> Spy.expect "webSocketSend" (
wasCalledWith
[ stringArg "ws://testserver.com"
, stringArg "Hello!"
]
)
]

Testing Dom Events

Elmer provides functions for simulating dom events like a text field input or a button click. We target the element we’re interested in, and then call the appropriate function to simulate the event; see the test above for some examples of what you can do with Elmer. When an event occurs, Elmer processes the resulting message, sends it to the app’s update function, records any changes to the model and processes any commands until none remain.

Testing a Command

In our app, we expect that clicking the button will fire off a command to send a message across the web socket. To write a test for this behavior, we don’t need our app to actually connect to a web socket. Instead, we’ll spy on the function that would generate the command to connect to the web socket and assert that this function is called with the arguments we expect. We don’t need to test the WebSocket library itself; we just need to test that we are using it in the right way.

Take a look at the webSocketSendSpy function. Here we create a Spy that allows us to track calls to WebSocket.send. We then replace the normal implementation of this function with a fake that, in this case, simply returns Cmd.none.

Once we have our spy set up, we register it with our test via Elmer.Spy.use. We can then assert that it was called with the arguments we expect: the host of the web socket server and the text we entered in the text input field.

To make our test pass, we can update our app like so:

module App exposing
( Model
, defaultModel
, view
, update
)

import Html exposing (Html)
import Html.Attributes as Attr
import Html.Events as Event
import WebSocket

type Msg
= SendMessage
| MessageUpdate String

type alias Model =
{ items : List String
, message : String
}

defaultModel : Model
defaultModel =
{ items = []
, message = ""
}

view : Model -> Html Msg
view model =
Html.div []
[ Html.input
[ Attr.id "message-field"
, Event.onInput MessageUpdate
] []
, Html.button
[ Attr.id "send-button"
, Event.onClick SendMessage
] []
, Html.ul [ Attr.id "items" ] []
]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
MessageUpdate message ->
( { model | message = message }, Cmd.none )
SendMessage ->
( model, WebSocket.send "ws://testserver.com" model.message )

If you’re familiar with Elm, there should be no surprises here. Elmer’s ability to spy on functions works without any special set up in the app code itself.

Testing Subscriptions

Let’s write one more test. We’d like our app to listen for incoming messages from the web socket. We’ll assume that the server will send us a list of strings that we want to display. Here’s our test

module WebSocketTests exposing (..)

import Test exposing (..)
import Expect
import Elmer exposing (atIndex, (<&&>))
import Elmer.Html as Markup
import Elmer.Html.Matchers exposing (elements, hasText)
import Elmer.Spy as Spy exposing (Spy, andCallFake)
import Elmer.Platform.Subscription as Subscription
import WebSocket
import App

webSocketListenSpy : Spy
webSocketListenSpy =
Spy.create "webSocketListen" (\_ -> WebSocket.listen)
|> andCallFake (\_ tagger ->
Subscription.fake "webSocket" tagger
)

listenTests : Test
listenTests =
describe "listen for message"
[ describe "when a list of items is received via the websocket"
[ test "it shows the items" <|
\() ->
Elmer.given App.defaultModel App.view App.update
|> Spy.use [ webSocketListenSpy ]
|> Subscription.with (\() -> App.subscriptions)
|> Subscription.send "webSocket"
"[\"fun\",\"sun\",\"beach\"]"
|> Markup.target "#items li"
|> Markup.expect (elements <|
(atIndex 0 <| hasText "fun") <&&>
(atIndex 1 <| hasText "sun") <&&>
(atIndex 2 <| hasText "beach")
)
]
]

We use another spy to override the implementation of WebSocket.listen with a function that injects a fake subscription into our app for testing purposes.

To create the fake subscription we need to tell it how to tag data. By spying on WebSocket.listen we obtain a reference to the function used by our app to tag data received from the web socket. In webSocketSendSpy, we grab the tagger and pass it to our fake subscription. Now, when we send data through this subscription in our test, it will be tagged and processed just as if the data had come from the web socket. We’re able to test that our app handles data from the web socket subscription without knowing about implementation details like how this data is tagged.

We register the app’s subscriptions with our test using Elmer.Subscription.with. And because we’ve already registered a spy for WebSocket.listen, the subscription we register will end up being our fake. We can then send data through that subscription using Elmer.Subscription.send. Elmer will tag the data in the right way and pass it to the update function for processing. Finally, we expect that the values sent via the subscription are rendered in the dom.

Let’s update our app to make the test pass:

module App exposing
( Model
, defaultModel
, view
, update
, subscriptions
)

import Html exposing (Html)
import Html.Attributes as Attr
import Html.Events as Event
import WebSocket
import Json.Decode as Json

type Msg
= WebSocketMessage String
| SendMessage
| MessageUpdate String

type alias Model =
{ items : List String
, message : String
}

defaultModel : Model
defaultModel =
{ items = []
, message = ""
}

view : Model -> Html Msg
view model =
Html.div []
[ Html.input
[ Attr.id "message-field"
, Event.onInput MessageUpdate
] []
, Html.button
[ Attr.id "send-button"
, Event.onClick SendMessage
] []
, Html.ul
[ Attr.id "items" ] <| List.map renderItem model.items
]

renderItem : String -> Html Msg
renderItem text =
Html.li []
[ Html.text text ]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
MessageUpdate message ->
( { model | message = message }, Cmd.none )
SendMessage ->
( model, WebSocket.send "ws://testserver.com" model.message )
WebSocketMessage wsMessage ->
( { model | items = parseItems wsMessage }, Cmd.none )

parseItems : String -> List String
parseItems json =
Json.decodeString (Json.list Json.string) json
|> Result.withDefault []

subscriptions : Model -> Sub Msg
subscriptions model =
WebSocket.listen "ws://testserver.com" WebSocketMessage

Great! We test drove a cool app in Elm!

TDD with Elmer

Elmer allows us to describe the public behavior of our app in a concise way. We aren’t unit testing the view or update functions. Instead, we’re testing the flow of the Elm architecture as a whole, ensuring that all parts of the app under test work together as expected.

This approach allows us to write tests with very little knowledge of implementation details. Elmer only needs the model, view, and update; things that every Elm web app must provide. After that, our tests initiate behaviors by triggering dom events, dealing with commands, or sending data via subscriptions — just like in the real world.

This is a very good thing. When it comes to adding new features or refactoring to keep our code clean, we’ll need to change the details of our implementation. Because Elmer doesn’t care what those details are, we are free to make those changes and get quick feedback from our tests as to whether the app still has all the behavior we expect.

Like what you read? Give Brian Watkins a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.