Back to all articles

Elm Tricks from Production: Automated testing is just another tool

Illustration showing two people mounting the Elm logo

This is part of a series of posts about Elm in production at Lunar Logic. Be sure the check the first post for more context.


Since Lunar Logic is the oldest Ruby shop in Poland, it’s no wonder people have been exposed extensively to Ruby and Rails. Being part of the community, we have gotten accustomed to its values. In particular, we have used automated testing a bunch. This is made easy by the gems and tools that are available in the ecosystem.

Unfortunately, when the friction of doing something approaches zero, so does employing it without questioning its costs and benefits. As a matter of fact, testing does not come for free: tests are code and as such require time to write and maintain.

We argue that automated testing is yet another tool to achieve a shorter feedback loop. But not the only one and not the right one in every possible situation. Also, tests can be used in a variety of scenarios: TDD, unit testing, property-based testing, acceptance testing, etc.

At the unit level, where we will focus our attention in this post, sometimes it’s arguable if tests are needed. That is especially true when working with a language with a sound type system like Elm.

But let’s talk with some code in front of our eyes. We will start with a stupid example just to prove the point and then move to production code from the open source Aircasting.

The first example is the sum function:

sum a b = a + b

Should we test it? Of course not! Let’s forget for a second we shouldn’t have written the function in the first place: there’s no need to redefine +. In any case, the Elm compiler applies + only to numbers. Also, the fact that + returns the correct result should be and is guaranteed by the language maintainers. Therefore, the only problems we could introduce in the sum function are using the wrong operator (e.g. -) or not using the addends properly (e.g. sum a b = a, sum a b = a + 1).

What about the following boolToString function?

boolToInt bool =
  case bool of
    True -> 1
    False -> 0

Elm enforces callers to use a Boolean for bool. That’s because the function branches on bool with True and False. This function is so simple and declarative that we wouldn’t put the effort to test. Where we could consider a test is when composing sum and boolToInt together in a more complex sumBoolsOrDefault:

sumBoolsOrDefault predicate bool1 bool2 default =
  if predicate
    then
      sum (boolToInt bool1) (boolToInt bool2)
    else
      default

Following a functional programming style the basic building blocks of a program are simple and declarative functions. Most of the times the developer and the type system are enough to guarantee the correctness. It’s when composing simple things together that testing starts paying off. But where’s the tipping point? It depends.

Let’s take an example from AirCasting. In particular, each session at the bottom of the screen shows the timeframe in which measurements were taken. For recordings spanning multiple days we want to display “mm/dd/yyyy hh:mm - mm/dd/yyyy hh:mm” otherwise “mm/dd/yyyy hh:mm - hh:mm”:

Screenshot of AirCasting with the timeframe in a session card indicated

The logic resides in the Times module. Let’s follow the thinking process that brought us to the formatting code.

First of all, in AirCasting startTime and endTime for each session are Posix.

Therefore we first need to write functions to extract and format year, month, day, hour and minutes from Posix values.

For the year it’s enough to use toYear : Zone -> Posix -> Int and take the last two digits:

toYear posix =
  posix
    |> Time.toYear Time.utc
    |> String.fromInt
    |> String.right 2

For the day we can use toDay : Zone -> Posix -> Int and pad a “0” to the left if needed to have two digits, thus:

toDay posix =
  posix
    |> Time.toDay Time.utc
    |> String.fromInt
    |> String.padLeft 2 '0'

For the month we can use toMonth : Zone -> Posix -> Month and write a function to transform a Month into a string:

monthToString month =
  case month of
    Jan -> "01"
    Feb -> "02"
    Mar -> "03"
    ...

Hours and minutes follow a similar pattern:

toHour posix =
  posix
    |> Time.toHour Time.utc
    |> String.fromInt
    |> String.padLeft 2 '0'


toMinute posix =
  posix
    |> Time.toMinute Time.utc
    |> String.fromInt
    |> String.padLeft 2 '0'

Also, we need to distinguish between two posix values being on the same date or not:

areOnSameDate p1 p2 =
  toYear p1 == toYear p2 && toMonth p1 == toMonth p2 && toDay p1 == toDay p2

Should we test the functions mentioned above? In our case we haven’t done so. In fact, they are all simple and declarative building blocks. Now, let’s put everything together:

format start end =
  let
    toFullDate p =
      toMonth p ++ "/" ++ toDay p ++ "/" ++ toYear p ++ " " ++ toTime p
    in
      toFullDate start
        ++ (if areOnSameDate start end
          then
            "-" ++ toTime end
          else
            " - " ++ toFullDate end
          )

We used types to drive the development and the format function is where we decided to test. That’s because the function is “complex” enough to necessitate test coverage.

What defines “complex” is hard to say. But I can steal some wisdom out of an Elm discourse thread:

  • it’s hard to predict;
  • tricky code;
  • I fear or I know I will fear of changing some functions;
  • encoders / decoders;
  • sequence of actions for data;
  • logic that can’t easily be encoded in the type system;
  • correctness in very important parts.

Be sure to read the thread mentioned above because it’s full of great stuff. Thanks a lot to all the people who have participated and shared so much there!

Special thanks to Rafał for proofreading this post.

Share this article: