Moving from Types to Tests with Elm

Starting on the Business Logic

billperegoy
im-becoming-functional
8 min readOct 31, 2017

--

Cleanup Up the Types

In last week’s post on type-driven design, I defined the basic types required for a dart scoring application. Thanks to @marc.minnee, I have refined things even further. He suggested that I could simplify my message types quite a bit. Instead of this monstrosity,

type Msg
= HitFifteen Magnitude PlayerId
| HitSixteen Magnitude PlayerId
| HitSeventeen Magnitude PlayerId
| HitEighteen Magnitude PlayerId
| HitNineteen Magnitude PlayerId
| HitTwenty Magnitude PlayerId
| HitBullseye BullseyeMagnitude PlayerId
| Miss

I could use something much simpler.

type Msg
= Hit PlayerId Target Magnitude
| Miss PlayerId

There was no real need to define an action for each Target type when I could simply pass that type in. Note that I had also forgotten to pass the player ID into the Miss action. I also reordered the parameters to make the actions read more naturally. This makes my actual actions look like this.

Hit Player1 Seventeen Double
Hit Player2 Fifteen Triple
Miss Player1

These actions are very clear and obviously reveal the intent of the action.

Cleary it’s worth going back and looking over your work and getting some more eyes on the code whenever possible. It’s easy to miss seemingly obvious things when you are deep in the code. I find that Elm code is perfect for pairing and peer review as the high-level types make it easier to understand the intent of the code without needing to understand all the details.

Preparing My Code for Testing

Before I can write effective tests for elm-test, I first made my code more modular. Instead of the single module named Main, I broke this up into three modules. One module named Model, contained all the type definitions. A second one called Update contains the update function as well as any functions related to update. All of the testing described below focuses on the update function. Here is that module after its split out.

module Update exposing (..)import Model exposing (..)update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
model
Hit player target magnitude ->
model

This is a basic “no-operation” update function. It imports the types that have been split out into the Model module, and pattern matches against the message type, returning the same model it received.

Implementing the First Feature

Once the types are feeling pretty solid, I move from type driven design to a more traditional test driven design. Just like in the Rails world, I like to test from the outside-in. I start with a high-level feature test and create unit tests where needed to test at a more tangible level.

In the Elm world, when I think of the highest level tests I can do without a browser, I gravitate towards tests of the update function. Here I can provide a model, execute the update function with a particular message type and conform that the model returned matches my expectations.

Here’s an example of the first feature test. This tests that when we process a Miss message type, the current dart number is incremented.

all : Test
all =
let
initPlayer1 : Player
initPlayer1 =
{ name = "Player 1"
, status = []
, score = 0
}
initPlayer2 : Player
initPlayer2 =
{ name = "Player 2"
, status = []
, score = 0
}
in
describe "A Test Suite"
[ test "Miss message type increments the currentDart" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 1
}
newModel =
Update.update (Model.Miss Player1)
initModel
in
Expect.equal newModel.currentDart 2
]

With the current update function, we get the expected elm-test error.

↓ A Test Suite
✗ Miss message type increments the currentDart
2

│ Expect.equal

1

At this point, I updated the update function to look like this.

update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
{ model | currentDart = model.currentDart + 1 }
Hit player target magnitude ->
model

This makes the test pass. We can then add a test to test that the Hit message type also increments currentDart properly.

This results in this new update code.

update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
{ model | currentDart = model.currentDart + 1 }
Hit player target magnitude ->
{ model | currentDart = model.currentDart + 1 }

With these two tests in place, we next want to make sure that the currentDart value can never increment beyond three. Here is the test for this functionality.

 test "currentDart cannot increment above 3" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 3
}
newModel =
Update.update (Model.Miss Player1) initModel
in
Expect.equal newModel.currentDart 1

To make this test pass, I’ll make the following change to the update function.

update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
{ model | currentDart = nextDart model.currentDart }
Hit player target magnitude ->
{ model | currentDart = nextDart model.currentDart }

nextDart : Int -> Int
nextDart num =
if (num >= 3) then
1
else
num + 1

At this point, I might also decide to write unit tests for the new function. Here here is an example of a unit tests for this new function.

, test "Update.nextDart properly increments" <|
\() ->
Expect.equal (Update.nextDart 1) 2
--
--
, test "Update.nextDart properly wraps" <|
\() ->
Expect.equal (Update.nextDart 3) 1

A Second Feature

With one feature tested, it’s pretty straightforward to add new features in a test-first fashion. Our next feature will be adding code that updates the current player after a third dart is thrown. I wrote two tests for this. One for throws that do not change the player and another for ones that do change the player.

