Learning Elm Part 2 - Tests
In part 1 of this series we used create-elm-app - inspired by create-react-app - to start a simple expenses tracking webapp.
Part 2 will be short and simple. I’ll add some tests and refactor one part of the code.
As always, you can find a deployed version here and the source code here.
When I installed elm-test in the previous post via npm install -g elm-test, it installed a different version from
the one provided by create-elm-app, so I got this message
when I tried running the tests:
❯ elm-test
/home/pablo/Code/elm-expenses/elm.json
This version of elm-test only supports elm-explorations/test 2.x, but you have "1.0.0".
To “reinstall” elm-test I first removed the dependency from elm.json:
"test-dependencies": {
"direct": {
- "elm-explorations/test": "1.0.0"
},
"indirect": {
"elm/random": "1.0.0"
}
}
And then simply ran elm-test init. This will install the test dependencies and create a sample test file.
Once that was done, I deleted the previous sample test file
tests/Tests.elm and renamed the new one:
mv tests/Example.elm tests/MainTests.elm
Right now, there is not much going on in the app, but I decided
to add a simple test case for the JSON parsing, one for the
update function that receives the list of transactions, and
one for the view function that renders the list.
Test JSON parsing
I have not yet added any validation logic yet, and adding fuzz tests seems like an overkill at this point. I chose to do a simple unit test for the happy-path. In this point of the project, the Elm assumes data from JavaScript is sane. This is the test:
transactionDecoderTest : Test
transactionDecoderTest =
let
expected : Transaction
expected =
buildTransaction
(TransactionInput
2023
Dec
29
"Supermarket"
"Expenses:Groceries"
"Assets:Cash"
3599
)
in
test "It decodes transactions" <|
\_ ->
"""
{
"date": "2023-12-29",
"description": "Supermarket",
"destination": {
"account": "Expenses:Groceries",
"amount": 3599,
"currency": "USD"
},
"source": {
"account": "Assets:Cash",
"amount": -3599,
"currency": "USD"
}
}
"""
|> decodeString transactionDecoder
|> Expect.equal (Ok expected)
I created a helper function because I will be using it later to create more tests. Creating Transactions is quite verbose right now. The helper function uses this type alias:
type alias TransactionInput =
{ year : Int
, month : Month
, day : Int
, description : String
, destination : String
, source : String
, amount : Int
}
Which I might move to the main program when I implement the create/edit functionality.
The helper function simply calls the Transaction constructor
after building the Date. The currency is hardcoded to USD:
buildTransaction : TransactionInput -> Transaction
buildTransaction input =
Transaction
(Date.fromCalendarDate input.year input.month input.day)
input.description
(Entry input.destination "USD" input.amount)
(Entry input.source "USD" (-1 * input.amount))
Test updating the model
In the following test, I wanted to verify the grouping and
ordering of the transactions. I would not call this a good unit test, because you have to read into what the buildSampleTransactions is doing in order to fully understand the test. But as I am writing this for me, I decided to leave myself some decent comments at least.
gotTransactionsSetsListItems : Test
gotTransactionsSetsListItems =
let
( transactions, expected ) =
buildSampleTransactions ()
in
test "It sets ListItems" <|
\_ ->
initialModel
|> update (GotTransactions (Ok transactions))
|> Tuple.first
|> .listItems
|> Expect.equal expected
{-| Builds a list of Transactions, and a list of expected ListItems.
We create three transactions:
- Two for 2023-12-29
- One for 2023-12-30
We expect 5 ListItems, and we expect them sorted descending by date:
1. Date 2023-12-30
2. T3
3. Date 2023-12-29
4. T2
5. T1
-}
buildSampleTransactions : () -> ( List Transaction, List ListItem )
buildSampleTransactions _ =
let
t0 : Transaction
t0 =
buildTransaction
(TransactionInput
2023
Dec
29
"Supermarket"
"Expenses:Groceries"
"Assets:Cash"
3599
)
t1 : Transaction
t1 =
buildTransaction
(TransactionInput
2023
Dec
29
"Gas"
"Expenses:Auto"
"Assets:Cash"
9923
)
t2 : Transaction
t2 =
buildTransaction
(TransactionInput
2023
Dec
30
"Lunch"
"Expenses:Eat Out"
"Assets:Cash"
9923
)
in
( [ t0, t1, t2 ], [ D t2.date, T t2, D t1.date, T t1, T t0 ] )
I had not written documentation comments for Elm before, and
I was pleasantly surprised that the IDE picked them up with no problem. I also noted that elm-format reformatted by markdown:

