Controlled hallucination

Learning Elm Part 2 - Tests

Published: - Reading time: 9 minutes

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:

Rendered Doc Comment

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. groupWhile uses 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:

  1. I still need to sort my list beforehand
  2. 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
  1. First I sorted the list by date descending using List.sortWith
  2. Then I grouped by date using groupWhile. At this point I had my List ( Transaction, List Transaction )
  3. 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 of T transaction. The remaining list is simply mapped to the T constructor.
  4. 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.