, test "Player doesn't change before dart 3" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 2
}
newModel =
Update.update (Model.Miss Player1) initModel
in
Expect.equal newModel.currentTurn Player1
--
--
, test "Player changes after dart 3" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 3
}
newModel =
Update.update (Model.Miss Player1) initModel
in
Expect.equal newModel.currentTurn Player2

Running these new tests result in the first test passing and the second failing (as we would expect). Writing code to make these tests pass is pretty simple.

update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
{ model
| currentDart = nextDart model.currentDart
, currentTurn =
nextPlayer model.currentDart model.currentTurn
}
Hit player target magnitude ->
{ model
| currentDart = nextDart model.currentDart
, currentTurn =
nextPlayer model.currentDart model.currentTurn
}
nextPlayer : Int -> PlayerId -> PlayerId
nextPlayer dartNum player =
if dartNum == 3 then
changePlayer player
else
player
changePlayer : PlayerId -> PlayerId
changePlayer player =
case player of
Player1 ->
Player2
Player2 ->
Player1

You’ll note here that I modified the update function to update the currentTurn element as well as the currentDart. This makes the tests pass but you may have noticed that our tests are not complete. We only test that currentTurn is properly set for the Miss message. It should also do the same thing for the Hit message type.

While we could go back and add two more tests at the update level, those feels like the wrong place to be testing this low-level functionality. To avoid this, let’s go ahead and factor out the repetition in the update function and use unit tests to test that new functionality.

update : Msg -> Model -> Model
update msg model =
case msg of
Miss player ->
model
|> updateDartAndTurn
Hit player target magnitude ->
model
|> updateDartAndTurn
updateDartAndTurn : Model -> Model
updateDartAndTurn model =
{ model
| currentDart = nextDart model.currentDart
, currentTurn =
nextPlayer model.currentDart model.currentTurn
}

This was a pretty simple change. You’ll note that I’m now using the pipeline operator within the update case statement. This will allow us to structure the update clauses as easy to read data pipelines as we add more functionality.

With this new function in place, it’s relatively easy to write some unit tests for the new function.

, test "updateDartandTurn before dart 3 - proper player" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 2
}
newModel =
Update.updateDartAndTurn initModel
in
Expect.equal newModel.currentTurn Player1
--
--
, test "updateDartandTurn before dart 3 - proper dart" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 2
}
newModel =
Update.updateDartAndTurn initModel
in
Expect.equal newModel.currentDart 3

In addition to these two tests, I also wrote tests to test that things properly roll over after dart 3.

, test "updateDartandTurn dart 3 - proper player" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 3
}
newModel =
Update.updateDartAndTurn initModel
in
Expect.equal newModel.currentTurn Player2
--
--
, test "updateDartandTurn dart 3 - proper dart" <|
\() ->
let
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 3
}
newModel =
Update.updateDartAndTurn initModel
in
Expect.equal newModel.currentDart 1

At this point, I have one remaining test hole. I haven’t proven that when player 2 is the active player, the turn properly rotates to player 1 after dart 3. This is best tested at the lower unit level as follows.

, test "changePlayer goes from Player1 to Player2" <|
\() ->
Expect.equal (Update.changePlayer Player1) Player2
--
--
, test "changePlayer goes from Player2 to Player1" <|
\() ->
Expect.equal (Update.changePlayer Player2) Player1

At this point, we can keep following the same process as we add new features. As I continue this process, I would likely next attack these features.

  • Dart throws properly update the target Unopened status and change it to Open after the proper number of hits.
  • Dart throws properly update scores for hits on Open targets
  • Target value is set to Closed after a second player gets the appropriate number of hits on that target.
  • Scoring cannot happen on Closed targets.
  • Application detects the status indicating the game is over (all targets closed or trailing player has no more open targets) and properly marks game over.

Lessons Learned

In this post, I walked through the process I use after I have a good handle on my types. With the types and messages defined, it’s very easy to move right into writing business logic in a type-driven fashion. There are a few rules I try to follow as I dive into this process.

  1. I start with feature level tests, which means that start my testing with the update function. I dive into each message type and start defining tests that will test some aspect of the logic associated with that message type.
  2. As I see the need for new lower-level functions, I factor these out of the update logic and write unit tests for these.
  3. I look for duplicate logic and factor that out of the update function into functions that can be chained together to transform the model.
  4. I don’t even consider the view logic until the business logic is largely complete. In my mind the holy grail of type/test driven design is when I can test all of the business logic without creating a view. Once the business logic is complete, I create a view and tie HTML events to messages and I’m pretty confident that things will work with few issues.

I’m just starting to build my Elm applications using this approach so I’m sure I still have much to learn. I’m suspecting I’ll have further follow up as I add more logic to this application. As always, any constructive feedback is appreciated. This application has improved greatly through feedback I received on the last post.

--

--

billperegoy
im-becoming-functional

Polyglot programmer exploring the possibilities of functional programming.