Test the view
I used the same test data for testing the simple view:
emptyListItemsIsEmptyList : Test
emptyListItemsIsEmptyList =
test "No transactions means no rows" <|
\_ ->
initialModel
|> view
|> Query.fromHtml
|> Query.findAll [ class "item" ]
|> Query.count (Expect.equal 0)
transactionsAreGroupedByDate : Test
transactionsAreGroupedByDate =
let
( transactions, listItems ) =
buildSampleTransactions ()
model : Model
model =
{ initialModel | transactions = transactions, listItems = listItems }
in
test "Three transactions in two days means five rows" <|
\_ ->
model
|> view
|> Query.fromHtml
|> Query.findAll [ class "item" ]
|> Query.count (Expect.equal 5)
After a couple of adjustments, the tests were all green:
❯ elm-test Compiling > Starting tests elm-test 0.19.1-revision12 -------------------------- Running 4 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 217435759278428 TEST RUN PASSED Duration: 154 ms Passed: 4 Failed: 0
I was not very keen into reading the whole Query.findAll [ class "item" ] documentation. I tried doing a more specific query but ended up having more nodes returned. Duplicated nodes actually, so I will have to read more about that later.
Refactor grouping Transactions
For the first post, I had implemented my grouping using only List and Dict. Having had that small experience, I now wanted to try List.Extra, so I installed it with
elm install elm-community/list-extra and read the documentation for groupWhile:
Group elements together, using a custom comparison test
(a -> a -> Bool). Start a new group each time the comparison test doesn’t hold for two adjacent elements.groupWhileuses a non-empty list type(a, List a)since groups necessarily must have at least one member since they are determined by comparing two members.
The function signature is:
groupWhile : (a -> a -> Bool) -> List a -> List ( a, List a )
So:
- I still need to sort my list beforehand
- The shape of the returned data is not exactly what I expected, but it is reasonable.
After I call groupWhile with my minimum use-case of three transactions, I would have something like this in JSON-like syntax:
[
// first group
[
{ "date": "2023-12-29" /*...*/ }, // T3
[]
],
// second group
[
{ "date": "2023-12-30" /*...*/ }, // T2
[
{ "date": "2023-12-30" /*...*/ } // T1
]
]
]
This is the first implementation I came up with:
buildListItems : List Transaction -> List ListItem
buildListItems txns =
let
grouped : List ( Transaction, List Transaction )
grouped =
txns
|> List.sortWith
(\a b -> compare (Date.toIsoString b.date) (Date.toIsoString a.date))
|> List.Extra.groupWhile (\a b -> a.date == b.date)
listItems : List ListItem
listItems =
grouped
|> List.map
(\nonEmptyList ->
let
( head, tail ) =
nonEmptyList
in
D head.date
:: T head
:: List.map T tail
)
|> List.concat
in
listItems
- First I sorted the list by date descending using List.sortWith
- Then I grouped by date using
groupWhile. At this point I had myList ( Transaction, List Transaction ) - The final step was mapping that into my ListItem type. For this I used the first item twice, once to create my
D date, and then to add it to the list ofT transaction. The remaining list is simply mapped to theTconstructor. - I flatten the list of lists with
List.concat
And then I confidently run my tests, expecting it to work, because hey, it compiles! But surprise surprise:
❯ elm-test
Compiling > Starting tests
elm-test 0.19.1-revision12
--------------------------
Running 4 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 161743721040205
↓ MainTests
✗ It sets ListItems
[D (RD 738884),T { date = RD 738884, description = "Lunch", destination = { account = "Expenses:Eat Out", amount = 9923, currency = "USD" }, source = { account = "Assets:Cash", amount = -9923, currency = "USD" } },D (RD 738883),T { date = RD 738883, description = "Supermarket", destination = { account = "Expenses:Groceries", amount = 3599, currency = "USD" }, source = { account = "Assets:Cash", amount = -3599, currency = "USD" } },T { date = RD 738883, description = "Gas", destination = { account = "Expenses:Auto", amount = 9923, currency = "USD" }, source = { account = "Assets:Cash", amount = -9923, currency = "USD" } }]
╷
│ Expect.equal
╵
[D (RD 738884),T { date = RD 738884, description = "Lunch", destination = { account = "Expenses:Eat Out", amount = 9923, currency = "USD" }, source = { account = "Assets:Cash", amount = -9923, currency = "USD" } },D (RD 738883),T { date = RD 738883, description = "Gas", destination = { account = "Expenses:Auto", amount = 9923, currency = "USD" }, source = { account = "Assets:Cash", amount = -9923, currency = "USD" } },T { date = RD 738883, description = "Supermarket", destination = { account = "Expenses:Groceries", amount = 3599, currency = "USD" }, source = { account = "Assets:Cash", amount = -3599, currency = "USD" } }]
TEST RUN FAILED
Duration: 188 ms
Passed: 3
Failed: 1
The reason is the ordering of the two transactions of December 30th. I was just lucky on my first test implementation to obtain the same ordering as the actual implementation.
I decided to order them descending by description, because… well, because the test says so! In reality, I’ll later sort them by “last added”, which is more natural. But I needed to make the sort “deterministic”, or at least, “better specified”. So I changed the sortWith to this:
txns
|> List.sortWith
(\a b ->
case compare (Date.toIsoString b.date) (Date.toIsoString a.date) of
EQ ->
compare a.description b.description
LT ->
LT
GT ->
GT
)
And now the tests are green again!
Final comments
I am not sure about how to best test Elm apps because I have not read nor written much Elm code. I am hoping to use fuzz test later in the series, when I get into accepting input from the user.
I did find it a bit weird that I needed to export things in order for them to be tested. I like that in Rust, for example, you can have the unit tests right along the source code, and so you can test small things without exporting them.
I understand that to compose parts of a “single-page application” in Elm, I’d still need to expose most of these, so I guess it boils down to unfamiliarity.
In the first post, I mentioned I enjoyed the |> syntax. And I do. But I did skip the part where the documentation clearly states:
Note: This can be overused! I think folks find it quite neat, but when you have three or four steps, the code often gets clearer if you break out a top-level helper function. Now the transformation has a name. The arguments are named. It has a type annotation. It is much more self-documenting that way! Testing the logic gets easier too. Nice side benefit!
I totally agree. Even for small, simple things like the ones above, I found that naming and writing and seeing the intermediate types helped me a lot.
Looking forward
I still need to add handle (and test) bad JSON input. I also need to decide how those errors will be rendered. That will probably be the beginning of the next part.